leangraph 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +198 -111
  2. package/dist/auth.d.ts +1 -4
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +5 -15
  5. package/dist/auth.js.map +1 -1
  6. package/dist/backup.d.ts +1 -3
  7. package/dist/backup.d.ts.map +1 -1
  8. package/dist/backup.js +10 -15
  9. package/dist/backup.js.map +1 -1
  10. package/dist/cli-helpers.d.ts +2 -3
  11. package/dist/cli-helpers.d.ts.map +1 -1
  12. package/dist/cli-helpers.js +11 -30
  13. package/dist/cli-helpers.js.map +1 -1
  14. package/dist/cli.js +82 -129
  15. package/dist/cli.js.map +1 -1
  16. package/dist/db.d.ts +9 -2
  17. package/dist/db.d.ts.map +1 -1
  18. package/dist/db.js +82 -9
  19. package/dist/db.js.map +1 -1
  20. package/dist/executor.d.ts +114 -0
  21. package/dist/executor.d.ts.map +1 -1
  22. package/dist/executor.js +1248 -341
  23. package/dist/executor.js.map +1 -1
  24. package/dist/index.d.ts +8 -34
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +12 -38
  27. package/dist/index.js.map +1 -1
  28. package/dist/local.d.ts +3 -3
  29. package/dist/local.d.ts.map +1 -1
  30. package/dist/local.js +13 -15
  31. package/dist/local.js.map +1 -1
  32. package/dist/parser.d.ts +15 -3
  33. package/dist/parser.d.ts.map +1 -1
  34. package/dist/parser.js +231 -42
  35. package/dist/parser.js.map +1 -1
  36. package/dist/remote.d.ts +3 -3
  37. package/dist/remote.d.ts.map +1 -1
  38. package/dist/remote.js +8 -10
  39. package/dist/remote.js.map +1 -1
  40. package/dist/routes.d.ts +0 -1
  41. package/dist/routes.d.ts.map +1 -1
  42. package/dist/routes.js +15 -39
  43. package/dist/routes.js.map +1 -1
  44. package/dist/server.js +1 -1
  45. package/dist/server.js.map +1 -1
  46. package/dist/translator.d.ts +36 -0
  47. package/dist/translator.d.ts.map +1 -1
  48. package/dist/translator.js +634 -136
  49. package/dist/translator.js.map +1 -1
  50. package/dist/types.d.ts +24 -26
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js +2 -2
  53. package/dist/types.js.map +1 -1
  54. package/package.json +8 -2
@@ -1278,7 +1278,9 @@ export class Translator {
1278
1278
  const hasVariableLengthPattern = relPatterns?.some(p => p.isVariableLength);
1279
1279
  if (hasVariableLengthPattern && relPatterns) {
1280
1280
  // Use recursive CTE for variable-length paths
1281
- return this.translateVariableLengthPath(clause, relPatterns, selectParts, returnColumns, exprParams, whereParams);
1281
+ // Pass the limit value for early termination optimization
1282
+ const limitValue = clause.limit !== undefined && typeof clause.limit === 'number' ? clause.limit : undefined;
1283
+ return this.translateVariableLengthPath(clause, relPatterns, selectParts, returnColumns, exprParams, whereParams, limitValue);
1282
1284
  }
1283
1285
  if (relPatterns && relPatterns.length > 0) {
1284
1286
  // Track which node aliases we've already added to FROM/JOIN
@@ -2952,7 +2954,7 @@ export class Translator {
2952
2954
  // ============================================================================
2953
2955
  // Variable-length paths
2954
2956
  // ============================================================================
2955
- translateVariableLengthPath(clause, relPatterns, selectParts, returnColumns, exprParams, whereParams) {
2957
+ translateVariableLengthPath(clause, relPatterns, selectParts, returnColumns, exprParams, whereParams, limitValue) {
2956
2958
  // For variable-length paths, we use SQLite's recursive CTEs
2957
2959
  // Pattern: WITH RECURSIVE path(start_id, end_id, depth) AS (
2958
2960
  // SELECT source_id, target_id, 1 FROM edges WHERE ...
@@ -2980,6 +2982,10 @@ export class Translator {
2980
2982
  const edgeProperties = varLengthPattern.edge.properties;
2981
2983
  const varLengthSourceAlias = varLengthPattern.sourceAlias;
2982
2984
  const varLengthTargetAlias = varLengthPattern.targetAlias;
2985
+ // Early termination optimization: if there's a LIMIT clause, use it to bound recursion
2986
+ // This prevents the CTE from generating millions of paths only to return a few
2987
+ const effectiveLimit = limitValue ?? 1000; // Default limit for safety
2988
+ const earlyTerminationLimit = Math.min(effectiveLimit * 10, 10000); // Cap at 10k for safety
2983
2989
  const allParams = [...exprParams];
2984
2990
  // Build edge property conditions for variable-length paths
2985
2991
  // These conditions need to be applied to every edge in the path
@@ -2996,6 +3002,39 @@ export class Translator {
2996
3002
  // Build the condition string for recursive case (with 'e.' table alias)
2997
3003
  const recursivePropConditions = edgePropConditions.map(c => c.replace("properties", "e.properties"));
2998
3004
  const recursivePropCondition = recursivePropConditions.length > 0 ? " AND " + recursivePropConditions.join(" AND ") : "";
3005
+ // Build source node filter for CTE optimization
3006
+ // This pushes the source filter INTO the CTE base case instead of filtering after
3007
+ // which dramatically improves performance for large graphs
3008
+ const sourcePattern = this.ctx[`pattern_${varLengthSourceAlias}`];
3009
+ const sourceFilterParts = [];
3010
+ const sourceFilterParams = [];
3011
+ if (sourcePattern?.label) {
3012
+ const labelMatch = this.generateLabelMatchCondition("src_n", sourcePattern.label);
3013
+ sourceFilterParts.push(labelMatch.sql);
3014
+ sourceFilterParams.push(...labelMatch.params);
3015
+ }
3016
+ if (sourcePattern?.properties) {
3017
+ for (const [key, value] of Object.entries(sourcePattern.properties)) {
3018
+ if (this.isParameterRef(value)) {
3019
+ sourceFilterParts.push(`json_extract(src_n.properties, '$.${key}') = ?`);
3020
+ sourceFilterParams.push(this.ctx.paramValues[value.name]);
3021
+ }
3022
+ else {
3023
+ sourceFilterParts.push(`json_extract(src_n.properties, '$.${key}') = ?`);
3024
+ sourceFilterParams.push(value);
3025
+ }
3026
+ }
3027
+ }
3028
+ // Build the source filter subquery if there are any constraints
3029
+ // This filters source_id IN (SELECT id FROM nodes WHERE <constraints>)
3030
+ const hasSourceFilter = sourceFilterParts.length > 0;
3031
+ const sourceFilterSubquery = hasSourceFilter
3032
+ ? ` AND source_id IN (SELECT src_n.id FROM nodes src_n WHERE ${sourceFilterParts.join(" AND ")})`
3033
+ : "";
3034
+ // For undirected, we also need to filter target_id for reverse direction
3035
+ const sourceFilterSubqueryReverse = hasSourceFilter
3036
+ ? ` AND target_id IN (SELECT src_n.id FROM nodes src_n WHERE ${sourceFilterParts.join(" AND ")})`
3037
+ : "";
2999
3038
  // Check if a path expression already allocated a CTE name for this variable-length pattern
3000
3039
  // This allows length(p) to reference the correct CTE
3001
3040
  let pathCteName;
@@ -3047,44 +3086,52 @@ export class Translator {
3047
3086
  }
3048
3087
  else if (minHops === 0) {
3049
3088
  // Need to include zero-length paths (source = target) plus longer paths
3089
+ // Build source filter for the base case (filters which nodes we start from)
3090
+ const minHops0SourceFilter = hasSourceFilter
3091
+ ? ` WHERE ${sourceFilterParts.join(" AND ").replace(/src_n\./g, "")}`
3092
+ : "";
3050
3093
  if (isUndirected) {
3051
3094
  // For undirected with minHops=0, traverse edges in both directions with edge tracking
3052
3095
  if (edgeType) {
3053
- cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids) AS (
3054
- SELECT id, id, 0, json_array() FROM nodes
3096
+ cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids, row_num) AS (
3097
+ SELECT id, id, 0, json_array(), ROW_NUMBER() OVER () FROM nodes${minHops0SourceFilter}
3055
3098
  UNION ALL
3056
- SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3099
+ SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3057
3100
  FROM ${pathCteName} p
3058
3101
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3059
- WHERE p.depth < ? AND e.type = ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id)
3102
+ WHERE p.depth < ? AND e.type = ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id) AND p.row_num < ?
3060
3103
  )`;
3061
- allParams.push(maxHops, edgeType);
3104
+ allParams.push(...sourceFilterParams); // for base case
3105
+ allParams.push(maxHops, edgeType, earlyTerminationLimit);
3062
3106
  }
3063
3107
  else {
3064
- cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids) AS (
3065
- SELECT id, id, 0, json_array() FROM nodes
3108
+ cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids, row_num) AS (
3109
+ SELECT id, id, 0, json_array(), ROW_NUMBER() OVER () FROM nodes${minHops0SourceFilter}
3066
3110
  UNION ALL
3067
- SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3111
+ SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3068
3112
  FROM ${pathCteName} p
3069
3113
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3070
- WHERE p.depth < ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id)
3114
+ WHERE p.depth < ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id) AND p.row_num < ?
3071
3115
  )`;
3072
- allParams.push(maxHops);
3116
+ allParams.push(...sourceFilterParams); // for base case
3117
+ allParams.push(maxHops, earlyTerminationLimit);
3073
3118
  }
3074
3119
  }
3075
3120
  else {
3076
- cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids) AS (
3077
- SELECT id, id, 0, json_array() FROM nodes
3121
+ cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids, row_num) AS (
3122
+ SELECT id, id, 0, json_array(), ROW_NUMBER() OVER () FROM nodes${minHops0SourceFilter}
3078
3123
  UNION ALL
3079
- SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3124
+ SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3080
3125
  FROM ${pathCteName} p
3081
3126
  JOIN edges e ON p.end_id = e.source_id
3082
- WHERE p.depth < ?${edgeType ? " AND e.type = ?" : ""}
3127
+ WHERE p.depth < ?${edgeType ? " AND e.type = ?" : ""} AND p.row_num < ?
3083
3128
  )`;
3129
+ allParams.push(...sourceFilterParams); // for base case
3084
3130
  allParams.push(maxHops);
3085
3131
  if (edgeType) {
3086
3132
  allParams.push(edgeType);
3087
3133
  }
3134
+ allParams.push(earlyTerminationLimit);
3088
3135
  }
3089
3136
  }
3090
3137
  else {
@@ -3100,49 +3147,55 @@ export class Translator {
3100
3147
  // The base case includes both directions, and recursive step does too
3101
3148
  // We need to avoid revisiting the same edge (tracked in edge_ids)
3102
3149
  if (edgeType) {
3103
- cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids) AS (
3104
- SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges WHERE ${edgeCondition}
3150
+ cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids, row_num) AS (
3151
+ SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges WHERE ${edgeCondition}${sourceFilterSubquery}
3105
3152
  UNION ALL
3106
- SELECT target_id, source_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges WHERE type = ?
3153
+ SELECT target_id, source_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges WHERE type = ?${sourceFilterSubqueryReverse}
3107
3154
  UNION ALL
3108
- SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3155
+ SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3109
3156
  FROM ${pathCteName} p
3110
3157
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3111
- WHERE p.depth < ? AND e.type = ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id)
3158
+ WHERE p.depth < ? AND e.type = ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id) AND p.row_num < ?
3112
3159
  )`;
3160
+ allParams.push(...sourceFilterParams); // for forward base case
3113
3161
  allParams.push(edgeType); // for reverse base case
3114
- allParams.push(maxHops, edgeType); // for recursive
3162
+ allParams.push(...sourceFilterParams); // for reverse base case
3163
+ allParams.push(maxHops, edgeType, earlyTerminationLimit); // for recursive
3115
3164
  }
3116
3165
  else {
3117
- cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids) AS (
3118
- SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges
3166
+ cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids, row_num) AS (
3167
+ SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges WHERE 1=1${sourceFilterSubquery}
3119
3168
  UNION ALL
3120
- SELECT target_id, source_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges
3169
+ SELECT target_id, source_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges WHERE 1=1${sourceFilterSubqueryReverse}
3121
3170
  UNION ALL
3122
- SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3171
+ SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3123
3172
  FROM ${pathCteName} p
3124
3173
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3125
- WHERE p.depth < ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id)
3174
+ WHERE p.depth < ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id) AND p.row_num < ?
3126
3175
  )`;
3127
- allParams.push(maxHops); // for recursive
3176
+ allParams.push(...sourceFilterParams); // for forward base case
3177
+ allParams.push(...sourceFilterParams); // for reverse base case
3178
+ allParams.push(maxHops, earlyTerminationLimit); // for recursive
3128
3179
  }
3129
3180
  }
3130
3181
  else {
3131
- cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids) AS (
3132
- SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges WHERE ${edgeCondition}${basePropCondition}
3182
+ cte = `WITH RECURSIVE ${pathCteName}(start_id, end_id, depth, edge_ids, row_num) AS (
3183
+ SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges WHERE ${edgeCondition}${basePropCondition}${sourceFilterSubquery}
3133
3184
  UNION ALL
3134
- SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3185
+ SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3135
3186
  FROM ${pathCteName} p
3136
3187
  JOIN edges e ON p.end_id = e.source_id
3137
- WHERE p.depth < ?${edgeType ? " AND e.type = ?" : ""}${recursivePropCondition} AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id)
3188
+ WHERE p.depth < ?${edgeType ? " AND e.type = ?" : ""}${recursivePropCondition} AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id) AND p.row_num < ?
3138
3189
  )`;
3139
3190
  // For maxHops=2, we need depth to reach 2, so recursion limit should be maxHops
3140
3191
  allParams.push(...edgePropParams); // for base case
3192
+ allParams.push(...sourceFilterParams); // for source filter subquery
3141
3193
  allParams.push(maxHops);
3142
3194
  if (edgeType) {
3143
3195
  allParams.push(edgeType);
3144
3196
  }
3145
3197
  allParams.push(...edgePropParams); // for recursive case
3198
+ allParams.push(earlyTerminationLimit);
3146
3199
  }
3147
3200
  }
3148
3201
  // Build FROM and JOIN clauses for fixed-length patterns before the variable-length
@@ -3488,51 +3541,51 @@ export class Translator {
3488
3541
  if (isUndirected2) {
3489
3542
  // Undirected: traverse in both directions
3490
3543
  if (edgeType2) {
3491
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3492
- SELECT id, id, 0, json_array() FROM nodes
3544
+ cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids, row_num) AS (
3545
+ SELECT id, id, 0, json_array(), ROW_NUMBER() OVER () FROM nodes
3493
3546
  UNION ALL
3494
- SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3547
+ SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3495
3548
  FROM ${pathCteName2} p
3496
3549
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3497
- WHERE p.depth < ? AND e.type = ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id)
3550
+ WHERE p.depth < ? AND e.type = ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id) AND p.row_num < ?
3498
3551
  )`;
3499
- allParams.push(maxHops2, edgeType2);
3552
+ allParams.push(maxHops2, edgeType2, earlyTerminationLimit);
3500
3553
  }
3501
3554
  else {
3502
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3503
- SELECT id, id, 0, json_array() FROM nodes
3555
+ cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids, row_num) AS (
3556
+ SELECT id, id, 0, json_array(), ROW_NUMBER() OVER () FROM nodes
3504
3557
  UNION ALL
3505
- SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3558
+ SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3506
3559
  FROM ${pathCteName2} p
3507
3560
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3508
- WHERE p.depth < ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id)
3561
+ WHERE p.depth < ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id) AND p.row_num < ?
3509
3562
  )`;
3510
- allParams.push(maxHops2);
3563
+ allParams.push(maxHops2, earlyTerminationLimit);
3511
3564
  }
3512
3565
  }
3513
3566
  else {
3514
3567
  // Directed
3515
3568
  if (edgeType2) {
3516
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3517
- SELECT id, id, 0, json_array() FROM nodes
3569
+ cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids, row_num) AS (
3570
+ SELECT id, id, 0, json_array(), ROW_NUMBER() OVER () FROM nodes
3518
3571
  UNION ALL
3519
- SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3572
+ SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3520
3573
  FROM ${pathCteName2} p
3521
3574
  JOIN edges e ON p.end_id = e.source_id
3522
- WHERE p.depth < ? AND e.type = ?
3575
+ WHERE p.depth < ? AND e.type = ? AND p.row_num < ?
3523
3576
  )`;
3524
- allParams.push(maxHops2, edgeType2);
3577
+ allParams.push(maxHops2, edgeType2, earlyTerminationLimit);
3525
3578
  }
3526
3579
  else {
3527
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3528
- SELECT id, id, 0, json_array() FROM nodes
3580
+ cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids, row_num) AS (
3581
+ SELECT id, id, 0, json_array(), ROW_NUMBER() OVER () FROM nodes
3529
3582
  UNION ALL
3530
- SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3583
+ SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3531
3584
  FROM ${pathCteName2} p
3532
3585
  JOIN edges e ON p.end_id = e.source_id
3533
- WHERE p.depth < ?
3586
+ WHERE p.depth < ? AND p.row_num < ?
3534
3587
  )`;
3535
- allParams.push(maxHops2);
3588
+ allParams.push(maxHops2, earlyTerminationLimit);
3536
3589
  }
3537
3590
  }
3538
3591
  }
@@ -3540,55 +3593,55 @@ export class Translator {
3540
3593
  if (isUndirected2) {
3541
3594
  // Undirected: traverse in both directions
3542
3595
  if (edgeType2) {
3543
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3544
- SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges WHERE type = ?
3596
+ cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids, row_num) AS (
3597
+ SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges WHERE type = ?
3545
3598
  UNION ALL
3546
- SELECT target_id, source_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges WHERE type = ?
3599
+ SELECT target_id, source_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges WHERE type = ?
3547
3600
  UNION ALL
3548
- SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3601
+ SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3549
3602
  FROM ${pathCteName2} p
3550
3603
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3551
- WHERE p.depth < ? AND e.type = ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id)
3604
+ WHERE p.depth < ? AND e.type = ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id) AND p.row_num < ?
3552
3605
  )`;
3553
- allParams.push(edgeType2, edgeType2, maxHops2, edgeType2);
3606
+ allParams.push(edgeType2, edgeType2, maxHops2, edgeType2, earlyTerminationLimit);
3554
3607
  }
3555
3608
  else {
3556
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3557
- SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges
3609
+ cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids, row_num) AS (
3610
+ SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges
3558
3611
  UNION ALL
3559
- SELECT target_id, source_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges
3612
+ SELECT target_id, source_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges
3560
3613
  UNION ALL
3561
- SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3614
+ SELECT p.start_id, CASE WHEN p.end_id = e.source_id THEN e.target_id ELSE e.source_id END, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3562
3615
  FROM ${pathCteName2} p
3563
3616
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3564
- WHERE p.depth < ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id)
3617
+ WHERE p.depth < ? AND NOT EXISTS (SELECT 1 FROM json_each(p.edge_ids) WHERE json_extract(value, '$.id') = e.id) AND p.row_num < ?
3565
3618
  )`;
3566
- allParams.push(maxHops2);
3619
+ allParams.push(maxHops2, earlyTerminationLimit);
3567
3620
  }
3568
3621
  }
3569
3622
  else {
3570
3623
  // Directed
3571
3624
  if (edgeType2) {
3572
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3573
- SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges WHERE type = ?
3625
+ cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids, row_num) AS (
3626
+ SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges WHERE type = ?
3574
3627
  UNION ALL
3575
- SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3628
+ SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3576
3629
  FROM ${pathCteName2} p
3577
3630
  JOIN edges e ON p.end_id = e.source_id
3578
- WHERE p.depth < ? AND e.type = ?
3631
+ WHERE p.depth < ? AND e.type = ? AND p.row_num < ?
3579
3632
  )`;
3580
- allParams.push(edgeType2, maxHops2, edgeType2);
3633
+ allParams.push(edgeType2, maxHops2, edgeType2, earlyTerminationLimit);
3581
3634
  }
3582
3635
  else {
3583
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3584
- SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))) FROM edges
3636
+ cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids, row_num) AS (
3637
+ SELECT source_id, target_id, 1, json_array(json_object('id', id, 'type', type, 'source_id', source_id, 'target_id', target_id, 'properties', json(properties))), ROW_NUMBER() OVER () FROM edges
3585
3638
  UNION ALL
3586
- SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties)))
3639
+ SELECT p.start_id, e.target_id, p.depth + 1, json_insert(p.edge_ids, '$[#]', json_object('id', e.id, 'type', e.type, 'source_id', e.source_id, 'target_id', e.target_id, 'properties', json(e.properties))), p.row_num + 1
3587
3640
  FROM ${pathCteName2} p
3588
3641
  JOIN edges e ON p.end_id = e.source_id
3589
- WHERE p.depth < ?
3642
+ WHERE p.depth < ? AND p.row_num < ?
3590
3643
  )`;
3591
- allParams.push(maxHops2);
3644
+ allParams.push(maxHops2, earlyTerminationLimit);
3592
3645
  }
3593
3646
  }
3594
3647
  }
@@ -4026,7 +4079,7 @@ export class Translator {
4026
4079
  return { sql: "?", params: [expr.value] };
4027
4080
  }
4028
4081
  if (typeof expr.value === "boolean") {
4029
- return { sql: expr.value ? "1" : "0", params };
4082
+ return { sql: expr.value ? "json('true')" : "json('false')", params };
4030
4083
  }
4031
4084
  if (expr.value === null) {
4032
4085
  return { sql: "NULL", params };
@@ -5356,6 +5409,16 @@ END FROM (SELECT json_group_array(${valueExpr}) as sv))`,
5356
5409
  }
5357
5410
  throw new Error("sqrt requires an argument");
5358
5411
  }
5412
+ // SIGN: returns -1, 0, or 1 based on the sign of the number
5413
+ if (expr.functionName === "SIGN") {
5414
+ if (expr.args && expr.args.length > 0) {
5415
+ const argResult = this.translateFunctionArg(expr.args[0]);
5416
+ tables.push(...argResult.tables);
5417
+ params.push(...argResult.params);
5418
+ return { sql: `SIGN(${argResult.sql})`, tables, params };
5419
+ }
5420
+ throw new Error("sign requires an argument");
5421
+ }
5359
5422
  // RAND: random float between 0 and 1
5360
5423
  if (expr.functionName === "RAND") {
5361
5424
  // SQLite's RANDOM() returns integer between -9223372036854775808 and 9223372036854775807
@@ -7805,7 +7868,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
7805
7868
  // Strip JSON quotes if input is wrapped in "" (from toString() output)
7806
7869
  const argResult = this.translateExpression(arg);
7807
7870
  tables.push(...argResult.tables);
7808
- params.push(...argResult.params, ...argResult.params);
7871
+ params.push(...argResult.params);
7809
7872
  return {
7810
7873
  sql: `(SELECT CASE WHEN _d IS NULL THEN NULL ELSE CASE
7811
7874
  WHEN substr(_d, 1, 1) = '"' AND substr(_d, length(_d), 1) = '"'
@@ -8134,16 +8197,25 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8134
8197
  const listResult = this.translateExpression(listArg);
8135
8198
  const indexResult = this.translateExpression(indexArg);
8136
8199
  tables.push(...listResult.tables, ...indexResult.tables);
8137
- params.push(...listResult.params, ...indexResult.params);
8138
8200
  // For map access with string key, use json_extract with the key
8139
8201
  // For list access with integer index, use json_extract with array index
8140
8202
  if (isContainerMap || isStringExpression(resolvedIndexArg)) {
8141
8203
  // Map access: use key directly
8204
+ params.push(...listResult.params, ...indexResult.params);
8142
8205
  return { sql: `json_extract(${listResult.sql}, '$.' || ${indexResult.sql})`, tables, params };
8143
8206
  }
8144
8207
  // Use -> operator with array index to preserve JSON types (booleans, etc.)
8145
8208
  // Cast index to integer to avoid "0.0" in JSON path
8146
- return { sql: `(${listResult.sql}) -> ('$[' || CAST(${indexResult.sql} AS INTEGER) || ']')`, tables, params };
8209
+ // Handle negative indices by converting to positive using json_array_length
8210
+ // Cypher: list[-1] gets last element, list[-2] gets second to last, etc.
8211
+ const idxCast = `CAST(${indexResult.sql} AS INTEGER)`;
8212
+ // SQL expression uses: list (for ->), idx (for CASE condition), list (for json_array_length), idx (for + in THEN), idx (for ELSE)
8213
+ params.push(...listResult.params, ...indexResult.params, ...listResult.params, ...indexResult.params, ...indexResult.params);
8214
+ return {
8215
+ sql: `(${listResult.sql}) -> ('$[' || (CASE WHEN ${idxCast} < 0 THEN json_array_length(${listResult.sql}) + ${idxCast} ELSE ${idxCast} END) || ']')`,
8216
+ tables,
8217
+ params
8218
+ };
8147
8219
  }
8148
8220
  throw new Error("INDEX requires list and index arguments");
8149
8221
  }
@@ -8282,8 +8354,14 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8282
8354
  if (Array.isArray(expr.value)) {
8283
8355
  return this.translateArrayLiteral(expr.value);
8284
8356
  }
8285
- // Convert booleans to 1/0 for SQLite
8286
- const value = expr.value === true ? 1 : expr.value === false ? 0 : expr.value;
8357
+ // Return JSON booleans to preserve boolean type in results
8358
+ if (expr.value === true) {
8359
+ return { sql: "json('true')", tables, params };
8360
+ }
8361
+ if (expr.value === false) {
8362
+ return { sql: "json('false')", tables, params };
8363
+ }
8364
+ const value = expr.value;
8287
8365
  // Preserve float-literal formatting (e.g., 0.0, -0.0, 1.0) so SQLite treats them as REAL.
8288
8366
  if (typeof value === "number" && expr.numberLiteralKind === "float" && expr.raw) {
8289
8367
  return { sql: expr.raw, tables, params };
@@ -8324,8 +8402,29 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8324
8402
  case "patternComprehension": {
8325
8403
  return this.translatePatternComprehension(expr);
8326
8404
  }
8405
+ case "existsPattern": {
8406
+ return this.translateExistsPattern(expr);
8407
+ }
8327
8408
  case "listPredicate": {
8328
- return this.translateListPredicate(expr);
8409
+ // Wrap list predicate result with cypher_to_json_bool for proper boolean output in RETURN
8410
+ // translateListPredicate returns 0/1 for SQLite WHERE compatibility
8411
+ const result = this.translateListPredicate(expr);
8412
+ return {
8413
+ sql: `cypher_to_json_bool(${result.sql})`,
8414
+ tables: result.tables,
8415
+ params: result.params,
8416
+ };
8417
+ }
8418
+ case "reduce": {
8419
+ return this.translateReduceExpression(expr);
8420
+ }
8421
+ case "filter": {
8422
+ // filter(x IN list WHERE cond) - same as list comprehension without map expression
8423
+ return this.translateFilterExpression(expr);
8424
+ }
8425
+ case "extract": {
8426
+ // extract(x IN list | expr) - same as list comprehension without filter
8427
+ return this.translateExtractExpression(expr);
8329
8428
  }
8330
8429
  case "unary": {
8331
8430
  return this.translateUnaryExpression(expr);
@@ -8419,7 +8518,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8419
8518
  if (listExpr.type === "literal" && Array.isArray(listExpr.value)) {
8420
8519
  const values = listExpr.value;
8421
8520
  if (values.length === 0) {
8422
- return { sql: "0", tables, params }; // false for empty list
8521
+ return { sql: "json('false')", tables, params }; // false for empty list
8423
8522
  }
8424
8523
  // Check if RHS contains complex types (nested arrays/objects)
8425
8524
  const rhsHasComplexTypes = values.some(containsComplexTypes);
@@ -8450,7 +8549,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8450
8549
  // If LHS contains null, finding a JSON match means comparing null=null → return NULL
8451
8550
  params.push(rhsJson, lhsJson);
8452
8551
  return {
8453
- sql: `CASE WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(?)) THEN NULL ELSE 0 END`,
8552
+ sql: `CASE WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(?)) THEN NULL ELSE json('false') END`,
8454
8553
  tables,
8455
8554
  params,
8456
8555
  };
@@ -8459,7 +8558,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8459
8558
  // If RHS has top-level null and no match, return NULL
8460
8559
  params.push(rhsJson, lhsJson);
8461
8560
  return {
8462
- sql: `CASE WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(?)) THEN 1 ELSE NULL END`,
8561
+ sql: `CASE WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(?)) THEN json('true') ELSE NULL END`,
8463
8562
  tables,
8464
8563
  params,
8465
8564
  };
@@ -8475,7 +8574,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8475
8574
  params.push(lhsJson, rhsJson);
8476
8575
  return {
8477
8576
  sql: `(SELECT CASE
8478
- WHEN EXISTS(SELECT 1 FROM json_each(rhs_param.v) WHERE json(value) = json(lhs_param.v)) THEN 1
8577
+ WHEN EXISTS(SELECT 1 FROM json_each(rhs_param.v) WHERE json(value) = json(lhs_param.v)) THEN json('true')
8479
8578
  WHEN EXISTS(
8480
8579
  SELECT 1 FROM (
8481
8580
  SELECT rhs.rowid,
@@ -8489,7 +8588,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8489
8588
  GROUP BY rhs.rowid
8490
8589
  ) WHERE mismatches = 0 AND nulls > 0
8491
8590
  ) THEN NULL
8492
- ELSE 0
8591
+ ELSE json('false')
8493
8592
  END FROM (SELECT ? AS v) AS lhs_param, (SELECT ? AS v) AS rhs_param)`,
8494
8593
  tables,
8495
8594
  params,
@@ -8498,7 +8597,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8498
8597
  // No null semantics needed - simple JSON comparison
8499
8598
  params.push(rhsJson, lhsJson);
8500
8599
  return {
8501
- sql: `EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(?))`,
8600
+ sql: `cypher_to_json_bool(EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(?)))`,
8502
8601
  tables,
8503
8602
  params,
8504
8603
  };
@@ -8511,47 +8610,63 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8511
8610
  // So rhsJson comes first (for json_each), then leftResult.params (for leftResult.sql)
8512
8611
  params.push(rhsJson);
8513
8612
  params.push(...leftResult.params);
8514
- // When LHS is a scalar expression (like comparison result), don't use json() wrapper
8515
- // because SQLite UDF returns real type and json(0.0) != json(0)
8516
- // Use direct value comparison which handles int/real equality correctly
8613
+ // When LHS is a scalar expression (like comparison result), use cypher_bool_eq
8614
+ // to handle type mismatches between JSON boolean strings ('true'/'false') and integers (1/0)
8517
8615
  const useDirectComparison = !leftIsComplex;
8518
8616
  if (rhsHasTopLevelNull) {
8519
8617
  if (useDirectComparison) {
8618
+ // Check for exact match first, then check for null comparison
8520
8619
  return {
8521
- sql: `CASE WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE value = ${leftResult.sql}) THEN 1 ELSE NULL END`,
8620
+ sql: `CASE WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE cypher_bool_eq(value, ${leftResult.sql}) = 1) THEN json('true') WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE cypher_bool_eq(value, ${leftResult.sql}) IS NULL) THEN NULL ELSE json('false') END`,
8522
8621
  tables,
8523
- params,
8622
+ params: [...params, ...params], // Duplicate for two json_each usages
8524
8623
  };
8525
8624
  }
8526
8625
  return {
8527
- sql: `CASE WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(${leftResult.sql})) THEN 1 ELSE NULL END`,
8626
+ sql: `CASE WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(${leftResult.sql})) THEN json('true') ELSE NULL END`,
8528
8627
  tables,
8529
8628
  params,
8530
8629
  };
8531
8630
  }
8532
8631
  if (useDirectComparison) {
8533
8632
  return {
8534
- sql: `EXISTS(SELECT 1 FROM json_each(?) WHERE value = ${leftResult.sql})`,
8633
+ sql: `cypher_to_json_bool(EXISTS(SELECT 1 FROM json_each(?) WHERE cypher_bool_eq(value, ${leftResult.sql})))`,
8535
8634
  tables,
8536
8635
  params,
8537
8636
  };
8538
8637
  }
8539
8638
  return {
8540
- sql: `EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(${leftResult.sql}))`,
8639
+ sql: `cypher_to_json_bool(EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(${leftResult.sql})))`,
8541
8640
  tables,
8542
8641
  params,
8543
8642
  };
8544
8643
  }
8545
- // Simple scalar values - translate LHS and use SQL IN clause
8644
+ // Simple scalar values - use json_each with cypher_bool_eq for type-safe comparison
8645
+ // This handles cases where LHS is json('true') (string) and RHS values are integers (1, 0)
8546
8646
  const leftResult = this.translateExpression(leftExpr);
8547
8647
  tables.push(...leftResult.tables);
8548
- params.push(...leftResult.params);
8549
- const placeholders = values.map(() => "?").join(", ");
8550
- params.push(...toSqliteParams(values));
8648
+ const rhsJson = JSON.stringify(values);
8551
8649
  // Wrap left side in extra parentheses to ensure correct precedence (e.g., NOT has lower precedence than IN in SQL)
8552
8650
  const leftSql = leftExpr.type === "unary" ? `(${leftResult.sql})` : leftResult.sql;
8651
+ // Cypher null semantics: if RHS has top-level null and no exact match is found, return null
8652
+ // e.g., null IN [null] returns null (unknown), not false
8653
+ // e.g., 1 IN [2, null] returns null (unknown) because null could be 1
8654
+ if (hasTopLevelNull(values)) {
8655
+ // IMPORTANT: params order must match SQL placeholder order
8656
+ // SQL: json_each(?) ... ${leftSql} ... json_each(?) ... cypher_bool_eq(${leftSql}, value)
8657
+ params.push(rhsJson, ...leftResult.params, rhsJson, ...leftResult.params);
8658
+ return {
8659
+ sql: `CASE WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE value = ${leftSql}) THEN json('true') WHEN EXISTS(SELECT 1 FROM json_each(?) WHERE cypher_bool_eq(${leftSql}, value) IS NULL) THEN NULL ELSE json('false') END`,
8660
+ tables,
8661
+ params,
8662
+ };
8663
+ }
8664
+ // IMPORTANT: params order must match SQL placeholder order
8665
+ // SQL: json_each(?) ... cypher_bool_eq(${leftSql}, value)
8666
+ // So rhsJson comes first (for json_each), then leftResult.params (for ${leftSql})
8667
+ params.push(rhsJson, ...leftResult.params);
8553
8668
  return {
8554
- sql: `(${leftSql} IN (${placeholders}))`,
8669
+ sql: `cypher_to_json_bool(EXISTS(SELECT 1 FROM json_each(?) WHERE cypher_bool_eq(${leftSql}, value)))`,
8555
8670
  tables,
8556
8671
  params,
8557
8672
  };
@@ -8560,7 +8675,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8560
8675
  const paramValue = this.ctx.paramValues[listExpr.name];
8561
8676
  if (Array.isArray(paramValue)) {
8562
8677
  if (paramValue.length === 0) {
8563
- return { sql: "0", tables, params }; // false for empty list
8678
+ return { sql: "json('false')", tables, params }; // false for empty list
8564
8679
  }
8565
8680
  // Check if RHS contains complex types
8566
8681
  const rhsHasComplexTypes = paramValue.some(containsComplexTypes);
@@ -8573,7 +8688,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8573
8688
  const lhsJson = JSON.stringify(leftExpr.value);
8574
8689
  params.push(rhsJson, lhsJson);
8575
8690
  return {
8576
- sql: `EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(?))`,
8691
+ sql: `cypher_to_json_bool(EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(?)))`,
8577
8692
  tables,
8578
8693
  params,
8579
8694
  };
@@ -8583,7 +8698,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8583
8698
  params.push(...leftResult.params);
8584
8699
  params.push(rhsJson);
8585
8700
  return {
8586
- sql: `EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(${leftResult.sql}))`,
8701
+ sql: `cypher_to_json_bool(EXISTS(SELECT 1 FROM json_each(?) WHERE json(value) = json(${leftResult.sql})))`,
8587
8702
  tables,
8588
8703
  params,
8589
8704
  };
@@ -8597,7 +8712,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8597
8712
  // Wrap left side in extra parentheses to ensure correct precedence (e.g., NOT has lower precedence than IN in SQL)
8598
8713
  const leftSql = leftExpr.type === "unary" ? `(${leftResult.sql})` : leftResult.sql;
8599
8714
  return {
8600
- sql: `(${leftSql} IN (${placeholders}))`,
8715
+ sql: `cypher_to_json_bool(${leftSql} IN (${placeholders}))`,
8601
8716
  tables,
8602
8717
  params,
8603
8718
  };
@@ -8614,7 +8729,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8614
8729
  params.push(...listResult.params);
8615
8730
  params.push(lhsJson);
8616
8731
  return {
8617
- sql: `EXISTS(SELECT 1 FROM json_each(${listResult.sql}) WHERE json(value) = json(?))`,
8732
+ sql: `cypher_to_json_bool(EXISTS(SELECT 1 FROM json_each(${listResult.sql}) WHERE json(value) = json(?)))`,
8618
8733
  tables,
8619
8734
  params,
8620
8735
  };
@@ -8627,7 +8742,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8627
8742
  params.push(...listResult.params);
8628
8743
  params.push(...leftResult.params);
8629
8744
  return {
8630
- sql: `EXISTS(SELECT 1 FROM json_each(${listResult.sql}) WHERE json(value) = json(${leftResult.sql}))`,
8745
+ sql: `cypher_to_json_bool(EXISTS(SELECT 1 FROM json_each(${listResult.sql}) WHERE json(value) = json(${leftResult.sql})))`,
8631
8746
  tables,
8632
8747
  params,
8633
8748
  };
@@ -8641,7 +8756,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8641
8756
  // Wrap left side in extra parentheses to ensure correct precedence (e.g., NOT has lower precedence than IN in SQL)
8642
8757
  const leftSql = leftExpr.type === "unary" ? `(${leftResult.sql})` : leftResult.sql;
8643
8758
  return {
8644
- sql: `(${leftSql} IN (SELECT value FROM json_each(${listResult.sql})))`,
8759
+ sql: `cypher_to_json_bool(${leftSql} IN (SELECT value FROM json_each(${listResult.sql})))`,
8645
8760
  tables,
8646
8761
  params,
8647
8762
  };
@@ -8664,7 +8779,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8664
8779
  if (stringOp === "CONTAINS") {
8665
8780
  // INSTR returns position (1-based) if found, 0 if not found
8666
8781
  return {
8667
- sql: `CASE WHEN ${isString(leftResult.sql)} AND ${isString(rightResult.sql)} THEN INSTR(${leftResult.sql}, ${rightResult.sql}) > 0 ELSE NULL END`,
8782
+ sql: `CASE WHEN ${isString(leftResult.sql)} AND ${isString(rightResult.sql)} THEN cypher_to_json_bool(INSTR(${leftResult.sql}, ${rightResult.sql}) > 0) ELSE NULL END`,
8668
8783
  tables,
8669
8784
  // leftResult.sql appears 3 times, rightResult.sql appears 3 times
8670
8785
  params: [...leftResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params, ...leftResult.params, ...rightResult.params],
@@ -8673,7 +8788,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8673
8788
  else if (stringOp === "STARTS WITH") {
8674
8789
  // Use SUBSTR for case-sensitive prefix match
8675
8790
  return {
8676
- sql: `CASE WHEN ${isString(leftResult.sql)} AND ${isString(rightResult.sql)} THEN SUBSTR(${leftResult.sql}, 1, LENGTH(${rightResult.sql})) = ${rightResult.sql} ELSE NULL END`,
8791
+ sql: `CASE WHEN ${isString(leftResult.sql)} AND ${isString(rightResult.sql)} THEN cypher_to_json_bool(SUBSTR(${leftResult.sql}, 1, LENGTH(${rightResult.sql})) = ${rightResult.sql}) ELSE NULL END`,
8677
8792
  tables,
8678
8793
  // leftResult.sql appears 3 times, rightResult.sql appears 5 times
8679
8794
  params: [...leftResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params],
@@ -8683,7 +8798,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8683
8798
  // ENDS WITH
8684
8799
  // Use CASE to handle: 1) type check 2) empty suffix edge case, 3) case-sensitive suffix match
8685
8800
  return {
8686
- sql: `CASE WHEN NOT (${isString(leftResult.sql)} AND ${isString(rightResult.sql)}) THEN NULL WHEN LENGTH(${rightResult.sql}) = 0 THEN 1 ELSE SUBSTR(${leftResult.sql}, -LENGTH(${rightResult.sql})) = ${rightResult.sql} END`,
8801
+ sql: `CASE WHEN NOT (${isString(leftResult.sql)} AND ${isString(rightResult.sql)}) THEN NULL WHEN LENGTH(${rightResult.sql}) = 0 THEN json('true') ELSE cypher_to_json_bool(SUBSTR(${leftResult.sql}, -LENGTH(${rightResult.sql})) = ${rightResult.sql}) END`,
8687
8802
  tables,
8688
8803
  // leftResult.sql appears 4 times, rightResult.sql appears 6 times
8689
8804
  params: [...leftResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params, ...rightResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params],
@@ -8917,7 +9032,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8917
9032
  // Use custom cypher_and/cypher_or functions for proper JSON boolean handling
8918
9033
  const func = expr.operator === "AND" ? "cypher_and" : "cypher_or";
8919
9034
  return {
8920
- sql: `${func}(${leftResult.sql}, ${rightResult.sql})`,
9035
+ sql: `cypher_to_json_bool(${func}(${leftResult.sql}, ${rightResult.sql}))`,
8921
9036
  tables,
8922
9037
  params,
8923
9038
  };
@@ -8930,13 +9045,11 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8930
9045
  const leftSql = leftResult.sql;
8931
9046
  const rightSql = rightResult.sql;
8932
9047
  // XOR with NULL semantics: (a XOR b) = (a AND NOT b) OR (NOT a AND b)
8933
- // This naturally handles NULL: if a is NULL, (a AND NOT b) is NULL or FALSE, (NOT a AND b) is NULL or FALSE
8934
- // NULL OR NULL = NULL, NULL OR FALSE = NULL, so result is NULL when either input is NULL
9048
+ // Use cypher_* functions to handle both JSON booleans and integers properly
8935
9049
  // Note: params are duplicated because the formula uses each operand twice:
8936
- // ((left AND NOT right) OR (NOT left AND right))
8937
9050
  const xorParams = [...leftResult.params, ...rightResult.params, ...leftResult.params, ...rightResult.params];
8938
9051
  return {
8939
- sql: `((${leftSql} AND NOT ${rightSql}) OR (NOT ${leftSql} AND ${rightSql}))`,
9052
+ sql: `cypher_to_json_bool(cypher_or(cypher_and(${leftSql}, cypher_not(${rightSql})), cypher_and(cypher_not(${leftSql}), ${rightSql})))`,
8940
9053
  tables,
8941
9054
  params: xorParams,
8942
9055
  };
@@ -9561,8 +9674,13 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9561
9674
  if (Array.isArray(expr.value)) {
9562
9675
  return this.translateArrayLiteral(expr.value);
9563
9676
  }
9564
- const value = expr.value === true ? 1 : expr.value === false ? 0 : expr.value;
9565
- params.push(value);
9677
+ if (expr.value === true) {
9678
+ return { sql: "json('true')", params };
9679
+ }
9680
+ if (expr.value === false) {
9681
+ return { sql: "json('false')", params };
9682
+ }
9683
+ params.push(expr.value);
9566
9684
  return { sql: "?", params };
9567
9685
  case "property": {
9568
9686
  // Use subquery to get property from the created node
@@ -9693,22 +9811,23 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9693
9811
  if (leftCouldBeNaN || rightCouldBeNaN) {
9694
9812
  const op = expr.comparisonOperator;
9695
9813
  // For = and <>, NaN semantics always apply (return false/true respectively)
9814
+ // Note: The expression appears twice in the CASE, so we need to duplicate params
9696
9815
  if (op === "=") {
9697
9816
  // NaN = anything is false (including NaN = NaN)
9698
- // If the comparison returns NULL (because of NaN), return false (0)
9817
+ // If the comparison returns NULL (because of NaN), return false
9699
9818
  return {
9700
- sql: `COALESCE((${leftSql} ${op} ${rightSql}), 0)`,
9819
+ sql: `CASE WHEN (${leftSql} ${op} ${rightSql}) IS NULL THEN json('false') WHEN (${leftSql} ${op} ${rightSql}) THEN json('true') ELSE json('false') END`,
9701
9820
  tables,
9702
- params,
9821
+ params: [...params, ...params],
9703
9822
  };
9704
9823
  }
9705
9824
  else if (op === "<>") {
9706
9825
  // NaN <> anything is true (including NaN <> NaN)
9707
- // If the comparison returns NULL (because of NaN), return true (1)
9826
+ // If the comparison returns NULL (because of NaN), return true
9708
9827
  return {
9709
- sql: `COALESCE((${leftSql} ${op} ${rightSql}), 1)`,
9828
+ sql: `CASE WHEN (${leftSql} ${op} ${rightSql}) IS NULL THEN json('true') WHEN (${leftSql} ${op} ${rightSql}) THEN json('true') ELSE json('false') END`,
9710
9829
  tables,
9711
- params,
9830
+ params: [...params, ...params],
9712
9831
  };
9713
9832
  }
9714
9833
  else {
@@ -9722,9 +9841,9 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9722
9841
  else {
9723
9842
  // NaN compared to numeric via range operators returns false
9724
9843
  return {
9725
- sql: `COALESCE((${leftSql} ${op} ${rightSql}), 0)`,
9844
+ sql: `CASE WHEN (${leftSql} ${op} ${rightSql}) IS NULL THEN json('false') WHEN (${leftSql} ${op} ${rightSql}) THEN json('true') ELSE json('false') END`,
9726
9845
  tables,
9727
- params,
9846
+ params: [...params, ...params],
9728
9847
  };
9729
9848
  }
9730
9849
  }
@@ -9743,7 +9862,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9743
9862
  };
9744
9863
  const func = opToFunc[expr.comparisonOperator];
9745
9864
  return {
9746
- sql: `${func}(${leftSql}, ${rightSql})`,
9865
+ sql: `cypher_to_json_bool(${func}(${leftSql}, ${rightSql}))`,
9747
9866
  tables,
9748
9867
  params,
9749
9868
  };
@@ -9756,7 +9875,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9756
9875
  if ((expr.comparisonOperator === "=" || expr.comparisonOperator === "<>") && needsCypherEquals) {
9757
9876
  if (expr.comparisonOperator === "=") {
9758
9877
  return {
9759
- sql: `cypher_equals(${leftSql}, ${rightSql})`,
9878
+ sql: `cypher_to_json_bool(cypher_equals(${leftSql}, ${rightSql}))`,
9760
9879
  tables,
9761
9880
  params,
9762
9881
  };
@@ -9765,14 +9884,31 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9765
9884
  // <> is NOT equals: invert the result, but preserve null
9766
9885
  // We need to duplicate params because cypher_equals appears twice in the SQL
9767
9886
  return {
9768
- sql: `CASE WHEN cypher_equals(${leftSql}, ${rightSql}) IS NULL THEN NULL WHEN cypher_equals(${leftSql}, ${rightSql}) = 1 THEN 0 ELSE 1 END`,
9887
+ sql: `CASE WHEN cypher_equals(${leftSql}, ${rightSql}) IS NULL THEN NULL WHEN cypher_equals(${leftSql}, ${rightSql}) = 1 THEN json('false') ELSE json('true') END`,
9769
9888
  tables,
9770
9889
  params: [...params, ...params],
9771
9890
  };
9772
9891
  }
9773
9892
  }
9893
+ // For equality comparisons, use cypher_bool_eq to handle mixed boolean representations
9894
+ // (JSON boolean strings 'true'/'false' vs SQLite integers 1/0)
9895
+ if (expr.comparisonOperator === "=") {
9896
+ return {
9897
+ sql: `cypher_to_json_bool(cypher_bool_eq(${leftSql}, ${rightSql}))`,
9898
+ tables,
9899
+ params,
9900
+ };
9901
+ }
9902
+ if (expr.comparisonOperator === "<>") {
9903
+ // <> is NOT equals: use cypher_bool_eq and invert the result
9904
+ return {
9905
+ sql: `cypher_to_json_bool(CASE WHEN cypher_bool_eq(${leftSql}, ${rightSql}) IS NULL THEN NULL ELSE 1 - cypher_bool_eq(${leftSql}, ${rightSql}) END)`,
9906
+ tables,
9907
+ params: [...params, ...params], // Duplicate params for the two uses
9908
+ };
9909
+ }
9774
9910
  return {
9775
- sql: `(${leftSql} ${expr.comparisonOperator} ${rightSql})`,
9911
+ sql: `cypher_to_json_bool(${leftSql} ${expr.comparisonOperator} ${rightSql})`,
9776
9912
  tables,
9777
9913
  params,
9778
9914
  };
@@ -9899,6 +10035,197 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9899
10035
  params.push(...mapParams, ...listResult.params, ...filterParams);
9900
10036
  return { sql, tables, params };
9901
10037
  }
10038
+ /**
10039
+ * Translate a reduce expression.
10040
+ * Syntax: reduce(acc = init, x IN list | expr)
10041
+ *
10042
+ * Uses a recursive CTE to iterate through the list and accumulate a value.
10043
+ *
10044
+ * Example: reduce(acc = 0, x IN [1,2,3,4] | acc + x) returns 10
10045
+ */
10046
+ translateReduceExpression(expr) {
10047
+ const tables = [];
10048
+ const params = [];
10049
+ const accumulator = expr.accumulator;
10050
+ const initialValue = expr.initialValue;
10051
+ const variable = expr.variable;
10052
+ const listExpr = expr.listExpr;
10053
+ const reduceExpr = expr.reduceExpr;
10054
+ // Translate the initial value
10055
+ const initResult = this.translateExpression(initialValue);
10056
+ tables.push(...initResult.tables);
10057
+ // Translate the source list expression
10058
+ const listResult = this.translateExpression(listExpr);
10059
+ tables.push(...listResult.tables);
10060
+ // Build the reduce expression with variable substitutions
10061
+ // Replace accumulator with "__red__.acc" and variable with "__red_elem.value"
10062
+ 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 (
10073
+ SELECT 0, ${initResult.sql}
10074
+ UNION ALL
10075
+ SELECT __red__.idx + 1, ${reduceResult.sql}
10076
+ FROM __red__, json_each(${listResult.sql}) AS __red_elem
10077
+ WHERE __red__.idx = __red_elem.key
10078
+ )
10079
+ SELECT acc FROM __red__ ORDER BY idx DESC LIMIT 1)`;
10080
+ // Params order: init params, then list params (once for each occurrence), then reduce params
10081
+ params.push(...initResult.params, ...listResult.params, ...reduceResult.params);
10082
+ return { sql, tables, params };
10083
+ }
10084
+ /**
10085
+ * Translate a filter expression.
10086
+ * Syntax: filter(x IN list WHERE predicate)
10087
+ *
10088
+ * Returns a list of elements that satisfy the predicate.
10089
+ * Equivalent to list comprehension [x IN list WHERE predicate] without mapping.
10090
+ */
10091
+ translateFilterExpression(expr) {
10092
+ const tables = [];
10093
+ const params = [];
10094
+ const variable = expr.variable;
10095
+ const listExpr = expr.listExpr;
10096
+ const filterCondition = expr.filterCondition;
10097
+ // Translate the source list expression
10098
+ const listResult = this.translateExpression(listExpr);
10099
+ tables.push(...listResult.tables);
10100
+ // Wrap the source expression for json_each
10101
+ let sourceExpr = listResult.sql;
10102
+ if (listExpr.type === "property") {
10103
+ // For property access, use json_extract
10104
+ const varInfo = this.ctx.variables.get(listExpr.variable);
10105
+ if (varInfo) {
10106
+ sourceExpr = `json_extract(${varInfo.alias}.properties, '$.${listExpr.property}')`;
10107
+ }
10108
+ }
10109
+ // Build the WHERE clause from the filter condition
10110
+ const filterResult = this.translateListComprehensionCondition(filterCondition, variable, "__flt__");
10111
+ const filterParams = filterResult.params;
10112
+ const whereClause = ` WHERE ${filterResult.sql}`;
10113
+ // Build the final SQL using json_group_array
10114
+ const sql = `(SELECT json_group_array(__flt__.value) FROM json_each(${sourceExpr}) AS __flt__${whereClause})`;
10115
+ // Params must match SQL order: source params, then filter params
10116
+ params.push(...listResult.params, ...filterParams);
10117
+ return { sql, tables, params };
10118
+ }
10119
+ /**
10120
+ * Translate an extract expression.
10121
+ * Syntax: extract(x IN list | expr)
10122
+ *
10123
+ * Returns a list of mapped values.
10124
+ * Equivalent to list comprehension [x IN list | expr] without filtering.
10125
+ */
10126
+ translateExtractExpression(expr) {
10127
+ const tables = [];
10128
+ const params = [];
10129
+ const variable = expr.variable;
10130
+ const listExpr = expr.listExpr;
10131
+ const mapExpr = expr.mapExpr;
10132
+ // Translate the source list expression
10133
+ const listResult = this.translateExpression(listExpr);
10134
+ tables.push(...listResult.tables);
10135
+ // Wrap the source expression for json_each
10136
+ let sourceExpr = listResult.sql;
10137
+ if (listExpr.type === "property") {
10138
+ // For property access, use json_extract
10139
+ const varInfo = this.ctx.variables.get(listExpr.variable);
10140
+ if (varInfo) {
10141
+ sourceExpr = `json_extract(${varInfo.alias}.properties, '$.${listExpr.property}')`;
10142
+ }
10143
+ }
10144
+ // Translate the map expression
10145
+ const mapResult = this.translateListComprehensionExpr(mapExpr, variable, "__ext__");
10146
+ const mapParams = mapResult.params;
10147
+ const selectExpr = mapResult.sql;
10148
+ // Build the final SQL using json_group_array
10149
+ const sql = `(SELECT json_group_array(${selectExpr}) FROM json_each(${sourceExpr}) AS __ext__)`;
10150
+ // Params must match SQL order: select params, then source params
10151
+ params.push(...mapParams, ...listResult.params);
10152
+ return { sql, tables, params };
10153
+ }
10154
+ /**
10155
+ * Translate an expression within a reduce body, substituting variables.
10156
+ */
10157
+ translateReduceBodyExpr(expr, accVar, iterVar, tableAlias, elemAlias) {
10158
+ const params = [];
10159
+ switch (expr.type) {
10160
+ case "variable": {
10161
+ if (expr.variable === accVar) {
10162
+ return { sql: `${tableAlias}.acc`, params };
10163
+ }
10164
+ if (expr.variable === iterVar) {
10165
+ return { sql: `${elemAlias}.value`, params };
10166
+ }
10167
+ // Other variables - use the standard translation
10168
+ const varInfo = this.ctx.variables.get(expr.variable);
10169
+ if (varInfo) {
10170
+ return { sql: `${varInfo.alias}.${expr.property || "id"}`, params };
10171
+ }
10172
+ return { sql: expr.variable, params };
10173
+ }
10174
+ case "property": {
10175
+ if (expr.variable === iterVar) {
10176
+ // Property access on the iterator variable: x.name
10177
+ return { sql: `json_extract(${elemAlias}.value, '$.${expr.property}')`, params };
10178
+ }
10179
+ if (expr.variable === accVar) {
10180
+ // Property access on accumulator (if acc is an object)
10181
+ return { sql: `json_extract(${tableAlias}.acc, '$.${expr.property}')`, params };
10182
+ }
10183
+ // Standard property translation
10184
+ const varInfo = this.ctx.variables.get(expr.variable);
10185
+ if (varInfo) {
10186
+ return { sql: `json_extract(${varInfo.alias}.properties, '$.${expr.property}')`, params };
10187
+ }
10188
+ return { sql: `json_extract(${expr.variable}, '$.${expr.property}')`, params };
10189
+ }
10190
+ case "literal": {
10191
+ if (expr.value === null)
10192
+ return { sql: "NULL", params };
10193
+ if (typeof expr.value === "boolean")
10194
+ return { sql: expr.value ? "json('true')" : "json('false')", params };
10195
+ if (typeof expr.value === "number")
10196
+ return { sql: String(expr.value), params };
10197
+ if (typeof expr.value === "string") {
10198
+ params.push(expr.value);
10199
+ return { sql: "?", params };
10200
+ }
10201
+ if (Array.isArray(expr.value)) {
10202
+ return { sql: `json('${JSON.stringify(expr.value)}')`, params };
10203
+ }
10204
+ return { sql: `json('${JSON.stringify(expr.value)}')`, params };
10205
+ }
10206
+ case "binary": {
10207
+ const left = this.translateReduceBodyExpr(expr.left, accVar, iterVar, tableAlias, elemAlias);
10208
+ const right = this.translateReduceBodyExpr(expr.right, accVar, iterVar, tableAlias, elemAlias);
10209
+ params.push(...left.params, ...right.params);
10210
+ return { sql: `(${left.sql} ${expr.operator} ${right.sql})`, params };
10211
+ }
10212
+ case "function": {
10213
+ const funcName = expr.functionName.toUpperCase();
10214
+ const args = expr.args || [];
10215
+ const argResults = args.map(arg => this.translateReduceBodyExpr(arg, accVar, iterVar, tableAlias, elemAlias));
10216
+ for (const arg of argResults) {
10217
+ params.push(...arg.params);
10218
+ }
10219
+ const argsSql = argResults.map(r => r.sql).join(", ");
10220
+ return { sql: `${funcName}(${argsSql})`, params };
10221
+ }
10222
+ default: {
10223
+ // For other expression types, fall back to standard translation
10224
+ const result = this.translateExpression(expr);
10225
+ return { sql: result.sql, params: result.params };
10226
+ }
10227
+ }
10228
+ }
9902
10229
  /**
9903
10230
  * Translate a pattern comprehension expression.
9904
10231
  * Syntax: [(pattern) WHERE filterCondition | mapExpr]
@@ -10044,6 +10371,118 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10044
10371
  tables.push(boundVarInfo.alias);
10045
10372
  return { sql, tables, params };
10046
10373
  }
10374
+ /**
10375
+ * Translate an exists() pattern expression.
10376
+ * Returns true if the pattern has at least one match, false otherwise.
10377
+ *
10378
+ * Example: exists((p)-[:KNOWS]->()) returns true if node p has any outgoing KNOWS edges
10379
+ */
10380
+ translateExistsPattern(expr) {
10381
+ const tables = [];
10382
+ const params = [];
10383
+ const patterns = expr.patterns;
10384
+ // Pattern structure: patterns from parsePatternChain
10385
+ // The first element is always a NodePattern or RelationshipPattern
10386
+ const firstPattern = patterns[0];
10387
+ const isRelPattern = (p) => {
10388
+ return typeof p === "object" && p !== null && "edge" in p;
10389
+ };
10390
+ let startVar;
10391
+ let relPattern;
10392
+ let startNodePattern;
10393
+ let targetNodePattern;
10394
+ if (isRelPattern(firstPattern)) {
10395
+ // First pattern is a RelationshipPattern
10396
+ relPattern = firstPattern;
10397
+ startNodePattern = relPattern.source;
10398
+ targetNodePattern = relPattern.target;
10399
+ startVar = startNodePattern.variable;
10400
+ }
10401
+ else {
10402
+ // First pattern is a NodePattern, look for RelationshipPattern in rest
10403
+ startNodePattern = firstPattern;
10404
+ startVar = startNodePattern.variable;
10405
+ for (let i = 1; i < patterns.length; i++) {
10406
+ if (isRelPattern(patterns[i])) {
10407
+ relPattern = patterns[i];
10408
+ targetNodePattern = relPattern.target;
10409
+ break;
10410
+ }
10411
+ }
10412
+ }
10413
+ if (!startVar) {
10414
+ throw new Error("exists() pattern must start with a bound variable");
10415
+ }
10416
+ // Get the bound variable info from outer context
10417
+ const boundVarInfo = this.ctx.variables.get(startVar);
10418
+ if (!boundVarInfo) {
10419
+ throw new Error(`Unknown variable in exists() pattern: ${startVar}`);
10420
+ }
10421
+ if (!relPattern) {
10422
+ throw new Error("exists() pattern must include a relationship pattern");
10423
+ }
10424
+ // Build the correlated subquery
10425
+ const edgeAlias = `__ex_e_${this.ctx.aliasCounter++}`;
10426
+ const targetAlias = `__ex_t_${this.ctx.aliasCounter++}`;
10427
+ const edge = relPattern.edge;
10428
+ // Build edge type filter
10429
+ const edgeTypes = edge.types || (edge.type ? [edge.type] : []);
10430
+ let edgeTypeFilter = "";
10431
+ const edgeTypeParams = [];
10432
+ if (edgeTypes.length > 0) {
10433
+ const typeConditions = edgeTypes.map((t) => `${edgeAlias}.type = ?`);
10434
+ edgeTypeFilter = ` AND (${typeConditions.join(" OR ")})`;
10435
+ edgeTypeParams.push(...edgeTypes);
10436
+ }
10437
+ // Build direction filter
10438
+ let directionFilter = "";
10439
+ const direction = edge.direction || "right";
10440
+ if (direction === "right") {
10441
+ directionFilter = `${edgeAlias}.source_id = ${boundVarInfo.alias}.id`;
10442
+ }
10443
+ else if (direction === "left") {
10444
+ directionFilter = `${edgeAlias}.target_id = ${boundVarInfo.alias}.id`;
10445
+ }
10446
+ else {
10447
+ // "none" means either direction
10448
+ directionFilter = `(${edgeAlias}.source_id = ${boundVarInfo.alias}.id OR ${edgeAlias}.target_id = ${boundVarInfo.alias}.id)`;
10449
+ }
10450
+ // Build target node filter if labels specified
10451
+ let targetFilter = "";
10452
+ const targetFilterParams = [];
10453
+ if (targetNodePattern && targetNodePattern.label) {
10454
+ const labels = Array.isArray(targetNodePattern.label)
10455
+ ? targetNodePattern.label
10456
+ : [targetNodePattern.label];
10457
+ const labelConditions = labels.map((l) => `EXISTS(SELECT 1 FROM json_each(${targetAlias}.label) WHERE value = ?)`);
10458
+ targetFilter = ` AND ${labelConditions.join(" AND ")}`;
10459
+ targetFilterParams.push(...labels);
10460
+ }
10461
+ // Build the from clause
10462
+ let fromClause = `edges ${edgeAlias}`;
10463
+ if (targetNodePattern && (targetNodePattern.label || targetNodePattern.variable)) {
10464
+ // Need to join with nodes for target filtering
10465
+ let targetJoin;
10466
+ if (direction === "right") {
10467
+ targetJoin = `${edgeAlias}.target_id = ${targetAlias}.id`;
10468
+ }
10469
+ else if (direction === "left") {
10470
+ targetJoin = `${edgeAlias}.source_id = ${targetAlias}.id`;
10471
+ }
10472
+ else {
10473
+ // For undirected, target is the "other" node
10474
+ targetJoin = `(CASE WHEN ${edgeAlias}.source_id = ${boundVarInfo.alias}.id THEN ${edgeAlias}.target_id ELSE ${edgeAlias}.source_id END) = ${targetAlias}.id`;
10475
+ }
10476
+ fromClause = `edges ${edgeAlias} JOIN nodes ${targetAlias} ON ${targetJoin}`;
10477
+ }
10478
+ // Build the EXISTS subquery and wrap result for proper boolean output
10479
+ const sql = `cypher_to_json_bool(EXISTS(SELECT 1 FROM ${fromClause} WHERE ${directionFilter}${edgeTypeFilter}${targetFilter}))`;
10480
+ // Params must be in SQL order: edgeType, then targetFilter
10481
+ params.push(...edgeTypeParams, ...targetFilterParams);
10482
+ // Add outer table reference
10483
+ tables.push(boundVarInfo.alias);
10484
+ return { sql, tables, params };
10485
+ }
10047
10486
  /**
10048
10487
  * Translate an expression within a pattern comprehension.
10049
10488
  */
@@ -10172,9 +10611,14 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10172
10611
  if (expr.value === null) {
10173
10612
  return { sql: "NULL", params };
10174
10613
  }
10175
- // Convert booleans to 1/0 for SQLite (SQLite can only bind numbers, strings, bigints, buffers, and null)
10176
- const literalValue = expr.value === true ? 1 : expr.value === false ? 0 : expr.value;
10177
- params.push(literalValue);
10614
+ // Use 0/1 for booleans in list comprehension context (used in WHERE clauses)
10615
+ if (expr.value === true) {
10616
+ return { sql: "1", params };
10617
+ }
10618
+ if (expr.value === false) {
10619
+ return { sql: "0", params };
10620
+ }
10621
+ params.push(expr.value);
10178
10622
  return { sql: "?", params };
10179
10623
  case "parameter": {
10180
10624
  const paramValue = this.ctx.paramValues[expr.name];
@@ -10299,10 +10743,34 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10299
10743
  }
10300
10744
  case "expression": {
10301
10745
  // Handle bare expressions used as boolean conditions (e.g., all(x IN list WHERE x))
10746
+ // Need to convert JSON boolean strings to integers for SQLite WHERE clause evaluation
10747
+ // JSON booleans in collected arrays are stored as strings "true"/"false" which SQLite
10748
+ // treats as falsy (value 0) since they're not integers
10302
10749
  const exprResult = this.translateListComprehensionExpr(condition.left, compVar, tableAlias, scopes);
10750
+ // Wrap with CASE to handle JSON boolean strings, integers, and regular values
10751
+ // - JSON "true" string -> 1
10752
+ // - JSON "false" string -> 0
10753
+ // - JSON true literal (rarely occurs) -> 1
10754
+ // - JSON false literal (rarely occurs) -> 0
10755
+ // - Integer 1 -> 1 (already truthy)
10756
+ // - Integer 0 -> 0 (already falsy)
10757
+ // - NULL -> NULL
10758
+ // - Other truthy values pass through
10759
+ const wrappedSql = `(CASE WHEN ${exprResult.sql} = 'true' OR ${exprResult.sql} = 1 OR ${exprResult.sql} IS TRUE THEN 1 WHEN ${exprResult.sql} = 'false' OR ${exprResult.sql} = 0 OR ${exprResult.sql} IS FALSE THEN 0 WHEN ${exprResult.sql} IS NULL THEN NULL ELSE ${exprResult.sql} END)`;
10760
+ // Need to duplicate params for each use of exprResult.sql (6 uses total)
10761
+ const allParams = [
10762
+ ...exprResult.params, // first condition check (= 'true')
10763
+ ...exprResult.params, // second condition check (= 1)
10764
+ ...exprResult.params, // third condition check (IS TRUE)
10765
+ ...exprResult.params, // fourth condition check (= 'false')
10766
+ ...exprResult.params, // fifth condition check (= 0)
10767
+ ...exprResult.params, // sixth condition check (IS FALSE)
10768
+ ...exprResult.params, // seventh condition check (IS NULL)
10769
+ ...exprResult.params, // ELSE fallback
10770
+ ];
10303
10771
  return {
10304
- sql: exprResult.sql,
10305
- params: exprResult.params,
10772
+ sql: wrappedSql,
10773
+ params: allParams,
10306
10774
  };
10307
10775
  }
10308
10776
  case "listPredicate": {
@@ -10737,7 +11205,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10737
11205
  params.push(...operandResult.params);
10738
11206
  // Use custom cypher_not function that properly handles JSON booleans and integers
10739
11207
  return {
10740
- sql: `cypher_not(${operandResult.sql})`,
11208
+ sql: `cypher_to_json_bool(cypher_not(${operandResult.sql}))`,
10741
11209
  tables,
10742
11210
  params,
10743
11211
  };
@@ -10763,6 +11231,15 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10763
11231
  params: [...left.params, ...right.params],
10764
11232
  };
10765
11233
  }
11234
+ // For equality comparisons involving CASE expressions (which return json('true')/json('false')),
11235
+ // use cypher_bool_eq to handle JSON boolean vs integer comparison
11236
+ if (condition.operator === "=" &&
11237
+ (condition.left?.type === "case" || condition.right?.type === "case")) {
11238
+ return {
11239
+ sql: `cypher_bool_eq(${left.sql}, ${right.sql})`,
11240
+ params: [...left.params, ...right.params],
11241
+ };
11242
+ }
10766
11243
  return {
10767
11244
  sql: `${left.sql} ${condition.operator} ${right.sql}`,
10768
11245
  params: [...left.params, ...right.params],
@@ -10821,6 +11298,15 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10821
11298
  params: [...left.params, ...right.params, ...right.params, ...left.params, ...right.params, ...right.params],
10822
11299
  };
10823
11300
  }
11301
+ case "regex": {
11302
+ const left = this.translateWhereExpression(condition.left);
11303
+ const right = this.translateWhereExpression(condition.right);
11304
+ // Use cypher_regex custom function for regex matching
11305
+ return {
11306
+ sql: `cypher_regex(${left.sql}, ${right.sql})`,
11307
+ params: [...left.params, ...right.params],
11308
+ };
11309
+ }
10824
11310
  case "isNull": {
10825
11311
  const left = this.translateWhereExpression(condition.left);
10826
11312
  return {
@@ -11677,6 +12163,11 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
11677
12163
  params: [],
11678
12164
  };
11679
12165
  }
12166
+ case "case": {
12167
+ // CASE expression in WHERE clause
12168
+ const result = this.translateCaseExpression(expr);
12169
+ return { sql: result.sql, params: result.params };
12170
+ }
11680
12171
  default:
11681
12172
  throw new Error(`Unknown expression type in WHERE: ${expr.type}`);
11682
12173
  }
@@ -11705,7 +12196,14 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
11705
12196
  };
11706
12197
  }
11707
12198
  case "literal": {
11708
- const value = expr.value === true ? 1 : expr.value === false ? 0 : expr.value;
12199
+ // Return JSON booleans to preserve boolean type in results
12200
+ if (expr.value === true) {
12201
+ return { sql: "json('true')", tables, params };
12202
+ }
12203
+ if (expr.value === false) {
12204
+ return { sql: "json('false')", tables, params };
12205
+ }
12206
+ const value = expr.value;
11709
12207
  if (typeof value === "number" && expr.numberLiteralKind === "float" && expr.raw) {
11710
12208
  return { sql: expr.raw, tables, params };
11711
12209
  }