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