leangraph 1.1.1 → 1.1.3

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 (40) hide show
  1. package/dist/db.d.ts.map +1 -1
  2. package/dist/db.js +42 -3
  3. package/dist/db.js.map +1 -1
  4. package/dist/engine/hybrid-executor.d.ts +118 -0
  5. package/dist/engine/hybrid-executor.d.ts.map +1 -0
  6. package/dist/engine/hybrid-executor.js +205 -0
  7. package/dist/engine/hybrid-executor.js.map +1 -0
  8. package/dist/engine/index.d.ts +36 -0
  9. package/dist/engine/index.d.ts.map +1 -0
  10. package/dist/engine/index.js +34 -0
  11. package/dist/engine/index.js.map +1 -0
  12. package/dist/engine/memory-graph.d.ts +68 -0
  13. package/dist/engine/memory-graph.d.ts.map +1 -0
  14. package/dist/engine/memory-graph.js +176 -0
  15. package/dist/engine/memory-graph.js.map +1 -0
  16. package/dist/engine/query-planner.d.ts +62 -0
  17. package/dist/engine/query-planner.d.ts.map +1 -0
  18. package/dist/engine/query-planner.js +481 -0
  19. package/dist/engine/query-planner.js.map +1 -0
  20. package/dist/engine/subgraph-loader.d.ts +41 -0
  21. package/dist/engine/subgraph-loader.d.ts.map +1 -0
  22. package/dist/engine/subgraph-loader.js +172 -0
  23. package/dist/engine/subgraph-loader.js.map +1 -0
  24. package/dist/executor.d.ts +17 -0
  25. package/dist/executor.d.ts.map +1 -1
  26. package/dist/executor.js +286 -100
  27. package/dist/executor.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/parser.d.ts +47 -3
  33. package/dist/parser.d.ts.map +1 -1
  34. package/dist/parser.js +228 -41
  35. package/dist/parser.js.map +1 -1
  36. package/dist/translator.d.ts +53 -0
  37. package/dist/translator.d.ts.map +1 -1
  38. package/dist/translator.js +1548 -183
  39. package/dist/translator.js.map +1 -1
  40. package/package.json +9 -3
@@ -102,7 +102,17 @@ export class Translator {
102
102
  statements.push({ sql, params });
103
103
  returnColumns = [callClause.returnColumn];
104
104
  }
105
- return { statements, returnColumns };
105
+ // Handle EXPLAIN and PROFILE - prefix all SELECT statements with EXPLAIN QUERY PLAN
106
+ if (query.explain || query.profile) {
107
+ for (const stmt of statements) {
108
+ if (stmt.sql.trim().toUpperCase().startsWith("SELECT")) {
109
+ stmt.sql = `EXPLAIN QUERY PLAN ${stmt.sql}`;
110
+ }
111
+ }
112
+ // For explain/profile, the return columns are the plan columns
113
+ returnColumns = ["id", "parent", "notused", "detail"];
114
+ }
115
+ return { statements, returnColumns, explain: query.explain, profile: query.profile };
106
116
  }
107
117
  /**
108
118
  * Validate and translate a SKIP or LIMIT expression
@@ -151,6 +161,10 @@ export class Translator {
151
161
  return this.translateUnion(clause);
152
162
  case "CALL":
153
163
  return this.translateCall(clause);
164
+ case "CREATE_INDEX":
165
+ return { statements: this.translateCreateIndex(clause) };
166
+ case "DROP_INDEX":
167
+ return { statements: this.translateDropIndex(clause) };
154
168
  default:
155
169
  throw new Error(`Unknown clause type: ${clause.type}`);
156
170
  }
@@ -188,7 +202,7 @@ export class Translator {
188
202
  ? (Array.isArray(node.label) ? node.label : [node.label])
189
203
  : [];
190
204
  const labelJson = JSON.stringify(labelArray);
191
- const properties = this.serializeProperties(node.properties || {});
205
+ const properties = this.serializeNodePatternProperties(node);
192
206
  if (node.variable) {
193
207
  this.ctx.variables.set(node.variable, { type: "node", alias: id });
194
208
  // Store the resolved properties so later nodes in the same CREATE can reference them
@@ -202,6 +216,24 @@ export class Translator {
202
216
  params: [id, labelJson, properties.json],
203
217
  };
204
218
  }
219
+ serializeNodePatternProperties(node) {
220
+ const literal = this.serializeProperties(node.properties || {});
221
+ if (!node.propertiesParam) {
222
+ return literal;
223
+ }
224
+ const paramValue = this.ctx.paramValues[node.propertiesParam.name];
225
+ if (paramValue === undefined || paramValue === null) {
226
+ return literal;
227
+ }
228
+ if (typeof paramValue !== "object" || Array.isArray(paramValue)) {
229
+ throw new Error(`Expected parameter $${node.propertiesParam.name} to be a map`);
230
+ }
231
+ const merged = {
232
+ ...paramValue,
233
+ ...JSON.parse(literal.json),
234
+ };
235
+ return { json: JSON.stringify(merged), params: [] };
236
+ }
205
237
  translateCreateRelationship(rel) {
206
238
  const statements = [];
207
239
  // Create source node if it has a label (new node)
@@ -211,7 +243,7 @@ export class Translator {
211
243
  if (existing) {
212
244
  // Variable already bound - check for label or property conflict
213
245
  // In CREATE, you cannot rebind a variable with new properties/labels
214
- if (rel.source.label || rel.source.properties) {
246
+ if (rel.source.label || rel.source.properties || rel.source.propertiesParam) {
215
247
  throw new Error(`Variable \`${rel.source.variable}\` already declared`);
216
248
  }
217
249
  // Use the actual ID if available (from MERGE), otherwise use alias (from CREATE)
@@ -237,7 +269,7 @@ export class Translator {
237
269
  if (existing) {
238
270
  // Variable already bound - check for label or property conflict
239
271
  // In CREATE, you cannot rebind a variable with new properties/labels
240
- if (rel.target.label || rel.target.properties) {
272
+ if (rel.target.label || rel.target.properties || rel.target.propertiesParam) {
241
273
  throw new Error(`Variable \`${rel.target.variable}\` already declared`);
242
274
  }
243
275
  // Use the actual ID if available (from MERGE), otherwise use alias (from CREATE)
@@ -272,6 +304,28 @@ export class Translator {
272
304
  return statements;
273
305
  }
274
306
  // ============================================================================
307
+ // CREATE INDEX / DROP INDEX
308
+ // ============================================================================
309
+ /**
310
+ * Translate CREATE INDEX to SQL.
311
+ * Creates a global index on a JSON property (not filtered by label).
312
+ *
313
+ * Syntax: CREATE INDEX [name] ON [:Label](property)
314
+ * The :Label is optional and ignored (global index) - only used if no custom name provided.
315
+ */
316
+ translateCreateIndex(clause) {
317
+ const indexName = clause.indexName || `idx_${clause.property}`;
318
+ const sql = `CREATE INDEX IF NOT EXISTS ${indexName} ON nodes(json_extract(properties, '$.${clause.property}'))`;
319
+ return [{ sql, params: [] }];
320
+ }
321
+ /**
322
+ * Translate DROP INDEX to SQL.
323
+ */
324
+ translateDropIndex(clause) {
325
+ const sql = `DROP INDEX IF EXISTS ${clause.indexName}`;
326
+ return [{ sql, params: [] }];
327
+ }
328
+ // ============================================================================
275
329
  // MATCH
276
330
  // ============================================================================
277
331
  translateMatch(clause, optional = false) {
@@ -1068,11 +1122,18 @@ export class Translator {
1068
1122
  // Validate ORDER BY with aggregation
1069
1123
  // When aggregation is used in RETURN/WITH, ORDER BY expressions with mixed aggregate/non-aggregate
1070
1124
  // are not allowed unless the non-aggregate parts are in an implicit GROUP BY
1125
+ // Note: Only validate the RETURN clause's own ORDER BY, not a previous WITH's ORDER BY.
1126
+ // A WITH clause's ORDER BY applies to pre-aggregation rows and was already validated against WITH items.
1071
1127
  const hasAggregation = clause.items.some(item => this.isAggregateExpression(item.expression));
1072
- const prevWithOrderBy = this.ctx.withOrderBy;
1073
- const orderByToValidate = clause.orderBy && clause.orderBy.length > 0 ? clause.orderBy : prevWithOrderBy;
1074
- if (hasAggregation && orderByToValidate && orderByToValidate.length > 0) {
1075
- this.validateAggregationOrderBy(clause, orderByToValidate);
1128
+ if (hasAggregation && clause.orderBy && clause.orderBy.length > 0) {
1129
+ this.validateAggregationOrderBy(clause, clause.orderBy);
1130
+ }
1131
+ // When RETURN has aggregation and there's a previous WITH ORDER BY (or row ordering),
1132
+ // use that to order values within COLLECT() functions. This enables patterns like:
1133
+ // UNWIND [3,1,2] as n WITH n ORDER BY n RETURN collect(n) -> [1,2,3]
1134
+ const rowOrderBy = this.ctx.rowOrderBy;
1135
+ if (hasAggregation && rowOrderBy && rowOrderBy.length > 0) {
1136
+ this.ctx.collectOrderBy = rowOrderBy;
1076
1137
  }
1077
1138
  const selectParts = [];
1078
1139
  const returnColumns = [];
@@ -1192,11 +1253,28 @@ export class Translator {
1192
1253
  aggregateAliasesInListPredicates.add(alias);
1193
1254
  }
1194
1255
  }
1195
- // If we have any aggregate aliases used in list predicates, we need a CTE
1196
- const needsAggregateCTE = aggregateAliasesInListPredicates.size > 0;
1256
+ // Check for "double aggregation" - when RETURN has aggregates that reference WITH aggregates.
1257
+ // This causes nested aggregate function errors in SQLite (e.g., collect({v: vals}) where vals = collect(...))
1258
+ // We need to materialize the WITH aggregates in a CTE first.
1259
+ const aggregateAliasesInReturnAggregates = new Set();
1260
+ if (hasAggregation && withAliasesForCheck) {
1261
+ for (const item of clause.items) {
1262
+ const aliases = this.collectWithAggregateAliasesFromAggregateExpressions(item.expression, withAliasesForCheck);
1263
+ for (const alias of aliases) {
1264
+ aggregateAliasesInReturnAggregates.add(alias);
1265
+ }
1266
+ }
1267
+ }
1268
+ // Combine both sets of aliases that need materialization
1269
+ const allAggregateAliasesToMaterialize = new Set([
1270
+ ...aggregateAliasesInListPredicates,
1271
+ ...aggregateAliasesInReturnAggregates
1272
+ ]);
1273
+ // If we have any aggregate aliases that need materialization, we need a CTE
1274
+ const needsAggregateCTE = allAggregateAliasesToMaterialize.size > 0;
1197
1275
  if (needsAggregateCTE) {
1198
1276
  // Mark the context so translateExpression knows to use column references instead of re-translating
1199
- this.ctx.materializedAggregateAliases = aggregateAliasesInListPredicates;
1277
+ this.ctx.materializedAggregateAliases = allAggregateAliasesToMaterialize;
1200
1278
  // Also mark that we need to use __aggregates__ as the FROM source
1201
1279
  this.ctx.useAggregatesCTE = true;
1202
1280
  }
@@ -1237,7 +1315,7 @@ export class Translator {
1237
1315
  const { sql: exprSql, tables, params: itemParams } = this.translateExpression(item.expression);
1238
1316
  tables.forEach((t) => neededTables.add(t));
1239
1317
  exprParams.push(...itemParams);
1240
- const alias = item.alias || this.getExpressionName(item.expression);
1318
+ const alias = this.getReturnItemName(item);
1241
1319
  returnAliasExpressions.set(alias, item.expression);
1242
1320
  selectParts.push(`${exprSql} AS ${this.quoteAlias(alias)}`);
1243
1321
  returnColumns.push(alias);
@@ -2319,7 +2397,7 @@ export class Translator {
2319
2397
  // Computed expression - need to translate it
2320
2398
  const { sql: exprSql, params: exprParams } = this.translateExpression(item.expression);
2321
2399
  whereParams.push(...exprParams);
2322
- const alias = item.alias || this.getExpressionName(item.expression);
2400
+ const alias = this.getReturnItemName(item);
2323
2401
  withSelectParts.push(`${exprSql} AS "${alias}"`);
2324
2402
  }
2325
2403
  else {
@@ -2421,6 +2499,12 @@ export class Translator {
2421
2499
  sql += ` WHERE ${whereParts.join(" AND ")}`;
2422
2500
  }
2423
2501
  }
2502
+ else if (whereParts.length > 0) {
2503
+ // If we have WHERE but no FROM or JOINs, we need a dummy FROM clause
2504
+ // This happens with queries like: WITH [1,2,3] as nums WHERE all(x IN nums WHERE x > 0) RETURN nums
2505
+ sql += ` FROM (SELECT 1) __dummy__`;
2506
+ sql += ` WHERE ${whereParts.join(" AND ")}`;
2507
+ }
2424
2508
  // Add GROUP BY for aggregation (from WITH or RETURN clauses)
2425
2509
  // When we have aggregates mixed with non-aggregates, non-aggregate expressions become GROUP BY keys
2426
2510
  const groupByParts = [];
@@ -2528,44 +2612,123 @@ export class Translator {
2528
2612
  const materializedAggregates = this.ctx.materializedAggregateAliases;
2529
2613
  const useAggregatesCTE = this.ctx.useAggregatesCTE;
2530
2614
  if (materializedAggregates && materializedAggregates.size > 0 && useAggregatesCTE) {
2531
- // Build the CTE that computes the aggregates
2532
- const cteSelectParts = [];
2533
- const cteParams = [];
2534
2615
  const withAliasesFinal = this.ctx.withAliases;
2535
- for (const aliasName of materializedAggregates) {
2536
- if (withAliasesFinal && withAliasesFinal.has(aliasName)) {
2537
- const originalExpr = withAliasesFinal.get(aliasName);
2538
- // Temporarily clear materializedAggregateAliases to get the actual aggregate SQL
2616
+ const lastWithClause = this.ctx.withClauses?.[this.ctx.withClauses.length - 1];
2617
+ // Check if this is a MATCH-based double aggregation (WITH has aggregates from MATCH)
2618
+ // In this case we need to build a complete CTE with all WITH items, FROM, WHERE, GROUP BY
2619
+ // We distinguish between MATCH-based (which uses nodes/edges tables) and UNWIND-based
2620
+ // (which uses json_each). For UNWIND-only queries, we use the original CTE logic.
2621
+ // MATCH creates pattern_${alias} entries in context, while UNWIND does not.
2622
+ const hasMatchPatterns = Object.keys(this.ctx).some(k => k.startsWith("pattern_"));
2623
+ const hasMatchBasedAggregation = lastWithClause &&
2624
+ lastWithClause.items.some(item => this.isAggregateExpression(item.expression)) &&
2625
+ hasMatchPatterns;
2626
+ if (hasMatchBasedAggregation && lastWithClause && withAliasesFinal) {
2627
+ // Build a complete CTE that materializes the entire WITH clause
2628
+ const cteSelectParts = [];
2629
+ const cteGroupByParts = [];
2630
+ const cteParams = [];
2631
+ // Include ALL WITH items in the CTE (both aggregate and non-aggregate)
2632
+ // This is needed so the main query can reference all WITH aliases
2633
+ for (const item of lastWithClause.items) {
2634
+ const aliasName = this.getReturnItemName(item);
2635
+ // Temporarily clear materializedAggregateAliases to get the actual SQL
2539
2636
  this.ctx.materializedAggregateAliases = undefined;
2540
- const { sql: aggSql, params: aggParams } = this.translateExpression(originalExpr);
2637
+ const { sql: itemSql, params: itemParams } = this.translateExpression(item.expression);
2541
2638
  this.ctx.materializedAggregateAliases = materializedAggregates;
2542
- cteSelectParts.push(`${aggSql} AS "${aliasName}"`);
2543
- cteParams.push(...aggParams);
2544
- }
2545
- }
2546
- if (cteSelectParts.length > 0) {
2547
- // Build the CTE FROM clause (same as main query's FROM)
2548
- // We need to replicate the UNWIND/FROM structure
2549
- const unwindClausesFinal = this.ctx.unwindClauses;
2550
- let cteFrom = "";
2551
- if (unwindClausesFinal && unwindClausesFinal.length > 0) {
2552
- const cteParts = [];
2553
- for (const unwind of unwindClausesFinal) {
2554
- cteParts.push(`json_each(${unwind.jsonExpr}) ${unwind.alias}`);
2555
- cteParams.push(...unwind.params);
2639
+ cteSelectParts.push(`${itemSql} AS "${aliasName}"`);
2640
+ cteParams.push(...itemParams);
2641
+ // Non-aggregate items become GROUP BY keys
2642
+ if (!this.isAggregateExpression(item.expression)) {
2643
+ // Re-translate for GROUP BY (might need different SQL for grouping)
2644
+ this.ctx.materializedAggregateAliases = undefined;
2645
+ const { sql: groupSql, params: groupParams } = this.translateExpression(item.expression);
2646
+ this.ctx.materializedAggregateAliases = materializedAggregates;
2647
+ cteGroupByParts.push(groupSql);
2648
+ // Don't double-add params - they're already in cteParams from SELECT
2556
2649
  }
2557
- cteFrom = cteParts.join(" CROSS JOIN ");
2558
2650
  }
2559
- // Build the CTE SQL
2560
- let cteSql = `WITH __aggregates__ AS (SELECT ${cteSelectParts.join(", ")}`;
2561
- if (cteFrom) {
2562
- cteSql += ` FROM ${cteFrom}`;
2651
+ // Build the CTE with full FROM/JOIN and WHERE from the main query
2652
+ let cteSql = `WITH __with_results__ AS (SELECT ${cteSelectParts.join(", ")}`;
2653
+ // Add FROM clause
2654
+ if (fromParts.length > 0) {
2655
+ cteSql += ` FROM ${fromParts.join(", ")}`;
2656
+ }
2657
+ // Add JOINs
2658
+ if (joinParts.length > 0) {
2659
+ cteSql += ` ${joinParts.join(" ")}`;
2660
+ cteParams.push(...joinParams);
2661
+ }
2662
+ // Add WHERE conditions
2663
+ if (whereParts.length > 0) {
2664
+ cteSql += ` WHERE ${whereParts.join(" AND ")}`;
2665
+ cteParams.push(...whereParams);
2666
+ }
2667
+ // Add GROUP BY for non-aggregate items
2668
+ if (cteGroupByParts.length > 0) {
2669
+ cteSql += ` GROUP BY ${cteGroupByParts.join(", ")}`;
2563
2670
  }
2564
2671
  cteSql += `) `;
2565
- // Prepend CTE to main query
2566
- sql = cteSql + sql;
2567
- // Prepend CTE params (only CTE params, exprParams don't use UNWIND params since we use CTE)
2568
- allParams = [...cteParams];
2672
+ // Now rebuild the main query to SELECT from the CTE
2673
+ // The RETURN expressions need to reference CTE columns instead of table columns
2674
+ const mainSelectParts = [];
2675
+ const mainParams = [];
2676
+ // Store mapping of WITH aliases to CTE column references
2677
+ const cteColumnMap = new Map();
2678
+ for (const item of lastWithClause.items) {
2679
+ const aliasName = this.getReturnItemName(item);
2680
+ cteColumnMap.set(aliasName, `__with_results__."${aliasName}"`);
2681
+ }
2682
+ // Rebuild RETURN expressions using CTE column references
2683
+ for (const item of clause.items) {
2684
+ const alias = this.getReturnItemName(item);
2685
+ // Translate expression with CTE column mapping
2686
+ const exprSql = this.translateExpressionWithCTEMapping(item.expression, cteColumnMap, mainParams);
2687
+ mainSelectParts.push(`${exprSql} AS ${this.quoteAlias(alias)}`);
2688
+ }
2689
+ // Build the main query that selects from the CTE
2690
+ sql = cteSql + `SELECT ${mainSelectParts.join(", ")} FROM __with_results__`;
2691
+ allParams = [...cteParams, ...mainParams];
2692
+ }
2693
+ else {
2694
+ // Original CTE logic for UNWIND-based cases
2695
+ const cteSelectParts = [];
2696
+ const cteParams = [];
2697
+ for (const aliasName of materializedAggregates) {
2698
+ if (withAliasesFinal && withAliasesFinal.has(aliasName)) {
2699
+ const originalExpr = withAliasesFinal.get(aliasName);
2700
+ // Temporarily clear materializedAggregateAliases to get the actual aggregate SQL
2701
+ this.ctx.materializedAggregateAliases = undefined;
2702
+ const { sql: aggSql, params: aggParams } = this.translateExpression(originalExpr);
2703
+ this.ctx.materializedAggregateAliases = materializedAggregates;
2704
+ cteSelectParts.push(`${aggSql} AS "${aliasName}"`);
2705
+ cteParams.push(...aggParams);
2706
+ }
2707
+ }
2708
+ if (cteSelectParts.length > 0) {
2709
+ // Build the CTE FROM clause (same as main query's FROM)
2710
+ // We need to replicate the UNWIND/FROM structure
2711
+ const unwindClausesFinal = this.ctx.unwindClauses;
2712
+ let cteFrom = "";
2713
+ if (unwindClausesFinal && unwindClausesFinal.length > 0) {
2714
+ const cteParts = [];
2715
+ for (const unwind of unwindClausesFinal) {
2716
+ cteParts.push(`json_each(${unwind.jsonExpr}) ${unwind.alias}`);
2717
+ cteParams.push(...unwind.params);
2718
+ }
2719
+ cteFrom = cteParts.join(" CROSS JOIN ");
2720
+ }
2721
+ // Build the CTE SQL
2722
+ let cteSql = `WITH __aggregates__ AS (SELECT ${cteSelectParts.join(", ")}`;
2723
+ if (cteFrom) {
2724
+ cteSql += ` FROM ${cteFrom}`;
2725
+ }
2726
+ cteSql += `) `;
2727
+ // Prepend CTE to main query
2728
+ sql = cteSql + sql;
2729
+ // Prepend CTE params (only CTE params, exprParams don't use UNWIND params since we use CTE)
2730
+ allParams = [...cteParams];
2731
+ }
2569
2732
  }
2570
2733
  // Clean up context
2571
2734
  this.ctx.materializedAggregateAliases = undefined;
@@ -2892,7 +3055,7 @@ export class Translator {
2892
3055
  exprSql = translated.sql;
2893
3056
  params.push(...translated.params);
2894
3057
  }
2895
- const alias = item.alias || this.getExpressionName(item.expression);
3058
+ const alias = this.getReturnItemName(item);
2896
3059
  selectParts.push(`${exprSql} AS ${this.quoteAlias(alias)}`);
2897
3060
  returnColumns.push(alias);
2898
3061
  }
@@ -4108,6 +4271,98 @@ export class Translator {
4108
4271
  }
4109
4272
  return undefined;
4110
4273
  }
4274
+ /**
4275
+ * Translate an expression, substituting WITH alias references with CTE column references.
4276
+ * This is used when we've materialized WITH results in a CTE and need to build
4277
+ * the RETURN query that references those CTE columns.
4278
+ */
4279
+ translateExpressionWithCTEMapping(expr, cteColumnMap, params) {
4280
+ switch (expr.type) {
4281
+ case "variable": {
4282
+ const varName = expr.variable;
4283
+ // Check if this is a WITH alias that maps to a CTE column
4284
+ if (cteColumnMap.has(varName)) {
4285
+ return cteColumnMap.get(varName);
4286
+ }
4287
+ // For other variables, translate normally
4288
+ const { sql, params: exprParams } = this.translateExpression(expr);
4289
+ params.push(...exprParams);
4290
+ return sql;
4291
+ }
4292
+ case "function": {
4293
+ const funcName = (expr.functionName || expr.name || "").toLowerCase();
4294
+ const args = expr.args || [];
4295
+ // Handle aggregate functions
4296
+ if (funcName === "collect") {
4297
+ // collect({...}) becomes json_group_array(json_object(...))
4298
+ if (args.length === 1 && args[0].type === "object") {
4299
+ const objExpr = args[0];
4300
+ const objParts = [];
4301
+ for (const prop of objExpr.properties || []) {
4302
+ const key = prop.key;
4303
+ const valueSql = this.translateExpressionWithCTEMapping(prop.value, cteColumnMap, params);
4304
+ objParts.push(`?`);
4305
+ params.push(key);
4306
+ objParts.push(valueSql);
4307
+ }
4308
+ return `json_group_array(json_object(${objParts.join(", ")}))`;
4309
+ }
4310
+ // Simple collect
4311
+ const argSql = this.translateExpressionWithCTEMapping(args[0], cteColumnMap, params);
4312
+ return `json_group_array(${argSql})`;
4313
+ }
4314
+ // For other functions, translate arguments with CTE mapping
4315
+ const argSqls = args.map(arg => this.translateExpressionWithCTEMapping(arg, cteColumnMap, params));
4316
+ // Map function names to SQL
4317
+ const funcNameUpper = funcName.toUpperCase();
4318
+ if (["COUNT", "SUM", "AVG", "MIN", "MAX"].includes(funcNameUpper)) {
4319
+ return `${funcNameUpper}(${argSqls.join(", ")})`;
4320
+ }
4321
+ // Fall back to standard translation for other functions
4322
+ const { sql, params: funcParams } = this.translateExpression(expr);
4323
+ params.push(...funcParams);
4324
+ return sql;
4325
+ }
4326
+ case "object": {
4327
+ // json_object('key1', val1, 'key2', val2, ...)
4328
+ const objParts = [];
4329
+ for (const prop of expr.properties || []) {
4330
+ const key = prop.key;
4331
+ const valueSql = this.translateExpressionWithCTEMapping(prop.value, cteColumnMap, params);
4332
+ objParts.push(`?`);
4333
+ params.push(key);
4334
+ objParts.push(valueSql);
4335
+ }
4336
+ return `json_object(${objParts.join(", ")})`;
4337
+ }
4338
+ case "literal": {
4339
+ if (expr.value === null)
4340
+ return "NULL";
4341
+ if (typeof expr.value === "boolean") {
4342
+ params.push(expr.value ? 1 : 0);
4343
+ return "?";
4344
+ }
4345
+ if (typeof expr.value === "number" || typeof expr.value === "string") {
4346
+ params.push(expr.value);
4347
+ return "?";
4348
+ }
4349
+ const { sql, params: litParams } = this.translateExpression(expr);
4350
+ params.push(...litParams);
4351
+ return sql;
4352
+ }
4353
+ case "binary": {
4354
+ const leftSql = this.translateExpressionWithCTEMapping(expr.left, cteColumnMap, params);
4355
+ const rightSql = this.translateExpressionWithCTEMapping(expr.right, cteColumnMap, params);
4356
+ return `(${leftSql} ${expr.operator} ${rightSql})`;
4357
+ }
4358
+ default: {
4359
+ // Fall back to standard translation
4360
+ const { sql, params: defaultParams } = this.translateExpression(expr);
4361
+ params.push(...defaultParams);
4362
+ return sql;
4363
+ }
4364
+ }
4365
+ }
4111
4366
  translateExpression(expr) {
4112
4367
  const tables = [];
4113
4368
  const params = [];
@@ -4400,12 +4655,16 @@ export class Translator {
4400
4655
  // This variable is a WITH alias - translate the underlying expression and access the property
4401
4656
  const objectResult = this.translateExpression(originalExpr);
4402
4657
  tables.push(...objectResult.tables);
4403
- params.push(...objectResult.params);
4404
4658
  // Check if this is a temporal property accessor (year, month, day, etc.)
4405
- const temporalSql = this.translateTemporalPropertyAccess(objectResult.sql, expr.property);
4406
- if (temporalSql) {
4407
- return { sql: temporalSql, tables, params };
4659
+ const temporalResult = this.translateTemporalPropertyAccess(objectResult.sql, expr.property);
4660
+ if (temporalResult) {
4661
+ // Repeat the params for each occurrence of the base SQL in the temporal accessor
4662
+ for (let i = 0; i < temporalResult.paramMultiplier; i++) {
4663
+ params.push(...objectResult.params);
4664
+ }
4665
+ return { sql: temporalResult.sql, tables, params };
4408
4666
  }
4667
+ params.push(...objectResult.params);
4409
4668
  // Access property from the result using json_extract
4410
4669
  return {
4411
4670
  sql: `json_extract(${objectResult.sql}, '$.${expr.property}')`,
@@ -4436,9 +4695,10 @@ export class Translator {
4436
4695
  // UNWIND variables use the 'value' column from json_each
4437
4696
  const baseSql = `${unwindClause.alias}.value`;
4438
4697
  // Check if this is a temporal property accessor
4439
- const temporalSql = this.translateTemporalPropertyAccess(baseSql, expr.property);
4440
- if (temporalSql) {
4441
- return { sql: temporalSql, tables, params };
4698
+ const temporalResult = this.translateTemporalPropertyAccess(baseSql, expr.property);
4699
+ if (temporalResult) {
4700
+ // baseSql is a column reference (no params), so no need to repeat params
4701
+ return { sql: temporalResult.sql, tables, params };
4442
4702
  }
4443
4703
  // Access property from the unwound value using json_extract
4444
4704
  return {
@@ -4483,6 +4743,30 @@ export class Translator {
4483
4743
  };
4484
4744
  }
4485
4745
  else if (arg.type === "variable") {
4746
+ // Check if this is an UNWIND variable first
4747
+ const unwindClausesCount = this.ctx.unwindClauses;
4748
+ if (unwindClausesCount) {
4749
+ const unwindClause = unwindClausesCount.find(u => u.variable === arg.variable);
4750
+ if (unwindClause) {
4751
+ // Check if UNWIND was consumed by MIN/MAX subquery
4752
+ const consumedUnwinds = this.ctx.consumedUnwindClauses;
4753
+ if (consumedUnwinds?.has(unwindClause.alias)) {
4754
+ // UNWIND was consumed, use subquery
4755
+ const alias = unwindClause.alias;
4756
+ return {
4757
+ sql: `(SELECT COUNT(${distinctKeyword}${alias}.value) FROM json_each(${unwindClause.jsonExpr}) ${alias})`,
4758
+ tables: [],
4759
+ params: [...params, ...unwindClause.params],
4760
+ };
4761
+ }
4762
+ tables.push(unwindClause.alias);
4763
+ return {
4764
+ sql: `COUNT(${distinctKeyword}${unwindClause.alias}.value)`,
4765
+ tables,
4766
+ params,
4767
+ };
4768
+ }
4769
+ }
4486
4770
  const varInfo = this.ctx.variables.get(arg.variable);
4487
4771
  if (!varInfo) {
4488
4772
  throw new Error(`Unknown variable: ${arg.variable}`);
@@ -4579,6 +4863,20 @@ export class Translator {
4579
4863
  }
4580
4864
  const distinctKeyword = expr.distinct ? "DISTINCT " : "";
4581
4865
  if (arg.type === "property") {
4866
+ // First check if this is an UNWIND variable
4867
+ const unwindClauses = this.ctx.unwindClauses;
4868
+ if (unwindClauses) {
4869
+ const unwindClause = unwindClauses.find(u => u.variable === arg.variable);
4870
+ if (unwindClause) {
4871
+ tables.push(unwindClause.alias);
4872
+ // UNWIND variables use the 'value' column from json_each
4873
+ return {
4874
+ sql: `${expr.functionName}(${distinctKeyword}json_extract(${unwindClause.alias}.value, '$.${arg.property}'))`,
4875
+ tables,
4876
+ params,
4877
+ };
4878
+ }
4879
+ }
4582
4880
  const varInfo = this.ctx.variables.get(arg.variable);
4583
4881
  if (!varInfo) {
4584
4882
  throw new Error(`Unknown variable: ${arg.variable}`);
@@ -4592,10 +4890,10 @@ export class Translator {
4592
4890
  };
4593
4891
  }
4594
4892
  else if (arg.type === "variable") {
4595
- // Check if this is an UNWIND variable first
4596
- const unwindClauses = this.ctx.unwindClauses;
4597
- if (unwindClauses) {
4598
- const unwindClause = unwindClauses.find(u => u.variable === arg.variable);
4893
+ // Check if this is an UNWIND variable first (use different name to avoid shadowing)
4894
+ const unwindClausesVar = this.ctx.unwindClauses;
4895
+ if (unwindClausesVar) {
4896
+ const unwindClause = unwindClausesVar.find((u) => u.variable === arg.variable);
4599
4897
  if (unwindClause) {
4600
4898
  tables.push(unwindClause.alias);
4601
4899
  // For MIN/MAX on UNWIND variables, use type-aware comparison
@@ -4672,7 +4970,17 @@ export class Translator {
4672
4970
  };
4673
4971
  }
4674
4972
  }
4675
- // For other aggregates (SUM, AVG), use standard aggregation
4973
+ // For other aggregates (SUM, AVG), check if UNWIND was consumed by subquery aggregate
4974
+ const consumedUnwinds = this.ctx.consumedUnwindClauses;
4975
+ if (consumedUnwinds?.has(unwindClause.alias)) {
4976
+ // UNWIND was consumed by MIN/MAX subquery, use subquery for SUM/AVG too
4977
+ const alias = unwindClause.alias;
4978
+ return {
4979
+ sql: `(SELECT ${expr.functionName}(${distinctKeyword}${alias}.value) FROM json_each(${unwindClause.jsonExpr}) ${alias})`,
4980
+ tables: [], // Don't add to outer tables since we use subquery
4981
+ params: [...params, ...unwindClause.params],
4982
+ };
4983
+ }
4676
4984
  // Check if this UNWIND variable was wrapped in a WITH subquery
4677
4985
  const subqueryColName = unwindClause.subqueryColumnName;
4678
4986
  const valueRef = subqueryColName
@@ -4697,8 +5005,8 @@ export class Translator {
4697
5005
  params,
4698
5006
  };
4699
5007
  }
4700
- else if (arg.type === "function" || arg.type === "binary") {
4701
- // Handle aggregates on expressions like sum(n.x * n.y) or min(length(p))
5008
+ else if (arg.type === "function" || arg.type === "binary" || arg.type === "case") {
5009
+ // Handle aggregates on expressions like sum(n.x * n.y), min(length(p)), or sum(CASE WHEN ... END)
4702
5010
  const argResult = this.translateFunctionArg(arg);
4703
5011
  tables.push(...argResult.tables);
4704
5012
  params.push(...argResult.params);
@@ -4719,6 +5027,69 @@ export class Translator {
4719
5027
  }
4720
5028
  throw new Error(`${expr.functionName} requires a property, variable, or expression argument`);
4721
5029
  }
5030
+ // Standard deviation functions: STDEV (sample), STDEVP (population)
5031
+ // Formula: sqrt((sum(x^2) - sum(x)^2/n) / (n-1)) for sample
5032
+ // sqrt((sum(x^2) - sum(x)^2/n) / n) for population
5033
+ if (expr.functionName === "STDEV" || expr.functionName === "STDEVP") {
5034
+ if (expr.args && expr.args.length > 0) {
5035
+ const arg = expr.args[0];
5036
+ // Validate that the argument doesn't contain non-deterministic functions
5037
+ if (this.containsNonDeterministicFunction(arg)) {
5038
+ throw new Error(`SyntaxError: Can't use non-deterministic (random) functions inside of aggregate functions.`);
5039
+ }
5040
+ let valueSql;
5041
+ if (arg.type === "property") {
5042
+ const varInfo = this.ctx.variables.get(arg.variable);
5043
+ if (!varInfo) {
5044
+ throw new Error(`Unknown variable: ${arg.variable}`);
5045
+ }
5046
+ tables.push(varInfo.alias);
5047
+ valueSql = `json_extract(${varInfo.alias}.properties, '$.${arg.property}')`;
5048
+ }
5049
+ else if (arg.type === "variable") {
5050
+ // Check if this is an UNWIND variable
5051
+ const unwindClauses = this.ctx.unwindClauses;
5052
+ if (unwindClauses) {
5053
+ const unwindClause = unwindClauses.find(u => u.variable === arg.variable);
5054
+ if (unwindClause) {
5055
+ tables.push(unwindClause.alias);
5056
+ valueSql = `${unwindClause.alias}.value`;
5057
+ }
5058
+ else {
5059
+ const varInfo = this.ctx.variables.get(arg.variable);
5060
+ if (!varInfo) {
5061
+ throw new Error(`Unknown variable: ${arg.variable}`);
5062
+ }
5063
+ tables.push(varInfo.alias);
5064
+ valueSql = `${varInfo.alias}.id`;
5065
+ }
5066
+ }
5067
+ else {
5068
+ const varInfo = this.ctx.variables.get(arg.variable);
5069
+ if (!varInfo) {
5070
+ throw new Error(`Unknown variable: ${arg.variable}`);
5071
+ }
5072
+ tables.push(varInfo.alias);
5073
+ valueSql = `${varInfo.alias}.id`;
5074
+ }
5075
+ }
5076
+ else {
5077
+ // Handle expressions
5078
+ const argResult = this.translateFunctionArg(arg);
5079
+ tables.push(...argResult.tables);
5080
+ params.push(...argResult.params);
5081
+ valueSql = argResult.sql;
5082
+ }
5083
+ // Use computational formula for standard deviation
5084
+ // Sample: sqrt((sum(x^2) - sum(x)^2/n) / (n-1))
5085
+ // Population: sqrt((sum(x^2) - sum(x)^2/n) / n)
5086
+ // Note: multiply by 1.0 to force float division in SQLite
5087
+ const divisor = expr.functionName === "STDEV" ? `(COUNT(${valueSql}) - 1)` : `COUNT(${valueSql})`;
5088
+ const formula = `CASE WHEN COUNT(${valueSql}) <= 1 THEN 0.0 ELSE sqrt((SUM(${valueSql} * ${valueSql}) - SUM(${valueSql}) * SUM(${valueSql}) * 1.0 / COUNT(${valueSql})) * 1.0 / ${divisor}) END`;
5089
+ return { sql: formula, tables, params };
5090
+ }
5091
+ throw new Error(`${expr.functionName.toLowerCase()} requires an argument`);
5092
+ }
4722
5093
  // Percentile functions: PERCENTILEDISC, PERCENTILECONT
4723
5094
  // percentileDisc(value, percentile) - Returns discrete value at percentile position
4724
5095
  // percentileCont(value, percentile) - Returns interpolated value at percentile position
@@ -4934,13 +5305,20 @@ END FROM (SELECT json_group_array(${valueExpr}) as sv))`,
4934
5305
  if (unwindClauses) {
4935
5306
  const unwindClause = unwindClauses.find(u => u.variable === arg.variable);
4936
5307
  if (unwindClause) {
4937
- tables.push(unwindClause.alias);
5308
+ // Check if this UNWIND variable was wrapped in a WITH subquery
5309
+ const subqueryColName = unwindClause.subqueryColumnName;
5310
+ const valueRef = subqueryColName
5311
+ ? `__with_subquery__."${subqueryColName}"`
5312
+ : `${unwindClause.alias}.value`;
5313
+ if (!subqueryColName) {
5314
+ tables.push(unwindClause.alias);
5315
+ }
4938
5316
  // For UNWIND variables, collect the raw values from json_each
4939
5317
  if (useDistinct) {
4940
5318
  // Filter nulls using CASE WHEN ... IS NOT NULL
4941
5319
  params.push(...collectOrderParams);
4942
5320
  return {
4943
- sql: `COALESCE(json('[' || GROUP_CONCAT(DISTINCT CASE WHEN ${unwindClause.alias}.value IS NOT NULL THEN json_quote(${unwindClause.alias}.value) END${collectOrderClause}) || ']'), json('[]'))`,
5321
+ sql: `COALESCE(json('[' || GROUP_CONCAT(DISTINCT CASE WHEN ${valueRef} IS NOT NULL THEN json_quote(${valueRef}) END${collectOrderClause}) || ']'), json('[]'))`,
4944
5322
  tables,
4945
5323
  params,
4946
5324
  };
@@ -4948,7 +5326,7 @@ END FROM (SELECT json_group_array(${valueExpr}) as sv))`,
4948
5326
  // Neo4j's collect() skips NULL values - use GROUP_CONCAT with null filtering
4949
5327
  params.push(...collectOrderParams);
4950
5328
  return {
4951
- sql: `COALESCE(json('[' || GROUP_CONCAT(CASE WHEN ${unwindClause.alias}.value IS NOT NULL THEN json_quote(${unwindClause.alias}.value) END${collectOrderClause}) || ']'), json('[]'))`,
5329
+ sql: `COALESCE(json('[' || GROUP_CONCAT(CASE WHEN ${valueRef} IS NOT NULL THEN json_quote(${valueRef}) END${collectOrderClause}) || ']'), json('[]'))`,
4952
5330
  tables,
4953
5331
  params,
4954
5332
  };
@@ -5346,6 +5724,103 @@ END FROM (SELECT json_group_array(${valueExpr}) as sv))`,
5346
5724
  }
5347
5725
  throw new Error("coalesce requires at least one argument");
5348
5726
  }
5727
+ // EXISTS: check if map property exists
5728
+ // This handles exists(m.prop) in RETURN context where m is a map
5729
+ if (expr.functionName === "EXISTS") {
5730
+ if (expr.args && expr.args.length > 0) {
5731
+ const arg = expr.args[0];
5732
+ if (arg.type === "property") {
5733
+ const varName = arg.variable;
5734
+ const propName = arg.property;
5735
+ // Check if this is a node variable (has node info in variables)
5736
+ const varInfo = this.ctx.variables.get(varName);
5737
+ if (varInfo && (varInfo.type === "node" || varInfo.type === "edge")) {
5738
+ // For nodes/edges, check if property is not null in properties JSON
5739
+ const result = this.translateExpression(arg);
5740
+ tables.push(...result.tables);
5741
+ params.push(...result.params);
5742
+ return {
5743
+ sql: `cypher_to_json_bool(${result.sql} IS NOT NULL)`,
5744
+ tables,
5745
+ params,
5746
+ };
5747
+ }
5748
+ // For maps (WITH alias or other), check if key exists in JSON object
5749
+ // json_type returns NULL if key doesn't exist, type name if it does
5750
+ const argResult = this.translateExpression({ type: "variable", variable: varName });
5751
+ tables.push(...argResult.tables);
5752
+ params.push(...argResult.params);
5753
+ return {
5754
+ sql: `cypher_to_json_bool(json_type(${argResult.sql}, '$.${propName}') IS NOT NULL)`,
5755
+ tables,
5756
+ params,
5757
+ };
5758
+ }
5759
+ // For other expressions passed to exists(), check IS NOT NULL
5760
+ const argResult = this.translateFunctionArg(arg);
5761
+ tables.push(...argResult.tables);
5762
+ params.push(...argResult.params);
5763
+ return {
5764
+ sql: `cypher_to_json_bool(${argResult.sql} IS NOT NULL)`,
5765
+ tables,
5766
+ params,
5767
+ };
5768
+ }
5769
+ throw new Error("exists requires an argument");
5770
+ }
5771
+ // ============================================================================
5772
+ // Spatial functions
5773
+ // ============================================================================
5774
+ // POINT: create a point from coordinates
5775
+ // Supports: point({x: 0, y: 0}) for Cartesian
5776
+ // and point({latitude: 48.8, longitude: 2.3}) for geographic
5777
+ if (expr.functionName === "POINT") {
5778
+ if (expr.args && expr.args.length > 0) {
5779
+ const arg = expr.args[0];
5780
+ // The argument should be a map/object literal
5781
+ if (arg.type === "object") {
5782
+ // Build the point object from the map properties
5783
+ const properties = arg.properties || [];
5784
+ const pointProps = [];
5785
+ for (const prop of properties) {
5786
+ const keyName = prop.key;
5787
+ const valueResult = this.translateExpression(prop.value);
5788
+ tables.push(...valueResult.tables);
5789
+ params.push(...valueResult.params);
5790
+ pointProps.push({ key: keyName, sql: valueResult.sql });
5791
+ }
5792
+ // Build a JSON object with the point properties
5793
+ const jsonPairs = pointProps.map(p => `'${p.key}', ${p.sql}`).join(", ");
5794
+ return { sql: `json_object(${jsonPairs})`, tables, params };
5795
+ }
5796
+ // For non-literal map, pass through as-is (variable or parameter)
5797
+ const argResult = this.translateFunctionArg(arg);
5798
+ tables.push(...argResult.tables);
5799
+ params.push(...argResult.params);
5800
+ return { sql: argResult.sql, tables, params };
5801
+ }
5802
+ throw new Error("point requires a map argument with coordinates");
5803
+ }
5804
+ // DISTANCE: calculate distance between two points
5805
+ // For Cartesian: sqrt((x2-x1)^2 + (y2-y1)^2)
5806
+ // For geographic: haversine formula
5807
+ if (expr.functionName === "DISTANCE") {
5808
+ if (expr.args && expr.args.length >= 2) {
5809
+ const p1Result = this.translateFunctionArg(expr.args[0]);
5810
+ const p2Result = this.translateFunctionArg(expr.args[1]);
5811
+ tables.push(...p1Result.tables, ...p2Result.tables);
5812
+ params.push(...p1Result.params, ...p2Result.params);
5813
+ // Check if it's Cartesian (x,y) or geographic (latitude,longitude)
5814
+ // For simplicity, use Cartesian distance formula
5815
+ // Euclidean distance: sqrt((x2-x1)^2 + (y2-y1)^2)
5816
+ const sql = `(SELECT SQRT(
5817
+ POWER(COALESCE(json_extract(p2, '$.x'), json_extract(p2, '$.longitude')) - COALESCE(json_extract(p1, '$.x'), json_extract(p1, '$.longitude')), 2) +
5818
+ POWER(COALESCE(json_extract(p2, '$.y'), json_extract(p2, '$.latitude')) - COALESCE(json_extract(p1, '$.y'), json_extract(p1, '$.latitude')), 2)
5819
+ ) FROM (SELECT ${p1Result.sql} as p1, ${p2Result.sql} as p2))`;
5820
+ return { sql, tables, params };
5821
+ }
5822
+ throw new Error("distance requires two point arguments");
5823
+ }
5349
5824
  // ============================================================================
5350
5825
  // Math functions
5351
5826
  // ============================================================================
@@ -5359,37 +5834,51 @@ END FROM (SELECT json_group_array(${valueExpr}) as sv))`,
5359
5834
  }
5360
5835
  throw new Error("abs requires an argument");
5361
5836
  }
5362
- // ROUND: round to nearest integer
5837
+ // ROUND: round to nearest integer using "round half up" (toward positive infinity)
5838
+ // Neo4j: round(-6.5) = -6, round(6.5) = 7
5839
+ // SQLite's ROUND uses "round half away from zero" which gives -7 for -6.5
5840
+ // We implement floor(x + 0.5) to get "round half up" semantics
5363
5841
  if (expr.functionName === "ROUND") {
5364
5842
  if (expr.args && expr.args.length > 0) {
5365
5843
  const argResult = this.translateFunctionArg(expr.args[0]);
5366
5844
  tables.push(...argResult.tables);
5367
5845
  params.push(...argResult.params);
5368
- return { sql: `ROUND(${argResult.sql})`, tables, params };
5846
+ // floor(x + 0.5) gives "round half up" semantics
5847
+ // SQLite CAST truncates toward zero, so we need proper floor for negatives
5848
+ return {
5849
+ sql: `(SELECT CASE WHEN v >= 0 OR v = CAST(v AS INTEGER) THEN CAST(v AS INTEGER) ELSE CAST(v AS INTEGER) - 1 END FROM (SELECT (${argResult.sql} + 0.5) AS v))`,
5850
+ tables,
5851
+ params
5852
+ };
5369
5853
  }
5370
5854
  throw new Error("round requires an argument");
5371
5855
  }
5372
- // FLOOR: round down to integer
5856
+ // FLOOR: round down to integer (towards negative infinity)
5373
5857
  if (expr.functionName === "FLOOR") {
5374
5858
  if (expr.args && expr.args.length > 0) {
5375
5859
  const argResult = this.translateFunctionArg(expr.args[0]);
5376
5860
  tables.push(...argResult.tables);
5377
5861
  params.push(...argResult.params);
5378
- // SQLite doesn't have FLOOR, use CAST for positive numbers or CASE for proper floor
5379
- return { sql: `CAST(${argResult.sql} AS INTEGER)`, tables, params };
5862
+ // SQLite's CAST truncates towards zero, but floor rounds towards -infinity
5863
+ // For negative numbers with a fractional part, we need to subtract 1
5864
+ return {
5865
+ sql: `(SELECT CASE WHEN v >= 0 OR v = CAST(v AS INTEGER) THEN CAST(v AS INTEGER) ELSE CAST(v AS INTEGER) - 1 END FROM (SELECT ${argResult.sql} AS v))`,
5866
+ tables,
5867
+ params
5868
+ };
5380
5869
  }
5381
5870
  throw new Error("floor requires an argument");
5382
5871
  }
5383
- // CEIL: round up to integer
5872
+ // CEIL: round up to integer (towards positive infinity)
5384
5873
  if (expr.functionName === "CEIL") {
5385
5874
  if (expr.args && expr.args.length > 0) {
5386
5875
  const argResult = this.translateFunctionArg(expr.args[0]);
5387
5876
  tables.push(...argResult.tables);
5388
5877
  params.push(...argResult.params);
5389
- // SQLite doesn't have CEIL, simulate with CASE
5390
- // Use subquery to evaluate arg once (avoids duplicate parameter binding)
5878
+ // SQLite's CAST truncates towards zero, which is correct for ceil of negative numbers
5879
+ // For positive numbers with fractional part, we need to add 1
5391
5880
  return {
5392
- sql: `(SELECT CASE WHEN v = CAST(v AS INTEGER) THEN CAST(v AS INTEGER) ELSE CAST(v AS INTEGER) + 1 END FROM (SELECT ${argResult.sql} AS v))`,
5881
+ sql: `(SELECT CASE WHEN v <= 0 OR v = CAST(v AS INTEGER) THEN CAST(v AS INTEGER) ELSE CAST(v AS INTEGER) + 1 END FROM (SELECT ${argResult.sql} AS v))`,
5393
5882
  tables,
5394
5883
  params
5395
5884
  };
@@ -5429,92 +5918,230 @@ END FROM (SELECT json_group_array(${valueExpr}) as sv))`,
5429
5918
  params
5430
5919
  };
5431
5920
  }
5432
- // ============================================================================
5433
- // List functions
5434
- // ============================================================================
5435
- // SIZE: get length of array
5436
- if (expr.functionName === "SIZE") {
5921
+ // LOG: natural logarithm (Cypher log = SQLite ln)
5922
+ if (expr.functionName === "LOG") {
5437
5923
  if (expr.args && expr.args.length > 0) {
5438
5924
  const argResult = this.translateFunctionArg(expr.args[0]);
5439
5925
  tables.push(...argResult.tables);
5440
5926
  params.push(...argResult.params);
5441
- return { sql: `json_array_length(${argResult.sql})`, tables, params };
5927
+ return { sql: `ln(${argResult.sql})`, tables, params };
5442
5928
  }
5443
- throw new Error("size requires an argument");
5929
+ throw new Error("log requires an argument");
5444
5930
  }
5445
- // HEAD: get first element of array
5446
- if (expr.functionName === "HEAD") {
5931
+ // LOG10: base-10 logarithm
5932
+ if (expr.functionName === "LOG10") {
5447
5933
  if (expr.args && expr.args.length > 0) {
5448
5934
  const argResult = this.translateFunctionArg(expr.args[0]);
5449
5935
  tables.push(...argResult.tables);
5450
5936
  params.push(...argResult.params);
5451
- return { sql: `json_extract(${argResult.sql}, '$[0]')`, tables, params };
5937
+ return { sql: `log10(${argResult.sql})`, tables, params };
5452
5938
  }
5453
- throw new Error("head requires an argument");
5939
+ throw new Error("log10 requires an argument");
5454
5940
  }
5455
- // LAST: get last element of array
5456
- if (expr.functionName === "LAST") {
5941
+ // EXP: exponential (e^x)
5942
+ if (expr.functionName === "EXP") {
5457
5943
  if (expr.args && expr.args.length > 0) {
5458
5944
  const argResult = this.translateFunctionArg(expr.args[0]);
5459
5945
  tables.push(...argResult.tables);
5460
5946
  params.push(...argResult.params);
5461
- // SQLite: json_extract with [json_array_length - 1] or $[#-1] syntax
5462
- return { sql: `json_extract(${argResult.sql}, '$[#-1]')`, tables, params };
5947
+ return { sql: `exp(${argResult.sql})`, tables, params };
5463
5948
  }
5464
- throw new Error("last requires an argument");
5949
+ throw new Error("exp requires an argument");
5465
5950
  }
5466
- // KEYS: get property keys of a node/map (only returns keys with non-null values)
5467
- if (expr.functionName === "KEYS") {
5951
+ // Trigonometric functions
5952
+ if (expr.functionName === "SIN") {
5468
5953
  if (expr.args && expr.args.length > 0) {
5469
- const arg = expr.args[0];
5470
- // Handle null literal - keys(null) returns null
5471
- if (arg.type === "literal" && arg.value === null) {
5472
- return { sql: "NULL", tables, params };
5473
- }
5474
- if (arg.type === "variable") {
5475
- // First check if it's a WITH alias
5476
- const withAliases = this.ctx.withAliases;
5477
- if (withAliases && withAliases.has(arg.variable)) {
5478
- const originalExpr = withAliases.get(arg.variable);
5479
- // If the WITH alias is null, return null
5480
- if (originalExpr.type === "literal" && originalExpr.value === null) {
5481
- return { sql: "NULL", tables, params };
5482
- }
5483
- // For maps/objects from WITH, translate and get keys
5484
- const translated = this.translateExpression(originalExpr);
5485
- tables.push(...translated.tables);
5486
- params.push(...translated.params);
5487
- // Use json_each to get keys from the JSON object
5488
- // Note: keys() returns ALL keys including those with null values
5489
- return {
5490
- sql: `(SELECT json_group_array(key) FROM json_each(${translated.sql}))`,
5491
- tables,
5492
- params,
5493
- };
5494
- }
5495
- // Check ctx.variables for node/edge variables
5496
- const varInfo = this.ctx.variables.get(arg.variable);
5497
- if (!varInfo) {
5498
- throw new Error(`Unknown variable: ${arg.variable}`);
5499
- }
5500
- tables.push(varInfo.alias);
5501
- // Use json_each to get keys from the node/edge properties
5502
- // For nodes/edges, filter out null-valued properties since setting a property to null removes it
5503
- return {
5504
- sql: `(SELECT json_group_array(key) FROM json_each(${varInfo.alias}.properties) WHERE type != 'null')`,
5505
- tables,
5506
- params
5507
- };
5508
- }
5509
- // Handle map literals
5510
- if (arg.type === "object") {
5511
- const translated = this.translateExpression(arg);
5512
- tables.push(...translated.tables);
5513
- params.push(...translated.params);
5514
- // Note: keys() returns ALL keys including those with null values
5515
- return {
5516
- sql: `(SELECT json_group_array(key) FROM json_each(${translated.sql}))`,
5517
- tables,
5954
+ const argResult = this.translateFunctionArg(expr.args[0]);
5955
+ tables.push(...argResult.tables);
5956
+ params.push(...argResult.params);
5957
+ return { sql: `sin(${argResult.sql})`, tables, params };
5958
+ }
5959
+ throw new Error("sin requires an argument");
5960
+ }
5961
+ if (expr.functionName === "COS") {
5962
+ if (expr.args && expr.args.length > 0) {
5963
+ const argResult = this.translateFunctionArg(expr.args[0]);
5964
+ tables.push(...argResult.tables);
5965
+ params.push(...argResult.params);
5966
+ return { sql: `cos(${argResult.sql})`, tables, params };
5967
+ }
5968
+ throw new Error("cos requires an argument");
5969
+ }
5970
+ if (expr.functionName === "TAN") {
5971
+ if (expr.args && expr.args.length > 0) {
5972
+ const argResult = this.translateFunctionArg(expr.args[0]);
5973
+ tables.push(...argResult.tables);
5974
+ params.push(...argResult.params);
5975
+ return { sql: `tan(${argResult.sql})`, tables, params };
5976
+ }
5977
+ throw new Error("tan requires an argument");
5978
+ }
5979
+ if (expr.functionName === "ASIN") {
5980
+ if (expr.args && expr.args.length > 0) {
5981
+ const argResult = this.translateFunctionArg(expr.args[0]);
5982
+ tables.push(...argResult.tables);
5983
+ params.push(...argResult.params);
5984
+ return { sql: `asin(${argResult.sql})`, tables, params };
5985
+ }
5986
+ throw new Error("asin requires an argument");
5987
+ }
5988
+ if (expr.functionName === "ACOS") {
5989
+ if (expr.args && expr.args.length > 0) {
5990
+ const argResult = this.translateFunctionArg(expr.args[0]);
5991
+ tables.push(...argResult.tables);
5992
+ params.push(...argResult.params);
5993
+ return { sql: `acos(${argResult.sql})`, tables, params };
5994
+ }
5995
+ throw new Error("acos requires an argument");
5996
+ }
5997
+ if (expr.functionName === "ATAN") {
5998
+ if (expr.args && expr.args.length > 0) {
5999
+ const argResult = this.translateFunctionArg(expr.args[0]);
6000
+ tables.push(...argResult.tables);
6001
+ params.push(...argResult.params);
6002
+ return { sql: `atan(${argResult.sql})`, tables, params };
6003
+ }
6004
+ throw new Error("atan requires an argument");
6005
+ }
6006
+ if (expr.functionName === "ATAN2") {
6007
+ if (expr.args && expr.args.length >= 2) {
6008
+ const arg1Result = this.translateFunctionArg(expr.args[0]);
6009
+ const arg2Result = this.translateFunctionArg(expr.args[1]);
6010
+ tables.push(...arg1Result.tables, ...arg2Result.tables);
6011
+ params.push(...arg1Result.params, ...arg2Result.params);
6012
+ return { sql: `atan2(${arg1Result.sql}, ${arg2Result.sql})`, tables, params };
6013
+ }
6014
+ throw new Error("atan2 requires two arguments");
6015
+ }
6016
+ // DEGREES: convert radians to degrees
6017
+ if (expr.functionName === "DEGREES") {
6018
+ if (expr.args && expr.args.length > 0) {
6019
+ const argResult = this.translateFunctionArg(expr.args[0]);
6020
+ tables.push(...argResult.tables);
6021
+ params.push(...argResult.params);
6022
+ return { sql: `degrees(${argResult.sql})`, tables, params };
6023
+ }
6024
+ throw new Error("degrees requires an argument");
6025
+ }
6026
+ // RADIANS: convert degrees to radians
6027
+ if (expr.functionName === "RADIANS") {
6028
+ if (expr.args && expr.args.length > 0) {
6029
+ const argResult = this.translateFunctionArg(expr.args[0]);
6030
+ tables.push(...argResult.tables);
6031
+ params.push(...argResult.params);
6032
+ return { sql: `radians(${argResult.sql})`, tables, params };
6033
+ }
6034
+ throw new Error("radians requires an argument");
6035
+ }
6036
+ // HAVERSIN: haversine function (1 - cos(x)) / 2
6037
+ if (expr.functionName === "HAVERSIN") {
6038
+ if (expr.args && expr.args.length > 0) {
6039
+ const argResult = this.translateFunctionArg(expr.args[0]);
6040
+ tables.push(...argResult.tables);
6041
+ params.push(...argResult.params);
6042
+ return { sql: `((1.0 - cos(${argResult.sql})) / 2.0)`, tables, params };
6043
+ }
6044
+ throw new Error("haversin requires an argument");
6045
+ }
6046
+ // E: Euler's number constant
6047
+ if (expr.functionName === "E") {
6048
+ return { sql: `exp(1)`, tables, params };
6049
+ }
6050
+ // PI: mathematical constant pi
6051
+ if (expr.functionName === "PI") {
6052
+ return { sql: `pi()`, tables, params };
6053
+ }
6054
+ // ============================================================================
6055
+ // List functions
6056
+ // ============================================================================
6057
+ // SIZE: get length of array or string
6058
+ if (expr.functionName === "SIZE") {
6059
+ if (expr.args && expr.args.length > 0) {
6060
+ const argResult = this.translateFunctionArg(expr.args[0]);
6061
+ tables.push(...argResult.tables);
6062
+ // Push params 4 times since the arg is referenced 4 times in the CASE expression
6063
+ params.push(...argResult.params);
6064
+ params.push(...argResult.params);
6065
+ params.push(...argResult.params);
6066
+ params.push(...argResult.params);
6067
+ // Handle both strings and arrays: use json_array_length for valid JSON arrays, length() otherwise
6068
+ return { sql: `CASE WHEN json_valid(${argResult.sql}) AND json_type(${argResult.sql}) = 'array' THEN json_array_length(${argResult.sql}) ELSE length(${argResult.sql}) END`, tables, params };
6069
+ }
6070
+ throw new Error("size requires an argument");
6071
+ }
6072
+ // HEAD: get first element of array
6073
+ if (expr.functionName === "HEAD") {
6074
+ if (expr.args && expr.args.length > 0) {
6075
+ const argResult = this.translateFunctionArg(expr.args[0]);
6076
+ tables.push(...argResult.tables);
6077
+ params.push(...argResult.params);
6078
+ return { sql: `json_extract(${argResult.sql}, '$[0]')`, tables, params };
6079
+ }
6080
+ throw new Error("head requires an argument");
6081
+ }
6082
+ // LAST: get last element of array
6083
+ if (expr.functionName === "LAST") {
6084
+ if (expr.args && expr.args.length > 0) {
6085
+ const argResult = this.translateFunctionArg(expr.args[0]);
6086
+ tables.push(...argResult.tables);
6087
+ params.push(...argResult.params);
6088
+ // SQLite: json_extract with [json_array_length - 1] or $[#-1] syntax
6089
+ return { sql: `json_extract(${argResult.sql}, '$[#-1]')`, tables, params };
6090
+ }
6091
+ throw new Error("last requires an argument");
6092
+ }
6093
+ // KEYS: get property keys of a node/map (only returns keys with non-null values)
6094
+ if (expr.functionName === "KEYS") {
6095
+ if (expr.args && expr.args.length > 0) {
6096
+ const arg = expr.args[0];
6097
+ // Handle null literal - keys(null) returns null
6098
+ if (arg.type === "literal" && arg.value === null) {
6099
+ return { sql: "NULL", tables, params };
6100
+ }
6101
+ if (arg.type === "variable") {
6102
+ // First check if it's a WITH alias
6103
+ const withAliases = this.ctx.withAliases;
6104
+ if (withAliases && withAliases.has(arg.variable)) {
6105
+ const originalExpr = withAliases.get(arg.variable);
6106
+ // If the WITH alias is null, return null
6107
+ if (originalExpr.type === "literal" && originalExpr.value === null) {
6108
+ return { sql: "NULL", tables, params };
6109
+ }
6110
+ // For maps/objects from WITH, translate and get keys
6111
+ const translated = this.translateExpression(originalExpr);
6112
+ tables.push(...translated.tables);
6113
+ params.push(...translated.params);
6114
+ // Use json_each to get keys from the JSON object
6115
+ // Note: keys() returns ALL keys including those with null values
6116
+ return {
6117
+ sql: `(SELECT json_group_array(key) FROM json_each(${translated.sql}))`,
6118
+ tables,
6119
+ params,
6120
+ };
6121
+ }
6122
+ // Check ctx.variables for node/edge variables
6123
+ const varInfo = this.ctx.variables.get(arg.variable);
6124
+ if (!varInfo) {
6125
+ throw new Error(`Unknown variable: ${arg.variable}`);
6126
+ }
6127
+ tables.push(varInfo.alias);
6128
+ // Use json_each to get keys from the node/edge properties
6129
+ // For nodes/edges, filter out null-valued properties since setting a property to null removes it
6130
+ return {
6131
+ sql: `(SELECT json_group_array(key) FROM json_each(${varInfo.alias}.properties) WHERE type != 'null')`,
6132
+ tables,
6133
+ params
6134
+ };
6135
+ }
6136
+ // Handle map literals
6137
+ if (arg.type === "object") {
6138
+ const translated = this.translateExpression(arg);
6139
+ tables.push(...translated.tables);
6140
+ params.push(...translated.params);
6141
+ // Note: keys() returns ALL keys including those with null values
6142
+ return {
6143
+ sql: `(SELECT json_group_array(key) FROM json_each(${translated.sql}))`,
6144
+ tables,
5518
6145
  params,
5519
6146
  };
5520
6147
  }
@@ -8405,6 +9032,9 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8405
9032
  case "existsPattern": {
8406
9033
  return this.translateExistsPattern(expr);
8407
9034
  }
9035
+ case "sizePattern": {
9036
+ return this.translateSizePattern(expr);
9037
+ }
8408
9038
  case "listPredicate": {
8409
9039
  // Wrap list predicate result with cypher_to_json_bool for proper boolean output in RETURN
8410
9040
  // translateListPredicate returns 0/1 for SQLite WHERE compatibility
@@ -8444,7 +9074,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8444
9074
  // For multiple labels, all must be present (AND)
8445
9075
  const labelChecks = labelsToCheck.map(l => `EXISTS(SELECT 1 FROM json_each(${varInfo.alias}.label) WHERE value = '${l}')`).join(' AND ');
8446
9076
  return {
8447
- sql: `(${labelChecks})`,
9077
+ sql: `CASE WHEN ${varInfo.alias}.id IS NULL THEN NULL ELSE cypher_to_json_bool(${labelChecks}) END`,
8448
9078
  tables,
8449
9079
  params,
8450
9080
  };
@@ -8462,6 +9092,39 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8462
9092
  params,
8463
9093
  };
8464
9094
  }
9095
+ case "mapProjection": {
9096
+ // Map projection: p {.name, .age} or p {.name, key: expr}
9097
+ // This creates a new JSON object with selected properties from the source
9098
+ const sourceResult = this.translateExpression(expr.projectionSource);
9099
+ tables.push(...sourceResult.tables);
9100
+ params.push(...sourceResult.params);
9101
+ const items = expr.projectionItems || [];
9102
+ const jsonParts = [];
9103
+ for (const item of items) {
9104
+ if (item.type === "property") {
9105
+ // .property shorthand - extract property from source
9106
+ const key = item.property;
9107
+ jsonParts.push(`'${key}', json_extract(${sourceResult.sql}, '$.${key}')`);
9108
+ }
9109
+ else if (item.type === "literal") {
9110
+ // key: value syntax
9111
+ const key = item.key;
9112
+ const valueResult = this.translateExpression(item.value);
9113
+ tables.push(...valueResult.tables);
9114
+ params.push(...valueResult.params);
9115
+ jsonParts.push(`'${key}', ${valueResult.sql}`);
9116
+ }
9117
+ else if (item.type === "allProperties") {
9118
+ // .* - project all properties (not yet fully supported, just returns the source)
9119
+ // TODO: implement proper .* support
9120
+ }
9121
+ }
9122
+ return {
9123
+ sql: `json_object(${jsonParts.join(", ")})`,
9124
+ tables,
9125
+ params,
9126
+ };
9127
+ }
8465
9128
  case "in": {
8466
9129
  // value IN list - check if value is in the list
8467
9130
  const listExpr = expr.list;
@@ -8805,6 +9468,16 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8805
9468
  };
8806
9469
  }
8807
9470
  }
9471
+ case "regexMatch": {
9472
+ // Regex match operator: left =~ pattern
9473
+ const leftResult = this.translateExpression(expr.left);
9474
+ const patternResult = this.translateExpression(expr.pattern);
9475
+ return {
9476
+ sql: `cypher_to_json_bool(cypher_regex(${leftResult.sql}, ${patternResult.sql}))`,
9477
+ tables: [...leftResult.tables, ...patternResult.tables],
9478
+ params: [...leftResult.params, ...patternResult.params],
9479
+ };
9480
+ }
8808
9481
  default:
8809
9482
  throw new Error(`Unknown expression type: ${expr.type}`);
8810
9483
  }
@@ -8850,17 +9523,23 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8850
9523
  }
8851
9524
  params.push(...condParams);
8852
9525
  // Translate the result expression
9526
+ // Wrap string literals with json_quote() to preserve them as JSON strings
9527
+ // This ensures 'null' string is returned as '"null"' not null
8853
9528
  const { sql: resultSql, tables: resultTables, params: resultParams } = this.translateExpression(when.result);
8854
9529
  tables.push(...resultTables);
8855
9530
  params.push(...resultParams);
8856
- sql += ` WHEN ${condSql} THEN ${resultSql}`;
9531
+ const isStringLiteral = when.result.type === "literal" && typeof when.result.value === "string";
9532
+ const wrappedResultSql = isStringLiteral ? `json_quote(${resultSql})` : resultSql;
9533
+ sql += ` WHEN ${condSql} THEN ${wrappedResultSql}`;
8857
9534
  }
8858
9535
  // Add ELSE clause if present
8859
9536
  if (expr.elseExpr) {
8860
9537
  const { sql: elseSql, tables: elseTables, params: elseParams } = this.translateExpression(expr.elseExpr);
8861
9538
  tables.push(...elseTables);
8862
9539
  params.push(...elseParams);
8863
- sql += ` ELSE ${elseSql}`;
9540
+ const isStringLiteral = expr.elseExpr.type === "literal" && typeof expr.elseExpr.value === "string";
9541
+ const wrappedElseSql = isStringLiteral ? `json_quote(${elseSql})` : elseSql;
9542
+ sql += ` ELSE ${wrappedElseSql}`;
8864
9543
  }
8865
9544
  sql += " END";
8866
9545
  return { sql, tables, params };
@@ -8944,13 +9623,15 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8944
9623
  const rightIsList = this.isListExpression(expr.right);
8945
9624
  const leftIsStringLiteral = expr.left?.type === "literal" && typeof expr.left.value === "string";
8946
9625
  const rightIsStringLiteral = expr.right?.type === "literal" && typeof expr.right.value === "string";
9626
+ // Helper for json_each that preserves boolean types (SQLite converts true/false to 1/0)
9627
+ const jsonEachWithBooleans = (arrayExpr) => `SELECT CASE json_each.type WHEN 'true' THEN json('true') WHEN 'false' THEN json('false') ELSE json_each.value END as value FROM json_each(${arrayExpr})`;
8947
9628
  if (expr.operator === "+" && leftIsList && rightIsList) {
8948
9629
  // Both are lists: list + list concatenation
8949
9630
  // Pattern: (SELECT json_group_array(value) FROM (SELECT value FROM json_each(left) UNION ALL SELECT value FROM json_each(right)))
8950
9631
  const leftArraySql = this.wrapForArray(expr.left, leftResult.sql);
8951
9632
  const rightArraySql = this.wrapForArray(expr.right, rightResult.sql);
8952
9633
  return {
8953
- sql: `(SELECT json_group_array(value) FROM (SELECT value FROM json_each(${leftArraySql}) UNION ALL SELECT value FROM json_each(${rightArraySql})))`,
9634
+ sql: `(SELECT json_group_array(value) FROM (${jsonEachWithBooleans(leftArraySql)} UNION ALL ${jsonEachWithBooleans(rightArraySql)}))`,
8954
9635
  tables,
8955
9636
  params,
8956
9637
  };
@@ -8958,21 +9639,37 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8958
9639
  if (expr.operator === "+" && leftIsList && !rightIsList) {
8959
9640
  // list + scalar: append scalar to list
8960
9641
  // Use json_quote() to properly convert any scalar (including strings) to JSON
9642
+ // In Cypher, list + null returns null (not [list..., null])
8961
9643
  const leftArraySql = this.wrapForArray(expr.left, leftResult.sql);
8962
9644
  const rightScalarSql = rightResult.sql;
9645
+ // Params order: rightScalarSql (IS NULL) -> leftArraySql -> rightScalarSql (json_quote)
8963
9646
  return {
8964
- sql: `(SELECT json_group_array(value) FROM (SELECT value FROM json_each(${leftArraySql}) UNION ALL SELECT json_quote(${rightScalarSql})))`,
9647
+ sql: `(CASE WHEN ${rightScalarSql} IS NULL THEN NULL ELSE (SELECT json_group_array(value) FROM (${jsonEachWithBooleans(leftArraySql)} UNION ALL SELECT json_quote(${rightScalarSql}))) END)`,
8965
9648
  tables,
8966
- params,
9649
+ params: [...rightResult.params, ...leftResult.params, ...rightResult.params],
8967
9650
  };
8968
9651
  }
8969
9652
  // String concatenation: if either side is definitely a string (literal or string concat chain),
8970
9653
  // use || for concatenation instead of + (numeric addition)
9654
+ // Use cypher_to_string to properly format numbers (integers without .0)
9655
+ // Exception: power expressions always return floats, so use CAST to preserve .0
8971
9656
  const leftIsStringConcat = this.isStringConcatenation(expr.left);
8972
9657
  const rightIsStringConcat = this.isStringConcatenation(expr.right);
8973
9658
  if (expr.operator === "+" && !leftIsList && !rightIsList && (leftIsStringConcat || rightIsStringConcat)) {
8974
- const leftSql = this.wrapForArithmetic(expr.left, leftResult.sql);
8975
- const rightSql = this.wrapForArithmetic(expr.right, rightResult.sql);
9659
+ // Wrap non-string expressions with cypher_to_string for proper number formatting
9660
+ // But for power expressions, use CAST to preserve the .0 suffix (power always returns float)
9661
+ const leftIsPower = this.isPowerExpression(expr.left);
9662
+ const rightIsPower = this.isPowerExpression(expr.right);
9663
+ const leftSql = leftIsStringLiteral || leftIsStringConcat
9664
+ ? this.wrapForArithmetic(expr.left, leftResult.sql)
9665
+ : leftIsPower
9666
+ ? `CAST(${this.wrapForArithmetic(expr.left, leftResult.sql)} AS TEXT)`
9667
+ : `cypher_to_string(${this.wrapForArithmetic(expr.left, leftResult.sql)})`;
9668
+ const rightSql = rightIsStringLiteral || rightIsStringConcat
9669
+ ? this.wrapForArithmetic(expr.right, rightResult.sql)
9670
+ : rightIsPower
9671
+ ? `CAST(${this.wrapForArithmetic(expr.right, rightResult.sql)} AS TEXT)`
9672
+ : `cypher_to_string(${this.wrapForArithmetic(expr.right, rightResult.sql)})`;
8976
9673
  return { sql: `(${leftSql} || ${rightSql})`, tables, params };
8977
9674
  }
8978
9675
  // For property + literal list (where left is property and right is known list)
@@ -9000,12 +9697,15 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9000
9697
  if (expr.operator === "+" && !leftIsList && rightIsList) {
9001
9698
  // scalar + list: prepend scalar to list (only for non-property scalars)
9002
9699
  // Use json_quote() to properly convert any scalar (including strings) to JSON
9700
+ // Use jsonEachWithBooleans to preserve boolean types in the list
9701
+ // In Cypher, null + list returns null (not [null, list...])
9003
9702
  const leftScalarSql = leftResult.sql;
9004
9703
  const rightArraySql = this.wrapForArray(expr.right, rightResult.sql);
9704
+ // Params order: leftScalarSql (IS NULL) -> leftScalarSql (json_quote) -> rightArraySql
9005
9705
  return {
9006
- sql: `(SELECT json_group_array(value) FROM (SELECT json_quote(${leftScalarSql}) as value UNION ALL SELECT value FROM json_each(${rightArraySql})))`,
9706
+ sql: `(CASE WHEN ${leftScalarSql} IS NULL THEN NULL ELSE (SELECT json_group_array(value) FROM (SELECT json_quote(${leftScalarSql}) as value UNION ALL ${jsonEachWithBooleans(rightArraySql)})) END)`,
9007
9707
  tables,
9008
- params,
9708
+ params: [...leftResult.params, ...leftResult.params, ...rightResult.params],
9009
9709
  };
9010
9710
  }
9011
9711
  // For property + property, use a WITH subquery to avoid duplicate parameter references
@@ -9065,6 +9765,87 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9065
9765
  params,
9066
9766
  };
9067
9767
  }
9768
+ // Handle modulo operator - SQLite % only works on integers
9769
+ // For float modulo, use (a - trunc(a/b) * b) formula (truncate toward zero, like C/Java/Neo4j)
9770
+ if (expr.operator === "%") {
9771
+ // Recursive helper to check if expression contains float values
9772
+ const containsFloat = (e) => {
9773
+ if (!e)
9774
+ return false;
9775
+ // Float literal
9776
+ if (e.type === "literal" && typeof e.value === "number") {
9777
+ const literalKind = e.numberLiteralKind;
9778
+ return literalKind === "float";
9779
+ }
9780
+ // Binary expression - check both operands recursively
9781
+ if (e.type === "binary" && (e.left || e.right)) {
9782
+ return containsFloat(e.left) || containsFloat(e.right);
9783
+ }
9784
+ return false;
9785
+ };
9786
+ if (containsFloat(expr.left) || containsFloat(expr.right)) {
9787
+ // Float modulo: a - trunc(a/b) * b
9788
+ // SQLite doesn't have TRUNC, use CAST to INTEGER which truncates toward zero
9789
+ // Note: leftSql is used twice and rightSql is used twice, so duplicate params
9790
+ return {
9791
+ sql: `(${leftSql} - CAST((${leftSql} * 1.0 / ${rightSql}) AS INTEGER) * ${rightSql})`,
9792
+ tables,
9793
+ params: [...leftResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params],
9794
+ };
9795
+ }
9796
+ }
9797
+ // Handle division with proper integer division semantics
9798
+ // In Cypher, integer / integer = integer (truncated toward zero)
9799
+ // SQLite's -> operator returns TEXT which causes float division
9800
+ // We use CAST to ensure integer division when both operands are integers
9801
+ if (expr.operator === "/") {
9802
+ // Recursive helper to check if expression evaluates to integer
9803
+ const isIntegerExpression = (e) => {
9804
+ if (!e)
9805
+ return false;
9806
+ // Integer literal - check numberLiteralKind to distinguish 3 from 3.0
9807
+ if (e.type === "literal" && typeof e.value === "number") {
9808
+ const literalKind = e.numberLiteralKind;
9809
+ // Only consider it integer if explicitly marked as integer (not float)
9810
+ return literalKind === "integer";
9811
+ }
9812
+ // Array index with integer array elements
9813
+ if (e.type === "function" && e.functionName === "INDEX" &&
9814
+ e.args?.[0]?.type === "literal" && Array.isArray(e.args[0].value) &&
9815
+ e.args[0].value.every(v => typeof v === "number" && Number.isInteger(v))) {
9816
+ return true;
9817
+ }
9818
+ // Map property access: {a: 1, b: 2}.a - check if the accessed property is an integer
9819
+ if (e.type === "propertyAccess" && e.object?.type === "object" && e.property) {
9820
+ const mapObj = e.object;
9821
+ const prop = mapObj.properties?.find(p => p.key === e.property);
9822
+ if (prop && isIntegerExpression(prop.value)) {
9823
+ return true;
9824
+ }
9825
+ }
9826
+ // Binary expression where result is integer (both operands are integers and op preserves integerality)
9827
+ if (e.type === "binary" && e.left && e.right) {
9828
+ // Include "/" because integer/integer = integer in Cypher
9829
+ const integerOps = ["+", "-", "*", "%", "/"]; // These preserve integer type
9830
+ if (integerOps.includes(e.operator) && isIntegerExpression(e.left) && isIntegerExpression(e.right)) {
9831
+ return true;
9832
+ }
9833
+ }
9834
+ return false;
9835
+ };
9836
+ // Check if both operands could be integers at compile time
9837
+ const leftIsStaticInt = isIntegerExpression(expr.left);
9838
+ const rightIsStaticInt = isIntegerExpression(expr.right);
9839
+ if (leftIsStaticInt && rightIsStaticInt) {
9840
+ // Both operands are integers, use CAST to ensure integer division
9841
+ // CAST(left AS INTEGER) / CAST(right AS INTEGER) ensures SQLite does integer division
9842
+ return {
9843
+ sql: `(CAST(${leftSql} AS INTEGER) / CAST(${rightSql} AS INTEGER))`,
9844
+ tables,
9845
+ params,
9846
+ };
9847
+ }
9848
+ }
9068
9849
  return {
9069
9850
  sql: `(${leftSql} ${expr.operator} ${rightSql})`,
9070
9851
  tables,
@@ -9563,7 +10344,8 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9563
10344
  }
9564
10345
  if (expr.type === "function") {
9565
10346
  // List-returning functions like collect(), range(), etc.
9566
- const listFunctions = ["COLLECT", "RANGE", "KEYS", "LABELS", "SPLIT", "TAIL", "REVERSE"];
10347
+ // LIST is used by the parser when a list contains complex expressions like objects
10348
+ const listFunctions = ["COLLECT", "RANGE", "KEYS", "LABELS", "SPLIT", "TAIL", "REVERSE", "LIST"];
9567
10349
  return listFunctions.includes(expr.functionName || "");
9568
10350
  }
9569
10351
  if (expr.type === "case") {
@@ -9609,6 +10391,18 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9609
10391
  }
9610
10392
  return false;
9611
10393
  }
10394
+ /**
10395
+ * Check if an expression is a boolean literal (true or false).
10396
+ */
10397
+ isBooleanLiteral(expr) {
10398
+ return expr.type === "literal" && typeof expr.value === "boolean";
10399
+ }
10400
+ /**
10401
+ * Check if an expression is an integer literal.
10402
+ */
10403
+ isIntegerLiteral(expr) {
10404
+ return expr.type === "literal" && typeof expr.value === "number" && Number.isInteger(expr.value);
10405
+ }
9612
10406
  isStringConcatenation(expr) {
9613
10407
  // Check if expression is a string concatenation chain
9614
10408
  // (contains a string literal anywhere in a + chain)
@@ -9626,6 +10420,21 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9626
10420
  }
9627
10421
  return false;
9628
10422
  }
10423
+ /**
10424
+ * Check if an expression is or contains a power operation (^).
10425
+ * In Neo4j, power always returns a float, so when converting to string
10426
+ * we should preserve the .0 suffix for whole numbers.
10427
+ */
10428
+ isPowerExpression(expr) {
10429
+ if (expr.type === "binary" && expr.operator === "^") {
10430
+ return true;
10431
+ }
10432
+ // Check if nested in other binary operations
10433
+ if (expr.type === "binary" && expr.left && expr.right) {
10434
+ return this.isPowerExpression(expr.left) || this.isPowerExpression(expr.right);
10435
+ }
10436
+ return false;
10437
+ }
9629
10438
  /**
9630
10439
  * Check if an expression could produce NaN (IEEE 754 Not a Number).
9631
10440
  * In SQLite, division by zero returns NULL, but Cypher semantics require NaN.
@@ -9890,6 +10699,22 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9890
10699
  };
9891
10700
  }
9892
10701
  }
10702
+ // In Cypher, boolean and integer types are NOT comparable.
10703
+ // `true = 1` should return `false` (not `true`), because they are different types.
10704
+ // Detect when comparing a boolean literal to an integer literal and short-circuit.
10705
+ const leftIsBooleanLiteral = this.isBooleanLiteral(expr.left);
10706
+ const rightIsBooleanLiteral = this.isBooleanLiteral(expr.right);
10707
+ const leftIsIntegerLiteral = this.isIntegerLiteral(expr.left);
10708
+ const rightIsIntegerLiteral = this.isIntegerLiteral(expr.right);
10709
+ if ((leftIsBooleanLiteral && rightIsIntegerLiteral) || (leftIsIntegerLiteral && rightIsBooleanLiteral)) {
10710
+ // Different types: boolean vs integer -> always false for =, always true for <>
10711
+ if (expr.comparisonOperator === "=") {
10712
+ return { sql: `json('false')`, tables, params: [] };
10713
+ }
10714
+ if (expr.comparisonOperator === "<>") {
10715
+ return { sql: `json('true')`, tables, params: [] };
10716
+ }
10717
+ }
9893
10718
  // For equality comparisons, use cypher_bool_eq to handle mixed boolean representations
9894
10719
  // (JSON boolean strings 'true'/'false' vs SQLite integers 1/0)
9895
10720
  if (expr.comparisonOperator === "=") {
@@ -9994,19 +10819,34 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9994
10819
  * Translates to SQLite using json_each and json_group_array:
9995
10820
  * (SELECT json_group_array(value_or_mapped) FROM json_each(listExpr) WHERE filter)
9996
10821
  */
9997
- translateListComprehension(expr) {
10822
+ translateListComprehension(expr, outerScopes) {
9998
10823
  const tables = [];
9999
10824
  const params = [];
10000
10825
  const variable = expr.variable;
10001
10826
  const listExpr = expr.listExpr;
10002
10827
  const filterCondition = expr.filterCondition;
10003
10828
  const mapExpr = expr.mapExpr;
10004
- // Translate the source list expression
10005
- const listResult = this.translateExpression(listExpr);
10829
+ // Generate a unique table alias for this comprehension to avoid conflicts in nested comprehensions
10830
+ const tableAlias = outerScopes ? `__lc${outerScopes.length}__` : "__lc__";
10831
+ // Build the current scope chain
10832
+ const currentScope = { variable, tableAlias };
10833
+ const allScopes = outerScopes ? [...outerScopes, currentScope] : [currentScope];
10834
+ // Translate the source list expression, potentially referencing outer scope variables
10835
+ let listResult;
10836
+ if (outerScopes && outerScopes.length > 0) {
10837
+ // Use translateListComprehensionExpr to handle references to outer variables
10838
+ const lastOuterScope = outerScopes[outerScopes.length - 1];
10839
+ listResult = this.translateListComprehensionExpr(listExpr, lastOuterScope.variable, lastOuterScope.tableAlias, outerScopes.slice(0, -1));
10840
+ // Wrap in an object to match expected structure
10841
+ listResult = { sql: listResult.sql, tables: [], params: listResult.params };
10842
+ }
10843
+ else {
10844
+ listResult = this.translateExpression(listExpr);
10845
+ }
10006
10846
  tables.push(...listResult.tables);
10007
10847
  // Wrap the source expression for json_each
10008
10848
  let sourceExpr = listResult.sql;
10009
- if (listExpr.type === "property") {
10849
+ if (listExpr.type === "property" && !outerScopes) {
10010
10850
  // For property access, use json_extract
10011
10851
  const varInfo = this.ctx.variables.get(listExpr.variable);
10012
10852
  if (varInfo) {
@@ -10014,23 +10854,34 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10014
10854
  }
10015
10855
  }
10016
10856
  // Determine what to select: the mapped expression or just the value
10017
- let selectExpr = `__lc__.value`;
10857
+ let selectExpr = `${tableAlias}.value`;
10018
10858
  let mapParams = [];
10019
10859
  if (mapExpr) {
10020
- const mapResult = this.translateListComprehensionExpr(mapExpr, variable, "__lc__");
10021
- mapParams = mapResult.params;
10022
- selectExpr = mapResult.sql;
10860
+ // Check if mapExpr is a nested list comprehension
10861
+ if (mapExpr.type === "listComprehension") {
10862
+ // Recursively translate nested list comprehension with current scope
10863
+ const nestedResult = this.translateListComprehension(mapExpr, allScopes);
10864
+ mapParams = nestedResult.params;
10865
+ selectExpr = nestedResult.sql;
10866
+ tables.push(...nestedResult.tables);
10867
+ }
10868
+ else {
10869
+ // Pass outerScopes (without current) so inner can access outer variables
10870
+ const mapResult = this.translateListComprehensionExpr(mapExpr, variable, tableAlias, outerScopes);
10871
+ mapParams = mapResult.params;
10872
+ selectExpr = mapResult.sql;
10873
+ }
10023
10874
  }
10024
10875
  // Build the WHERE clause if filter is present
10025
10876
  let whereClause = "";
10026
10877
  let filterParams = [];
10027
10878
  if (filterCondition) {
10028
- const filterResult = this.translateListComprehensionCondition(filterCondition, variable, "__lc__");
10879
+ const filterResult = this.translateListComprehensionCondition(filterCondition, variable, tableAlias, outerScopes);
10029
10880
  filterParams = filterResult.params;
10030
10881
  whereClause = ` WHERE ${filterResult.sql}`;
10031
10882
  }
10032
10883
  // Build the final SQL using json_group_array
10033
- const sql = `(SELECT json_group_array(${selectExpr}) FROM json_each(${sourceExpr}) AS __lc__${whereClause})`;
10884
+ const sql = `(SELECT json_group_array(${selectExpr}) FROM json_each(${sourceExpr}) AS ${tableAlias}${whereClause})`;
10034
10885
  // Params must match SQL order: selectExpr params, then source params, then filter params
10035
10886
  params.push(...mapParams, ...listResult.params, ...filterParams);
10036
10887
  return { sql, tables, params };
@@ -10057,26 +10908,62 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10057
10908
  // Translate the source list expression
10058
10909
  const listResult = this.translateExpression(listExpr);
10059
10910
  tables.push(...listResult.tables);
10911
+ // Check if the list expression is a WITH alias that contains an aggregation
10912
+ // In that case, we need to avoid putting the aggregation inside the recursive CTE
10913
+ // because SQLite doesn't allow aggregate functions in that context.
10914
+ let listExprSql = listResult.sql;
10915
+ let needsAggregateWrapper = false;
10916
+ if (listExpr.type === "variable") {
10917
+ const withAliases = this.ctx.withAliases;
10918
+ if (withAliases && withAliases.has(listExpr.variable)) {
10919
+ const aliasExpr = withAliases.get(listExpr.variable);
10920
+ if (this.isAggregateExpression(aliasExpr)) {
10921
+ needsAggregateWrapper = true;
10922
+ }
10923
+ }
10924
+ }
10060
10925
  // Build the reduce expression with variable substitutions
10061
10926
  // Replace accumulator with "__red__.acc" and variable with "__red_elem.value"
10062
10927
  const reduceResult = this.translateReduceBodyExpr(reduceExpr, accumulator, variable, "__red__", "__red_elem");
10063
- // Build recursive CTE:
10064
- // WITH RECURSIVE __red__(idx, acc) AS (
10065
- // SELECT 0, <init>
10066
- // UNION ALL
10067
- // SELECT idx + 1, <reduceExpr>
10068
- // FROM __red__, json_each(<list>) AS __red_elem__
10069
- // WHERE __red__.idx = __red_elem__.key
10070
- // )
10071
- // SELECT acc FROM __red__ ORDER BY idx DESC LIMIT 1
10072
- const sql = `(WITH RECURSIVE __red__(idx, acc) AS (
10928
+ let sql;
10929
+ if (needsAggregateWrapper) {
10930
+ // For aggregation-based lists, use a subquery approach that avoids aggregate in recursive CTE
10931
+ // This uses the json_array_reduce helper pattern that iterates without recursion
10932
+ // SQLite's json_each produces key/value pairs we can iterate over
10933
+ // We calculate the reduce using a correlated subquery sum pattern for numeric operations
10934
+ // Check if this is a simple addition pattern: acc + x or x + acc
10935
+ const isSimpleAddition = reduceExpr.type === "binary" && reduceExpr.operator === "+";
10936
+ if (isSimpleAddition) {
10937
+ // For simple addition, use SUM which works with aggregations
10938
+ // reduce(s=0, x IN list | s+x) is equivalent to COALESCE(init + SUM(values), init)
10939
+ sql = `(${initResult.sql} + COALESCE((SELECT SUM(__elem.value) FROM json_each(${listExprSql}) AS __elem), 0))`;
10940
+ }
10941
+ else {
10942
+ // For complex reduce operations, we need to use a window function approach
10943
+ // or materialize the list first. For now, fall back to the standard CTE approach
10944
+ // but wrap the list in a scalar subquery to try to avoid the aggregate issue.
10945
+ // This may still fail for some patterns, but handles many cases.
10946
+ sql = `(WITH RECURSIVE __red__(idx, acc) AS (
10073
10947
  SELECT 0, ${initResult.sql}
10074
10948
  UNION ALL
10075
10949
  SELECT __red__.idx + 1, ${reduceResult.sql}
10076
- FROM __red__, json_each(${listResult.sql}) AS __red_elem
10950
+ FROM __red__, json_each(${listExprSql}) AS __red_elem
10077
10951
  WHERE __red__.idx = __red_elem.key
10078
10952
  )
10079
10953
  SELECT acc FROM __red__ ORDER BY idx DESC LIMIT 1)`;
10954
+ }
10955
+ }
10956
+ else {
10957
+ // Standard recursive CTE approach for non-aggregate lists
10958
+ sql = `(WITH RECURSIVE __red__(idx, acc) AS (
10959
+ SELECT 0, ${initResult.sql}
10960
+ UNION ALL
10961
+ SELECT __red__.idx + 1, ${reduceResult.sql}
10962
+ FROM __red__, json_each(${listExprSql}) AS __red_elem
10963
+ WHERE __red__.idx = __red_elem.key
10964
+ )
10965
+ SELECT acc FROM __red__ ORDER BY idx DESC LIMIT 1)`;
10966
+ }
10080
10967
  // Params order: init params, then list params (once for each occurrence), then reduce params
10081
10968
  params.push(...initResult.params, ...listResult.params, ...reduceResult.params);
10082
10969
  return { sql, tables, params };
@@ -10483,6 +11370,119 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10483
11370
  tables.push(boundVarInfo.alias);
10484
11371
  return { sql, tables, params };
10485
11372
  }
11373
+ /**
11374
+ * Translate a size() function on a pattern expression.
11375
+ *
11376
+ * Pattern: size((n)-[:R]->())
11377
+ * Returns the count of matching relationships.
11378
+ *
11379
+ * Example: size((p)-[:KNOWS]->()) returns the count of outgoing KNOWS edges for node p
11380
+ */
11381
+ translateSizePattern(expr) {
11382
+ const tables = [];
11383
+ const params = [];
11384
+ const patterns = expr.patterns;
11385
+ // Pattern structure: patterns from parsePatternChain
11386
+ const firstPattern = patterns[0];
11387
+ const isRelPattern = (p) => {
11388
+ return typeof p === "object" && p !== null && "edge" in p;
11389
+ };
11390
+ let startVar;
11391
+ let relPattern;
11392
+ let startNodePattern;
11393
+ let targetNodePattern;
11394
+ if (isRelPattern(firstPattern)) {
11395
+ // First pattern is a RelationshipPattern
11396
+ relPattern = firstPattern;
11397
+ startNodePattern = relPattern.source;
11398
+ targetNodePattern = relPattern.target;
11399
+ startVar = startNodePattern.variable;
11400
+ }
11401
+ else {
11402
+ // First pattern is a NodePattern, look for RelationshipPattern in rest
11403
+ startNodePattern = firstPattern;
11404
+ startVar = startNodePattern.variable;
11405
+ for (let i = 1; i < patterns.length; i++) {
11406
+ if (isRelPattern(patterns[i])) {
11407
+ relPattern = patterns[i];
11408
+ targetNodePattern = relPattern.target;
11409
+ break;
11410
+ }
11411
+ }
11412
+ }
11413
+ if (!startVar) {
11414
+ throw new Error("size() pattern must start with a bound variable");
11415
+ }
11416
+ // Get the bound variable info from outer context
11417
+ const boundVarInfo = this.ctx.variables.get(startVar);
11418
+ if (!boundVarInfo) {
11419
+ throw new Error(`Unknown variable in size() pattern: ${startVar}`);
11420
+ }
11421
+ if (!relPattern) {
11422
+ throw new Error("size() pattern must include a relationship pattern");
11423
+ }
11424
+ // Build the correlated subquery
11425
+ const edgeAlias = `__sz_e_${this.ctx.aliasCounter++}`;
11426
+ const targetAlias = `__sz_t_${this.ctx.aliasCounter++}`;
11427
+ const edge = relPattern.edge;
11428
+ // Build edge type filter
11429
+ const edgeTypes = edge.types || (edge.type ? [edge.type] : []);
11430
+ let edgeTypeFilter = "";
11431
+ const edgeTypeParams = [];
11432
+ if (edgeTypes.length > 0) {
11433
+ const typeConditions = edgeTypes.map((t) => `${edgeAlias}.type = ?`);
11434
+ edgeTypeFilter = ` AND (${typeConditions.join(" OR ")})`;
11435
+ edgeTypeParams.push(...edgeTypes);
11436
+ }
11437
+ // Build direction filter
11438
+ let directionFilter = "";
11439
+ const direction = edge.direction || "right";
11440
+ if (direction === "right") {
11441
+ directionFilter = `${edgeAlias}.source_id = ${boundVarInfo.alias}.id`;
11442
+ }
11443
+ else if (direction === "left") {
11444
+ directionFilter = `${edgeAlias}.target_id = ${boundVarInfo.alias}.id`;
11445
+ }
11446
+ else {
11447
+ // "none" means either direction
11448
+ directionFilter = `(${edgeAlias}.source_id = ${boundVarInfo.alias}.id OR ${edgeAlias}.target_id = ${boundVarInfo.alias}.id)`;
11449
+ }
11450
+ // Build target node filter if labels specified
11451
+ let targetFilter = "";
11452
+ const targetFilterParams = [];
11453
+ if (targetNodePattern && targetNodePattern.label) {
11454
+ const labels = Array.isArray(targetNodePattern.label)
11455
+ ? targetNodePattern.label
11456
+ : [targetNodePattern.label];
11457
+ const labelConditions = labels.map((l) => `EXISTS(SELECT 1 FROM json_each(${targetAlias}.label) WHERE value = ?)`);
11458
+ targetFilter = ` AND ${labelConditions.join(" AND ")}`;
11459
+ targetFilterParams.push(...labels);
11460
+ }
11461
+ // Build the from clause
11462
+ let fromClause = `edges ${edgeAlias}`;
11463
+ if (targetNodePattern && (targetNodePattern.label || targetNodePattern.variable)) {
11464
+ // Need to join with nodes for target filtering
11465
+ let targetJoin;
11466
+ if (direction === "right") {
11467
+ targetJoin = `${edgeAlias}.target_id = ${targetAlias}.id`;
11468
+ }
11469
+ else if (direction === "left") {
11470
+ targetJoin = `${edgeAlias}.source_id = ${targetAlias}.id`;
11471
+ }
11472
+ else {
11473
+ // For undirected, target is the "other" node
11474
+ targetJoin = `(CASE WHEN ${edgeAlias}.source_id = ${boundVarInfo.alias}.id THEN ${edgeAlias}.target_id ELSE ${edgeAlias}.source_id END) = ${targetAlias}.id`;
11475
+ }
11476
+ fromClause = `edges ${edgeAlias} JOIN nodes ${targetAlias} ON ${targetJoin}`;
11477
+ }
11478
+ // Build the COUNT subquery (unlike EXISTS, we want the actual count)
11479
+ const sql = `(SELECT COUNT(*) FROM ${fromClause} WHERE ${directionFilter}${edgeTypeFilter}${targetFilter})`;
11480
+ // Params must be in SQL order: edgeType, then targetFilter
11481
+ params.push(...edgeTypeParams, ...targetFilterParams);
11482
+ // Add outer table reference
11483
+ tables.push(boundVarInfo.alias);
11484
+ return { sql, tables, params };
11485
+ }
10486
11486
  /**
10487
11487
  * Translate an expression within a pattern comprehension.
10488
11488
  */
@@ -10618,6 +11618,11 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10618
11618
  if (expr.value === false) {
10619
11619
  return { sql: "0", params };
10620
11620
  }
11621
+ // Handle array literals - need to serialize as JSON for json_each
11622
+ if (Array.isArray(expr.value)) {
11623
+ params.push(JSON.stringify(expr.value));
11624
+ return { sql: "?", params };
11625
+ }
10621
11626
  params.push(expr.value);
10622
11627
  return { sql: "?", params };
10623
11628
  case "parameter": {
@@ -10630,6 +11635,14 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10630
11635
  }
10631
11636
  return { sql: "?", params };
10632
11637
  }
11638
+ case "listComprehension": {
11639
+ // Handle nested list comprehensions - pass the current scope chain
11640
+ const allScopes = scopes
11641
+ ? [...scopes, { variable: compVar, tableAlias }]
11642
+ : [{ variable: compVar, tableAlias }];
11643
+ const nestedResult = this.translateListComprehension(expr, allScopes);
11644
+ return { sql: nestedResult.sql, params: nestedResult.params };
11645
+ }
10633
11646
  case "function": {
10634
11647
  // Handle functions like size(x)
10635
11648
  const funcArgs = [];
@@ -10666,6 +11679,17 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10666
11679
  // SQLite's RANDOM() returns integer, convert to 0-1 range
10667
11680
  return { sql: `((RANDOM() + 9223372036854775808) / 18446744073709551615.0)`, params };
10668
11681
  }
11682
+ if (funcName === "TYPE") {
11683
+ // type() on a relationship in list comprehension context (e.g., [r IN relationships(p) | type(r)])
11684
+ // The relationship value is a JSON object containing "type" directly
11685
+ // Validate that the argument is a relationship object with a type field
11686
+ // If not, trigger an error (TCK requirement: type() on invalid arguments should fail)
11687
+ const arg = funcArgs[0];
11688
+ return {
11689
+ sql: `CASE WHEN json_type(${arg}) = 'object' AND json_extract(${arg}, '$.type') IS NOT NULL THEN json_extract(${arg}, '$.type') ELSE json('type() requires a relationship argument') END`,
11690
+ params
11691
+ };
11692
+ }
10669
11693
  // Fall back to regular translation for unknown functions
10670
11694
  const result = this.translateExpression(expr);
10671
11695
  return { sql: result.sql, params: result.params };
@@ -11324,6 +12348,14 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
11324
12348
  case "exists": {
11325
12349
  return this.translateExistsCondition(condition);
11326
12350
  }
12351
+ case "propertyExists": {
12352
+ // exists(n.property) - checks if property is not NULL
12353
+ const expr = this.translateWhereExpression(condition.expression);
12354
+ return {
12355
+ sql: `${expr.sql} IS NOT NULL`,
12356
+ params: expr.params,
12357
+ };
12358
+ }
11327
12359
  case "in": {
11328
12360
  return this.translateInCondition(condition);
11329
12361
  }
@@ -11829,6 +12861,18 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
11829
12861
  targetVariable = aliasedExpr.variable;
11830
12862
  }
11831
12863
  }
12864
+ // Check if this is a property access on an UNWIND variable
12865
+ const unwindClauses = this.ctx.unwindClauses;
12866
+ if (unwindClauses) {
12867
+ const unwindClause = unwindClauses.find(u => u.variable === targetVariable);
12868
+ if (unwindClause) {
12869
+ // UNWIND variables use the 'value' column from json_each
12870
+ return {
12871
+ sql: this.buildDateTimeWithOffsetOrderBy(`json_extract(${unwindClause.alias}.value, '$.${expr.property}')`),
12872
+ params: [],
12873
+ };
12874
+ }
12875
+ }
11832
12876
  const varInfo = this.ctx.variables.get(targetVariable);
11833
12877
  if (!varInfo) {
11834
12878
  throw new Error(`Unknown variable: ${expr.variable}`);
@@ -11939,8 +12983,9 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
11939
12983
  }
11940
12984
  case "binary":
11941
12985
  case "unary":
11942
- case "literal": {
11943
- // For complex expressions (binary, literal, etc.), translate them
12986
+ case "literal":
12987
+ case "case": {
12988
+ // For complex expressions (binary, literal, case, etc.), translate them
11944
12989
  // but substitute variables with column aliases when they are RETURN aliases
11945
12990
  return this.translateOrderByComplexExpression(expr, returnAliases);
11946
12991
  }
@@ -12060,7 +13105,18 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
12060
13105
  }
12061
13106
  case "literal": {
12062
13107
  // Convert booleans to 1/0 for SQLite
12063
- const value = expr.value === true ? 1 : expr.value === false ? 0 : expr.value;
13108
+ // Arrays and objects need to be JSON serialized
13109
+ let value = expr.value;
13110
+ if (value === true) {
13111
+ value = 1;
13112
+ }
13113
+ else if (value === false) {
13114
+ value = 0;
13115
+ }
13116
+ else if (Array.isArray(value) || (value !== null && typeof value === "object")) {
13117
+ // For arrays/objects in WHERE expressions, use json() to create proper JSON value
13118
+ return { sql: `json(?)`, params: [JSON.stringify(value)] };
13119
+ }
12064
13120
  return { sql: "?", params: [value] };
12065
13121
  }
12066
13122
  case "parameter": {
@@ -12168,6 +13224,16 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
12168
13224
  const result = this.translateCaseExpression(expr);
12169
13225
  return { sql: result.sql, params: result.params };
12170
13226
  }
13227
+ case "sizePattern": {
13228
+ // size((n)-[:REL]->()) pattern in WHERE clause
13229
+ const result = this.translateSizePattern(expr);
13230
+ return { sql: result.sql, params: result.params };
13231
+ }
13232
+ case "object": {
13233
+ // Map literal in WHERE clause (e.g., WITH {a: 1} AS x WHERE x IS NOT NULL)
13234
+ const result = this.translateObjectLiteral(expr);
13235
+ return { sql: result.sql, params: result.params };
13236
+ }
12171
13237
  default:
12172
13238
  throw new Error(`Unknown expression type in WHERE: ${expr.type}`);
12173
13239
  }
@@ -12297,6 +13363,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
12297
13363
  * Check if a property name is a temporal accessor (like year, month, day, etc.)
12298
13364
  * and return the SQL expression to extract it from a temporal value.
12299
13365
  * Returns null if not a temporal accessor.
13366
+ * Also returns a paramMultiplier indicating how many times the baseSql params need to be repeated.
12300
13367
  */
12301
13368
  translateTemporalPropertyAccess(baseSql, propertyName) {
12302
13369
  // If baseSql uses the -> operator, we need to use json_extract to get the actual value
@@ -12403,7 +13470,14 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
12403
13470
  "microsecondsOfSecond": `CAST(COALESCE(NULLIF(substr(${valueExpr}, instr(${valueExpr}, '.') + 1, 6), ''), '0') AS INTEGER)`,
12404
13471
  "nanosecondsOfSecond": `CAST(COALESCE(NULLIF(substr(${valueExpr}, instr(${valueExpr}, '.') + 1, 9), ''), '0') AS INTEGER)`,
12405
13472
  };
12406
- return temporalAccessors[propertyName] ?? durationAccessors[propertyName] ?? null;
13473
+ const sql = temporalAccessors[propertyName] ?? durationAccessors[propertyName] ?? null;
13474
+ if (sql === null) {
13475
+ return null;
13476
+ }
13477
+ // Count how many times the valueExpr appears in the SQL to know how many times to repeat params
13478
+ // We count occurrences of valueExpr in the template result
13479
+ const paramMultiplier = (sql.split(valueExpr).length - 1);
13480
+ return { sql, paramMultiplier };
12407
13481
  }
12408
13482
  isRelationshipPattern(pattern) {
12409
13483
  return "source" in pattern && "edge" in pattern && "target" in pattern;
@@ -12447,7 +13521,9 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
12447
13521
  */
12448
13522
  quoteAlias(alias) {
12449
13523
  // SQLite uses double quotes for identifiers
12450
- return `"${alias}"`;
13524
+ // Double quotes inside the identifier must be escaped by doubling them
13525
+ const escaped = alias.replace(/"/g, '""');
13526
+ return `"${escaped}"`;
12451
13527
  }
12452
13528
  expressionReferencesGraphVariables(expr, withAliases, visitingAliases = new Set()) {
12453
13529
  for (const varName of this.findVariablesInExpression(expr)) {
@@ -12755,6 +13831,102 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
12755
13831
  collectFromExpr(expr);
12756
13832
  return aliases;
12757
13833
  }
13834
+ /**
13835
+ * Collect WITH aggregate aliases that are referenced inside aggregate expressions in RETURN.
13836
+ * This handles "double aggregation" like: WITH ... collect(x) as vals RETURN collect({v: vals}) as all
13837
+ * When an aggregate in RETURN references a WITH aggregate alias, we need to materialize
13838
+ * the WITH aggregates in a CTE first to avoid nested aggregate function errors in SQLite.
13839
+ */
13840
+ collectWithAggregateAliasesFromAggregateExpressions(expr, withAliases) {
13841
+ const aliases = new Set();
13842
+ if (!withAliases)
13843
+ return aliases;
13844
+ // Helper to collect WITH aggregate aliases from an expression
13845
+ const collectAggregateAliases = (e) => {
13846
+ if (e.type === "variable" && e.variable) {
13847
+ const aliasExpr = withAliases.get(e.variable);
13848
+ if (aliasExpr && this.isAggregateExpression(aliasExpr)) {
13849
+ aliases.add(e.variable);
13850
+ }
13851
+ }
13852
+ // Recursively check sub-expressions
13853
+ if (e.type === "binary") {
13854
+ if (e.left)
13855
+ collectAggregateAliases(e.left);
13856
+ if (e.right)
13857
+ collectAggregateAliases(e.right);
13858
+ }
13859
+ if (e.type === "function" && e.args) {
13860
+ for (const arg of e.args) {
13861
+ collectAggregateAliases(arg);
13862
+ }
13863
+ }
13864
+ if (e.type === "comparison") {
13865
+ if (e.left)
13866
+ collectAggregateAliases(e.left);
13867
+ if (e.right)
13868
+ collectAggregateAliases(e.right);
13869
+ }
13870
+ if (e.type === "unary" && e.operand) {
13871
+ collectAggregateAliases(e.operand);
13872
+ }
13873
+ if (e.type === "case") {
13874
+ if (e.expression)
13875
+ collectAggregateAliases(e.expression);
13876
+ for (const when of e.whens || []) {
13877
+ if (when.result)
13878
+ collectAggregateAliases(when.result);
13879
+ }
13880
+ if (e.elseExpr)
13881
+ collectAggregateAliases(e.elseExpr);
13882
+ }
13883
+ if (e.type === "object" && e.properties) {
13884
+ for (const prop of e.properties) {
13885
+ if (prop.value)
13886
+ collectAggregateAliases(prop.value);
13887
+ }
13888
+ }
13889
+ if (e.type === "list" && e.elements) {
13890
+ for (const el of e.elements) {
13891
+ collectAggregateAliases(el);
13892
+ }
13893
+ }
13894
+ };
13895
+ // Only check inside aggregate expressions - if this expression IS an aggregate,
13896
+ // collect any WITH aggregate aliases it references
13897
+ if (this.isAggregateExpression(expr)) {
13898
+ // For aggregate functions, check their arguments
13899
+ if (expr.type === "function" && expr.args) {
13900
+ for (const arg of expr.args) {
13901
+ collectAggregateAliases(arg);
13902
+ }
13903
+ }
13904
+ }
13905
+ // Also recursively check for nested aggregates
13906
+ if (expr.type === "binary") {
13907
+ const leftAliases = this.collectWithAggregateAliasesFromAggregateExpressions(expr.left, withAliases);
13908
+ const rightAliases = this.collectWithAggregateAliasesFromAggregateExpressions(expr.right, withAliases);
13909
+ leftAliases.forEach(a => aliases.add(a));
13910
+ rightAliases.forEach(a => aliases.add(a));
13911
+ }
13912
+ if (expr.type === "case") {
13913
+ if (expr.expression) {
13914
+ const exprAliases = this.collectWithAggregateAliasesFromAggregateExpressions(expr.expression, withAliases);
13915
+ exprAliases.forEach(a => aliases.add(a));
13916
+ }
13917
+ for (const when of expr.whens || []) {
13918
+ if (when.result) {
13919
+ const resultAliases = this.collectWithAggregateAliasesFromAggregateExpressions(when.result, withAliases);
13920
+ resultAliases.forEach(a => aliases.add(a));
13921
+ }
13922
+ }
13923
+ if (expr.elseExpr) {
13924
+ const elseAliases = this.collectWithAggregateAliasesFromAggregateExpressions(expr.elseExpr, withAliases);
13925
+ elseAliases.forEach(a => aliases.add(a));
13926
+ }
13927
+ }
13928
+ return aliases;
13929
+ }
12758
13930
  /**
12759
13931
  * Check if a WHERE condition references any alias that resolves to an aggregate expression.
12760
13932
  * This is used to determine if the condition should go in HAVING instead of WHERE.
@@ -12864,7 +14036,15 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
12864
14036
  }
12865
14037
  }
12866
14038
  }
12867
- return { json: JSON.stringify(resolved), params };
14039
+ // In Neo4j/Cypher, setting a property to null means the property is not stored.
14040
+ // Filter out properties with null values.
14041
+ const filtered = {};
14042
+ for (const [key, value] of Object.entries(resolved)) {
14043
+ if (value !== null) {
14044
+ filtered[key] = value;
14045
+ }
14046
+ }
14047
+ return { json: JSON.stringify(filtered), params };
12868
14048
  }
12869
14049
  isFunctionPropertyValue(value) {
12870
14050
  return (typeof value === "object" &&
@@ -12927,7 +14107,13 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
12927
14107
  case "+": return leftNum + rightNum;
12928
14108
  case "-": return leftNum - rightNum;
12929
14109
  case "*": return leftNum * rightNum;
12930
- case "/": return leftNum / rightNum;
14110
+ case "/": {
14111
+ // Cypher integer division: if both operands are integers, truncate toward zero
14112
+ if (Number.isInteger(leftNum) && Number.isInteger(rightNum)) {
14113
+ return Math.trunc(leftNum / rightNum);
14114
+ }
14115
+ return leftNum / rightNum;
14116
+ }
12931
14117
  case "%": return leftNum % rightNum;
12932
14118
  case "^": return Math.pow(leftNum, rightNum);
12933
14119
  default: throw new Error("Unknown operator");
@@ -13155,6 +14341,9 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
13155
14341
  throw new Error(`Cannot evaluate expression of type ${expr.type}`);
13156
14342
  }
13157
14343
  }
14344
+ getReturnItemName(item) {
14345
+ return item.alias || item.rawExpression || this.getExpressionName(item.expression);
14346
+ }
13158
14347
  getExpressionName(expr) {
13159
14348
  switch (expr.type) {
13160
14349
  case "variable":
@@ -13163,10 +14352,28 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
13163
14352
  return `${expr.variable}.${expr.property}`;
13164
14353
  case "function": {
13165
14354
  // Build full function call representation: function(args)
13166
- const funcName = expr.functionName.toLowerCase();
14355
+ const canonicalFunctionName = (name) => {
14356
+ switch (name.toUpperCase()) {
14357
+ case "TOUPPER":
14358
+ return "toUpper";
14359
+ case "TOLOWER":
14360
+ return "toLower";
14361
+ case "TOINTEGER":
14362
+ return "toInteger";
14363
+ case "TOFLOAT":
14364
+ return "toFloat";
14365
+ case "TOSTRING":
14366
+ return "toString";
14367
+ case "TOBOOLEAN":
14368
+ return "toBoolean";
14369
+ default:
14370
+ return name.toLowerCase();
14371
+ }
14372
+ };
14373
+ const funcName = canonicalFunctionName(expr.functionName);
13167
14374
  if (expr.args && expr.args.length > 0) {
13168
14375
  // Special case: INDEX function should be rendered as list[index] notation
13169
- if (funcName === "index" && expr.args.length === 2) {
14376
+ if (funcName.toLowerCase() === "index" && expr.args.length === 2) {
13170
14377
  const listName = this.getExpressionName(expr.args[0]);
13171
14378
  const indexName = this.getExpressionName(expr.args[1]);
13172
14379
  return `${listName}[${indexName}]`;
@@ -13189,7 +14396,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
13189
14396
  case "literal": {
13190
14397
  // For literals, use the string representation of the value
13191
14398
  if (expr.value === null)
13192
- return "NULL";
14399
+ return "null";
13193
14400
  if (typeof expr.value === "string")
13194
14401
  return `'${expr.value}'`;
13195
14402
  // For arrays/objects, use JSON.stringify to preserve nested structure
@@ -13234,6 +14441,164 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
13234
14441
  const operandName = this.getExpressionName(expr.operand);
13235
14442
  return `${expr.operator} ${operandName}`;
13236
14443
  }
14444
+ case "case": {
14445
+ const parts = ["CASE"];
14446
+ // Simple form: CASE <expr> WHEN <value> THEN <result> ...
14447
+ if (expr.expression) {
14448
+ parts.push(this.getExpressionName(expr.expression));
14449
+ }
14450
+ const whens = expr.whens ?? [];
14451
+ for (const whenClause of whens) {
14452
+ parts.push("WHEN");
14453
+ // If we have a simple-form CASE, the WHEN condition is stored as equality
14454
+ // against the CASE expression. For naming, reconstruct the original WHEN value.
14455
+ if (expr.expression &&
14456
+ whenClause.condition.type === "comparison" &&
14457
+ whenClause.condition.operator === "=" &&
14458
+ whenClause.condition.right) {
14459
+ parts.push(this.getExpressionName(whenClause.condition.right));
14460
+ }
14461
+ else {
14462
+ // Best-effort stringification for searched CASE conditions
14463
+ const cond = whenClause.condition;
14464
+ if (cond.type === "comparison" && cond.left && cond.right && cond.operator) {
14465
+ parts.push(`${this.getExpressionName(cond.left)} ${cond.operator} ${this.getExpressionName(cond.right)}`);
14466
+ }
14467
+ else if (cond.type === "and" && cond.conditions) {
14468
+ parts.push(cond.conditions.map(c => (c.type === "comparison" && c.left && c.right && c.operator)
14469
+ ? `${this.getExpressionName(c.left)} ${c.operator} ${this.getExpressionName(c.right)}`
14470
+ : "expr").join(" AND "));
14471
+ }
14472
+ else if (cond.type === "or" && cond.conditions) {
14473
+ parts.push(cond.conditions.map(c => (c.type === "comparison" && c.left && c.right && c.operator)
14474
+ ? `${this.getExpressionName(c.left)} ${c.operator} ${this.getExpressionName(c.right)}`
14475
+ : "expr").join(" OR "));
14476
+ }
14477
+ else if (cond.type === "not" && cond.condition) {
14478
+ parts.push(`NOT ${"expr"}`);
14479
+ }
14480
+ else if (cond.type === "isNull" && cond.left) {
14481
+ parts.push(`${this.getExpressionName(cond.left)} IS NULL`);
14482
+ }
14483
+ else if (cond.type === "isNotNull" && cond.left) {
14484
+ parts.push(`${this.getExpressionName(cond.left)} IS NOT NULL`);
14485
+ }
14486
+ else {
14487
+ parts.push("expr");
14488
+ }
14489
+ }
14490
+ parts.push("THEN");
14491
+ parts.push(this.getExpressionName(whenClause.result));
14492
+ }
14493
+ if (expr.elseExpr) {
14494
+ parts.push("ELSE");
14495
+ parts.push(this.getExpressionName(expr.elseExpr));
14496
+ }
14497
+ parts.push("END");
14498
+ return parts.join(" ");
14499
+ }
14500
+ case "listComprehension": {
14501
+ const canonicalFunctionName = (name) => {
14502
+ switch (name.toUpperCase()) {
14503
+ case "TOUPPER":
14504
+ return "toUpper";
14505
+ case "TOLOWER":
14506
+ return "toLower";
14507
+ case "TOINTEGER":
14508
+ return "toInteger";
14509
+ case "TOFLOAT":
14510
+ return "toFloat";
14511
+ case "TOSTRING":
14512
+ return "toString";
14513
+ case "TOBOOLEAN":
14514
+ return "toBoolean";
14515
+ default:
14516
+ return name.toLowerCase();
14517
+ }
14518
+ };
14519
+ const formatLiteralValue = (value) => {
14520
+ if (value === null)
14521
+ return "null";
14522
+ if (typeof value === "string")
14523
+ return `'${value}'`;
14524
+ if (typeof value === "boolean")
14525
+ return value ? "true" : "false";
14526
+ if (typeof value === "number")
14527
+ return String(value);
14528
+ if (Array.isArray(value)) {
14529
+ return `[${value.map(formatLiteralValue).join(", ")}]`;
14530
+ }
14531
+ if (typeof value === "object" && value !== null) {
14532
+ return JSON.stringify(value);
14533
+ }
14534
+ return String(value);
14535
+ };
14536
+ const formatExpression = (e) => {
14537
+ switch (e.type) {
14538
+ case "variable":
14539
+ return e.variable;
14540
+ case "property":
14541
+ return `${e.variable}.${e.property}`;
14542
+ case "literal":
14543
+ return formatLiteralValue(e.value);
14544
+ case "parameter":
14545
+ return `$${e.name}`;
14546
+ case "function": {
14547
+ const name = canonicalFunctionName(e.functionName);
14548
+ const args = (e.args ?? []).map(formatExpression);
14549
+ const distinctPrefix = e.distinct ? "DISTINCT " : "";
14550
+ if (e.star)
14551
+ return `${name}(*)`;
14552
+ return `${name}(${distinctPrefix}${args.join(", ")})`;
14553
+ }
14554
+ case "binary":
14555
+ return `${formatExpression(e.left)} ${e.operator} ${formatExpression(e.right)}`;
14556
+ case "unary":
14557
+ return `${e.operator} ${formatExpression(e.operand)}`;
14558
+ case "comparison": {
14559
+ const leftName = formatExpression(e.left);
14560
+ const op = e.comparisonOperator;
14561
+ if (op === "IS NULL" || op === "IS NOT NULL")
14562
+ return `${leftName} ${op}`;
14563
+ return `${leftName} ${op} ${formatExpression(e.right)}`;
14564
+ }
14565
+ case "in":
14566
+ return `${formatExpression(e.left)} IN ${formatExpression(e.list)}`;
14567
+ case "listComprehension":
14568
+ // Nested comprehension in naming
14569
+ return this.getExpressionName(e);
14570
+ default:
14571
+ return this.getExpressionName(e);
14572
+ }
14573
+ };
14574
+ const formatCondition = (c) => {
14575
+ switch (c.type) {
14576
+ case "comparison":
14577
+ if (c.left && c.right && c.operator) {
14578
+ return `${formatExpression(c.left)} ${c.operator} ${formatExpression(c.right)}`;
14579
+ }
14580
+ return "expr";
14581
+ case "and":
14582
+ return (c.conditions ?? []).map(formatCondition).join(" AND ");
14583
+ case "or":
14584
+ return (c.conditions ?? []).map(formatCondition).join(" OR ");
14585
+ case "not":
14586
+ return c.condition ? `NOT ${formatCondition(c.condition)}` : "expr";
14587
+ case "isNull":
14588
+ return c.left ? `${formatExpression(c.left)} IS NULL` : "expr";
14589
+ case "isNotNull":
14590
+ return c.left ? `${formatExpression(c.left)} IS NOT NULL` : "expr";
14591
+ case "in":
14592
+ return c.left && c.list ? `${formatExpression(c.left)} IN ${formatExpression(c.list)}` : "expr";
14593
+ default:
14594
+ return "expr";
14595
+ }
14596
+ };
14597
+ const listExprName = formatExpression(expr.listExpr);
14598
+ const wherePart = expr.filterCondition ? ` WHERE ${formatCondition(expr.filterCondition)}` : "";
14599
+ const mapPart = expr.mapExpr ? ` | ${formatExpression(expr.mapExpr)}` : "";
14600
+ return `[${expr.variable} IN ${listExprName}${wherePart}${mapPart}]`;
14601
+ }
13237
14602
  default:
13238
14603
  return "expr";
13239
14604
  }
@@ -13250,7 +14615,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
13250
14615
  continue;
13251
14616
  }
13252
14617
  // Get the column name (alias if provided, otherwise derived from expression)
13253
- const columnName = item.alias || this.getExpressionName(item.expression);
14618
+ const columnName = this.getReturnItemName(item);
13254
14619
  if (seenNames.has(columnName)) {
13255
14620
  throw new Error(`SyntaxError: ColumnNameConflict - Multiple result columns with the same name '${columnName}'`);
13256
14621
  }
@@ -13278,7 +14643,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
13278
14643
  const returnedExpressions = [];
13279
14644
  for (const item of clause.items) {
13280
14645
  // Get the column name
13281
- const columnName = item.alias || this.getExpressionName(item.expression);
14646
+ const columnName = this.getReturnItemName(item);
13282
14647
  availableColumns.add(columnName);
13283
14648
  returnedExpressions.push(item.expression);
13284
14649
  // If the expression is a whole variable (not a property), it's available for property access
@@ -13322,7 +14687,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
13322
14687
  const availableColumns = new Set();
13323
14688
  const returnedExpressions = [];
13324
14689
  for (const item of clause.items) {
13325
- const columnName = item.alias || this.getExpressionName(item.expression);
14690
+ const columnName = this.getReturnItemName(item);
13326
14691
  availableColumns.add(columnName);
13327
14692
  returnedExpressions.push(item.expression);
13328
14693
  }