leangraph 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3541,51 +3541,51 @@ export class Translator {
3541
3541
  if (isUndirected2) {
3542
3542
  // Undirected: traverse in both directions
3543
3543
  if (edgeType2) {
3544
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3545
- 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
3546
3546
  UNION ALL
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)))
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
3548
3548
  FROM ${pathCteName2} p
3549
3549
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_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)
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 < ?
3551
3551
  )`;
3552
- allParams.push(maxHops2, edgeType2);
3552
+ allParams.push(maxHops2, edgeType2, earlyTerminationLimit);
3553
3553
  }
3554
3554
  else {
3555
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3556
- 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
3557
3557
  UNION ALL
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)))
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
3559
3559
  FROM ${pathCteName2} p
3560
3560
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3561
- 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 < ?
3562
3562
  )`;
3563
- allParams.push(maxHops2);
3563
+ allParams.push(maxHops2, earlyTerminationLimit);
3564
3564
  }
3565
3565
  }
3566
3566
  else {
3567
3567
  // Directed
3568
3568
  if (edgeType2) {
3569
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3570
- 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
3571
3571
  UNION ALL
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)))
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
3573
3573
  FROM ${pathCteName2} p
3574
3574
  JOIN edges e ON p.end_id = e.source_id
3575
- WHERE p.depth < ? AND e.type = ?
3575
+ WHERE p.depth < ? AND e.type = ? AND p.row_num < ?
3576
3576
  )`;
3577
- allParams.push(maxHops2, edgeType2);
3577
+ allParams.push(maxHops2, edgeType2, earlyTerminationLimit);
3578
3578
  }
3579
3579
  else {
3580
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) AS (
3581
- 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
3582
3582
  UNION ALL
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)))
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
3584
3584
  FROM ${pathCteName2} p
3585
3585
  JOIN edges e ON p.end_id = e.source_id
3586
- WHERE p.depth < ?
3586
+ WHERE p.depth < ? AND p.row_num < ?
3587
3587
  )`;
3588
- allParams.push(maxHops2);
3588
+ allParams.push(maxHops2, earlyTerminationLimit);
3589
3589
  }
3590
3590
  }
3591
3591
  }
@@ -3593,55 +3593,55 @@ export class Translator {
3593
3593
  if (isUndirected2) {
3594
3594
  // Undirected: traverse in both directions
3595
3595
  if (edgeType2) {
3596
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) 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))) 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 = ?
3598
3598
  UNION ALL
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))) 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 = ?
3600
3600
  UNION ALL
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)))
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
3602
3602
  FROM ${pathCteName2} p
3603
3603
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_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)
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 < ?
3605
3605
  )`;
3606
- allParams.push(edgeType2, edgeType2, maxHops2, edgeType2);
3606
+ allParams.push(edgeType2, edgeType2, maxHops2, edgeType2, earlyTerminationLimit);
3607
3607
  }
3608
3608
  else {
3609
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) 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))) 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
3611
3611
  UNION ALL
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))) 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
3613
3613
  UNION ALL
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)))
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
3615
3615
  FROM ${pathCteName2} p
3616
3616
  JOIN edges e ON (p.end_id = e.source_id OR p.end_id = e.target_id)
3617
- 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 < ?
3618
3618
  )`;
3619
- allParams.push(maxHops2);
3619
+ allParams.push(maxHops2, earlyTerminationLimit);
3620
3620
  }
3621
3621
  }
3622
3622
  else {
3623
3623
  // Directed
3624
3624
  if (edgeType2) {
3625
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) 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))) 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 = ?
3627
3627
  UNION ALL
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)))
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
3629
3629
  FROM ${pathCteName2} p
3630
3630
  JOIN edges e ON p.end_id = e.source_id
3631
- WHERE p.depth < ? AND e.type = ?
3631
+ WHERE p.depth < ? AND e.type = ? AND p.row_num < ?
3632
3632
  )`;
3633
- allParams.push(edgeType2, maxHops2, edgeType2);
3633
+ allParams.push(edgeType2, maxHops2, edgeType2, earlyTerminationLimit);
3634
3634
  }
3635
3635
  else {
3636
- cte2 = `, ${pathCteName2}(start_id, end_id, depth, edge_ids) 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))) 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
3638
3638
  UNION ALL
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)))
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
3640
3640
  FROM ${pathCteName2} p
3641
3641
  JOIN edges e ON p.end_id = e.source_id
3642
- WHERE p.depth < ?
3642
+ WHERE p.depth < ? AND p.row_num < ?
3643
3643
  )`;
3644
- allParams.push(maxHops2);
3644
+ allParams.push(maxHops2, earlyTerminationLimit);
3645
3645
  }
3646
3646
  }
3647
3647
  }
@@ -4079,7 +4079,7 @@ export class Translator {
4079
4079
  return { sql: "?", params: [expr.value] };
4080
4080
  }
4081
4081
  if (typeof expr.value === "boolean") {
4082
- return { sql: expr.value ? "1" : "0", params };
4082
+ return { sql: expr.value ? "json('true')" : "json('false')", params };
4083
4083
  }
4084
4084
  if (expr.value === null) {
4085
4085
  return { sql: "NULL", params };
@@ -5409,6 +5409,16 @@ END FROM (SELECT json_group_array(${valueExpr}) as sv))`,
5409
5409
  }
5410
5410
  throw new Error("sqrt requires an argument");
5411
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
+ }
5412
5422
  // RAND: random float between 0 and 1
5413
5423
  if (expr.functionName === "RAND") {
5414
5424
  // SQLite's RANDOM() returns integer between -9223372036854775808 and 9223372036854775807
@@ -7858,7 +7868,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
7858
7868
  // Strip JSON quotes if input is wrapped in "" (from toString() output)
7859
7869
  const argResult = this.translateExpression(arg);
7860
7870
  tables.push(...argResult.tables);
7861
- params.push(...argResult.params, ...argResult.params);
7871
+ params.push(...argResult.params);
7862
7872
  return {
7863
7873
  sql: `(SELECT CASE WHEN _d IS NULL THEN NULL ELSE CASE
7864
7874
  WHEN substr(_d, 1, 1) = '"' AND substr(_d, length(_d), 1) = '"'
@@ -8187,16 +8197,25 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8187
8197
  const listResult = this.translateExpression(listArg);
8188
8198
  const indexResult = this.translateExpression(indexArg);
8189
8199
  tables.push(...listResult.tables, ...indexResult.tables);
8190
- params.push(...listResult.params, ...indexResult.params);
8191
8200
  // For map access with string key, use json_extract with the key
8192
8201
  // For list access with integer index, use json_extract with array index
8193
8202
  if (isContainerMap || isStringExpression(resolvedIndexArg)) {
8194
8203
  // Map access: use key directly
8204
+ params.push(...listResult.params, ...indexResult.params);
8195
8205
  return { sql: `json_extract(${listResult.sql}, '$.' || ${indexResult.sql})`, tables, params };
8196
8206
  }
8197
8207
  // Use -> operator with array index to preserve JSON types (booleans, etc.)
8198
8208
  // Cast index to integer to avoid "0.0" in JSON path
8199
- 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
+ };
8200
8219
  }
8201
8220
  throw new Error("INDEX requires list and index arguments");
8202
8221
  }
@@ -8335,8 +8354,14 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8335
8354
  if (Array.isArray(expr.value)) {
8336
8355
  return this.translateArrayLiteral(expr.value);
8337
8356
  }
8338
- // Convert booleans to 1/0 for SQLite
8339
- 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;
8340
8365
  // Preserve float-literal formatting (e.g., 0.0, -0.0, 1.0) so SQLite treats them as REAL.
8341
8366
  if (typeof value === "number" && expr.numberLiteralKind === "float" && expr.raw) {
8342
8367
  return { sql: expr.raw, tables, params };
@@ -8377,8 +8402,29 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8377
8402
  case "patternComprehension": {
8378
8403
  return this.translatePatternComprehension(expr);
8379
8404
  }
8405
+ case "existsPattern": {
8406
+ return this.translateExistsPattern(expr);
8407
+ }
8380
8408
  case "listPredicate": {
8381
- 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);
8382
8428
  }
8383
8429
  case "unary": {
8384
8430
  return this.translateUnaryExpression(expr);
@@ -8472,7 +8518,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8472
8518
  if (listExpr.type === "literal" && Array.isArray(listExpr.value)) {
8473
8519
  const values = listExpr.value;
8474
8520
  if (values.length === 0) {
8475
- return { sql: "0", tables, params }; // false for empty list
8521
+ return { sql: "json('false')", tables, params }; // false for empty list
8476
8522
  }
8477
8523
  // Check if RHS contains complex types (nested arrays/objects)
8478
8524
  const rhsHasComplexTypes = values.some(containsComplexTypes);
@@ -8503,7 +8549,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8503
8549
  // If LHS contains null, finding a JSON match means comparing null=null → return NULL
8504
8550
  params.push(rhsJson, lhsJson);
8505
8551
  return {
8506
- 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`,
8507
8553
  tables,
8508
8554
  params,
8509
8555
  };
@@ -8512,7 +8558,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8512
8558
  // If RHS has top-level null and no match, return NULL
8513
8559
  params.push(rhsJson, lhsJson);
8514
8560
  return {
8515
- 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`,
8516
8562
  tables,
8517
8563
  params,
8518
8564
  };
@@ -8528,7 +8574,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8528
8574
  params.push(lhsJson, rhsJson);
8529
8575
  return {
8530
8576
  sql: `(SELECT CASE
8531
- 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')
8532
8578
  WHEN EXISTS(
8533
8579
  SELECT 1 FROM (
8534
8580
  SELECT rhs.rowid,
@@ -8542,7 +8588,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8542
8588
  GROUP BY rhs.rowid
8543
8589
  ) WHERE mismatches = 0 AND nulls > 0
8544
8590
  ) THEN NULL
8545
- ELSE 0
8591
+ ELSE json('false')
8546
8592
  END FROM (SELECT ? AS v) AS lhs_param, (SELECT ? AS v) AS rhs_param)`,
8547
8593
  tables,
8548
8594
  params,
@@ -8551,7 +8597,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8551
8597
  // No null semantics needed - simple JSON comparison
8552
8598
  params.push(rhsJson, lhsJson);
8553
8599
  return {
8554
- 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(?)))`,
8555
8601
  tables,
8556
8602
  params,
8557
8603
  };
@@ -8564,47 +8610,63 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8564
8610
  // So rhsJson comes first (for json_each), then leftResult.params (for leftResult.sql)
8565
8611
  params.push(rhsJson);
8566
8612
  params.push(...leftResult.params);
8567
- // When LHS is a scalar expression (like comparison result), don't use json() wrapper
8568
- // because SQLite UDF returns real type and json(0.0) != json(0)
8569
- // 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)
8570
8615
  const useDirectComparison = !leftIsComplex;
8571
8616
  if (rhsHasTopLevelNull) {
8572
8617
  if (useDirectComparison) {
8618
+ // Check for exact match first, then check for null comparison
8573
8619
  return {
8574
- 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`,
8575
8621
  tables,
8576
- params,
8622
+ params: [...params, ...params], // Duplicate for two json_each usages
8577
8623
  };
8578
8624
  }
8579
8625
  return {
8580
- 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`,
8581
8627
  tables,
8582
8628
  params,
8583
8629
  };
8584
8630
  }
8585
8631
  if (useDirectComparison) {
8586
8632
  return {
8587
- 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})))`,
8588
8634
  tables,
8589
8635
  params,
8590
8636
  };
8591
8637
  }
8592
8638
  return {
8593
- 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})))`,
8594
8640
  tables,
8595
8641
  params,
8596
8642
  };
8597
8643
  }
8598
- // 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)
8599
8646
  const leftResult = this.translateExpression(leftExpr);
8600
8647
  tables.push(...leftResult.tables);
8601
- params.push(...leftResult.params);
8602
- const placeholders = values.map(() => "?").join(", ");
8603
- params.push(...toSqliteParams(values));
8648
+ const rhsJson = JSON.stringify(values);
8604
8649
  // Wrap left side in extra parentheses to ensure correct precedence (e.g., NOT has lower precedence than IN in SQL)
8605
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);
8606
8668
  return {
8607
- sql: `(${leftSql} IN (${placeholders}))`,
8669
+ sql: `cypher_to_json_bool(EXISTS(SELECT 1 FROM json_each(?) WHERE cypher_bool_eq(${leftSql}, value)))`,
8608
8670
  tables,
8609
8671
  params,
8610
8672
  };
@@ -8613,7 +8675,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8613
8675
  const paramValue = this.ctx.paramValues[listExpr.name];
8614
8676
  if (Array.isArray(paramValue)) {
8615
8677
  if (paramValue.length === 0) {
8616
- return { sql: "0", tables, params }; // false for empty list
8678
+ return { sql: "json('false')", tables, params }; // false for empty list
8617
8679
  }
8618
8680
  // Check if RHS contains complex types
8619
8681
  const rhsHasComplexTypes = paramValue.some(containsComplexTypes);
@@ -8626,7 +8688,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8626
8688
  const lhsJson = JSON.stringify(leftExpr.value);
8627
8689
  params.push(rhsJson, lhsJson);
8628
8690
  return {
8629
- 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(?)))`,
8630
8692
  tables,
8631
8693
  params,
8632
8694
  };
@@ -8636,7 +8698,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8636
8698
  params.push(...leftResult.params);
8637
8699
  params.push(rhsJson);
8638
8700
  return {
8639
- 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})))`,
8640
8702
  tables,
8641
8703
  params,
8642
8704
  };
@@ -8650,7 +8712,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8650
8712
  // Wrap left side in extra parentheses to ensure correct precedence (e.g., NOT has lower precedence than IN in SQL)
8651
8713
  const leftSql = leftExpr.type === "unary" ? `(${leftResult.sql})` : leftResult.sql;
8652
8714
  return {
8653
- sql: `(${leftSql} IN (${placeholders}))`,
8715
+ sql: `cypher_to_json_bool(${leftSql} IN (${placeholders}))`,
8654
8716
  tables,
8655
8717
  params,
8656
8718
  };
@@ -8667,7 +8729,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8667
8729
  params.push(...listResult.params);
8668
8730
  params.push(lhsJson);
8669
8731
  return {
8670
- 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(?)))`,
8671
8733
  tables,
8672
8734
  params,
8673
8735
  };
@@ -8680,7 +8742,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8680
8742
  params.push(...listResult.params);
8681
8743
  params.push(...leftResult.params);
8682
8744
  return {
8683
- 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})))`,
8684
8746
  tables,
8685
8747
  params,
8686
8748
  };
@@ -8694,7 +8756,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8694
8756
  // Wrap left side in extra parentheses to ensure correct precedence (e.g., NOT has lower precedence than IN in SQL)
8695
8757
  const leftSql = leftExpr.type === "unary" ? `(${leftResult.sql})` : leftResult.sql;
8696
8758
  return {
8697
- 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})))`,
8698
8760
  tables,
8699
8761
  params,
8700
8762
  };
@@ -8717,7 +8779,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8717
8779
  if (stringOp === "CONTAINS") {
8718
8780
  // INSTR returns position (1-based) if found, 0 if not found
8719
8781
  return {
8720
- 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`,
8721
8783
  tables,
8722
8784
  // leftResult.sql appears 3 times, rightResult.sql appears 3 times
8723
8785
  params: [...leftResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params, ...leftResult.params, ...rightResult.params],
@@ -8726,7 +8788,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8726
8788
  else if (stringOp === "STARTS WITH") {
8727
8789
  // Use SUBSTR for case-sensitive prefix match
8728
8790
  return {
8729
- 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`,
8730
8792
  tables,
8731
8793
  // leftResult.sql appears 3 times, rightResult.sql appears 5 times
8732
8794
  params: [...leftResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params],
@@ -8736,7 +8798,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8736
8798
  // ENDS WITH
8737
8799
  // Use CASE to handle: 1) type check 2) empty suffix edge case, 3) case-sensitive suffix match
8738
8800
  return {
8739
- 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`,
8740
8802
  tables,
8741
8803
  // leftResult.sql appears 4 times, rightResult.sql appears 6 times
8742
8804
  params: [...leftResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params, ...rightResult.params, ...leftResult.params, ...rightResult.params, ...rightResult.params],
@@ -8970,7 +9032,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8970
9032
  // Use custom cypher_and/cypher_or functions for proper JSON boolean handling
8971
9033
  const func = expr.operator === "AND" ? "cypher_and" : "cypher_or";
8972
9034
  return {
8973
- sql: `${func}(${leftResult.sql}, ${rightResult.sql})`,
9035
+ sql: `cypher_to_json_bool(${func}(${leftResult.sql}, ${rightResult.sql}))`,
8974
9036
  tables,
8975
9037
  params,
8976
9038
  };
@@ -8983,13 +9045,11 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
8983
9045
  const leftSql = leftResult.sql;
8984
9046
  const rightSql = rightResult.sql;
8985
9047
  // XOR with NULL semantics: (a XOR b) = (a AND NOT b) OR (NOT a AND b)
8986
- // This naturally handles NULL: if a is NULL, (a AND NOT b) is NULL or FALSE, (NOT a AND b) is NULL or FALSE
8987
- // 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
8988
9049
  // Note: params are duplicated because the formula uses each operand twice:
8989
- // ((left AND NOT right) OR (NOT left AND right))
8990
9050
  const xorParams = [...leftResult.params, ...rightResult.params, ...leftResult.params, ...rightResult.params];
8991
9051
  return {
8992
- 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})))`,
8993
9053
  tables,
8994
9054
  params: xorParams,
8995
9055
  };
@@ -9614,8 +9674,13 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9614
9674
  if (Array.isArray(expr.value)) {
9615
9675
  return this.translateArrayLiteral(expr.value);
9616
9676
  }
9617
- const value = expr.value === true ? 1 : expr.value === false ? 0 : expr.value;
9618
- 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);
9619
9684
  return { sql: "?", params };
9620
9685
  case "property": {
9621
9686
  // Use subquery to get property from the created node
@@ -9746,22 +9811,23 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9746
9811
  if (leftCouldBeNaN || rightCouldBeNaN) {
9747
9812
  const op = expr.comparisonOperator;
9748
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
9749
9815
  if (op === "=") {
9750
9816
  // NaN = anything is false (including NaN = NaN)
9751
- // If the comparison returns NULL (because of NaN), return false (0)
9817
+ // If the comparison returns NULL (because of NaN), return false
9752
9818
  return {
9753
- 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`,
9754
9820
  tables,
9755
- params,
9821
+ params: [...params, ...params],
9756
9822
  };
9757
9823
  }
9758
9824
  else if (op === "<>") {
9759
9825
  // NaN <> anything is true (including NaN <> NaN)
9760
- // If the comparison returns NULL (because of NaN), return true (1)
9826
+ // If the comparison returns NULL (because of NaN), return true
9761
9827
  return {
9762
- 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`,
9763
9829
  tables,
9764
- params,
9830
+ params: [...params, ...params],
9765
9831
  };
9766
9832
  }
9767
9833
  else {
@@ -9775,9 +9841,9 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9775
9841
  else {
9776
9842
  // NaN compared to numeric via range operators returns false
9777
9843
  return {
9778
- 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`,
9779
9845
  tables,
9780
- params,
9846
+ params: [...params, ...params],
9781
9847
  };
9782
9848
  }
9783
9849
  }
@@ -9796,7 +9862,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9796
9862
  };
9797
9863
  const func = opToFunc[expr.comparisonOperator];
9798
9864
  return {
9799
- sql: `${func}(${leftSql}, ${rightSql})`,
9865
+ sql: `cypher_to_json_bool(${func}(${leftSql}, ${rightSql}))`,
9800
9866
  tables,
9801
9867
  params,
9802
9868
  };
@@ -9809,7 +9875,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9809
9875
  if ((expr.comparisonOperator === "=" || expr.comparisonOperator === "<>") && needsCypherEquals) {
9810
9876
  if (expr.comparisonOperator === "=") {
9811
9877
  return {
9812
- sql: `cypher_equals(${leftSql}, ${rightSql})`,
9878
+ sql: `cypher_to_json_bool(cypher_equals(${leftSql}, ${rightSql}))`,
9813
9879
  tables,
9814
9880
  params,
9815
9881
  };
@@ -9818,14 +9884,31 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9818
9884
  // <> is NOT equals: invert the result, but preserve null
9819
9885
  // We need to duplicate params because cypher_equals appears twice in the SQL
9820
9886
  return {
9821
- 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`,
9822
9888
  tables,
9823
9889
  params: [...params, ...params],
9824
9890
  };
9825
9891
  }
9826
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
+ }
9827
9910
  return {
9828
- sql: `(${leftSql} ${expr.comparisonOperator} ${rightSql})`,
9911
+ sql: `cypher_to_json_bool(${leftSql} ${expr.comparisonOperator} ${rightSql})`,
9829
9912
  tables,
9830
9913
  params,
9831
9914
  };
@@ -9952,6 +10035,197 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
9952
10035
  params.push(...mapParams, ...listResult.params, ...filterParams);
9953
10036
  return { sql, tables, params };
9954
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
+ }
9955
10229
  /**
9956
10230
  * Translate a pattern comprehension expression.
9957
10231
  * Syntax: [(pattern) WHERE filterCondition | mapExpr]
@@ -10097,6 +10371,118 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10097
10371
  tables.push(boundVarInfo.alias);
10098
10372
  return { sql, tables, params };
10099
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
+ }
10100
10486
  /**
10101
10487
  * Translate an expression within a pattern comprehension.
10102
10488
  */
@@ -10225,9 +10611,14 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10225
10611
  if (expr.value === null) {
10226
10612
  return { sql: "NULL", params };
10227
10613
  }
10228
- // Convert booleans to 1/0 for SQLite (SQLite can only bind numbers, strings, bigints, buffers, and null)
10229
- const literalValue = expr.value === true ? 1 : expr.value === false ? 0 : expr.value;
10230
- 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);
10231
10622
  return { sql: "?", params };
10232
10623
  case "parameter": {
10233
10624
  const paramValue = this.ctx.paramValues[expr.name];
@@ -10352,10 +10743,34 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10352
10743
  }
10353
10744
  case "expression": {
10354
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
10355
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
+ ];
10356
10771
  return {
10357
- sql: exprResult.sql,
10358
- params: exprResult.params,
10772
+ sql: wrappedSql,
10773
+ params: allParams,
10359
10774
  };
10360
10775
  }
10361
10776
  case "listPredicate": {
@@ -10790,7 +11205,7 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10790
11205
  params.push(...operandResult.params);
10791
11206
  // Use custom cypher_not function that properly handles JSON booleans and integers
10792
11207
  return {
10793
- sql: `cypher_not(${operandResult.sql})`,
11208
+ sql: `cypher_to_json_bool(cypher_not(${operandResult.sql}))`,
10794
11209
  tables,
10795
11210
  params,
10796
11211
  };
@@ -10816,6 +11231,15 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10816
11231
  params: [...left.params, ...right.params],
10817
11232
  };
10818
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
+ }
10819
11243
  return {
10820
11244
  sql: `${left.sql} ${condition.operator} ${right.sql}`,
10821
11245
  params: [...left.params, ...right.params],
@@ -10874,6 +11298,15 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
10874
11298
  params: [...left.params, ...right.params, ...right.params, ...left.params, ...right.params, ...right.params],
10875
11299
  };
10876
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
+ }
10877
11310
  case "isNull": {
10878
11311
  const left = this.translateWhereExpression(condition.left);
10879
11312
  return {
@@ -11730,6 +12163,11 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
11730
12163
  params: [],
11731
12164
  };
11732
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
+ }
11733
12171
  default:
11734
12172
  throw new Error(`Unknown expression type in WHERE: ${expr.type}`);
11735
12173
  }
@@ -11758,7 +12196,14 @@ SELECT COALESCE(json_group_array(CAST(n AS INTEGER)), json_array()) FROM r)`,
11758
12196
  };
11759
12197
  }
11760
12198
  case "literal": {
11761
- 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;
11762
12207
  if (typeof value === "number" && expr.numberLiteralKind === "float" && expr.raw) {
11763
12208
  return { sql: expr.raw, tables, params };
11764
12209
  }