jsql-neo 6.3.3 → 6.3.5

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/lib/sql.js CHANGED
@@ -610,6 +610,17 @@ class Parser {
610
610
 
611
611
  while (true) {
612
612
  const t = this.peek();
613
+ // 列级 CHECK (age INT CHECK(age >= 0))。
614
+ // CHECK 刻意不在 KEYWORDS 里(避免影响同名标识符),所以用 isWord 匹配,
615
+ // 放在 keyword switch 之外。表级 CHECK 已能被解析(同样只是跳过),
616
+ // 列级此前会直接 return def,把 CHECK 留在流里 →
617
+ // "Expected ',' or ')' in CREATE TABLE, got 'CHECK'"。
618
+ // 与表级保持一致:解析并跳过,条件文本存到 def.check 供后续实现。
619
+ if (this.isWord('CHECK')) {
620
+ this.next();
621
+ if (this.peek().type === 'op' && this.peek().value === '(') this._skipParens();
622
+ continue;
623
+ }
613
624
  if (t.type === 'keyword') {
614
625
  switch (t.value) {
615
626
  case 'PRIMARY':
@@ -1073,8 +1084,19 @@ class Parser {
1073
1084
  this.expect('op', ')');
1074
1085
  aggregate = { type: 'COUNT', column: col, distinct: aggDistinct };
1075
1086
  aggregate = this.parseOptionalOver(aggregate);
1076
- aggregate.alias = this.parseOptionalAlias();
1077
- columns.push({ expr: col, aggregate: 'COUNT', column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct });
1087
+ // 聚合后紧跟运算符:SUM(v)/COUNT(*) / COUNT(*)-1 …
1088
+ // 顶层聚合默认被表示为独立的 aggregate 字段,不参与后续运算,
1089
+ // 运算符会留在流里 → "Unexpected token '/' after statement"。
1090
+ // 此时整体降级成 scalar 表达式(arith 里嵌 aggregate),
1091
+ // 由执行层的嵌套聚合替换逻辑求值。
1092
+ // 注意:先看尾随运算符,再取别名 —— 顺序反了会把 AS 当成运算符的一部分。
1093
+ const cntTail = this.parseArithTail(aggregate);
1094
+ if (cntTail) {
1095
+ columns.push({ expr: null, scalar: cntTail, alias: this.parseOptionalAlias() });
1096
+ } else {
1097
+ aggregate.alias = this.parseOptionalAlias();
1098
+ columns.push({ expr: col, aggregate: 'COUNT', column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct });
1099
+ }
1078
1100
  } else if (t.type === 'keyword' && ['SUM', 'AVG', 'MIN', 'MAX'].includes(t.value)) {
1079
1101
  this.next();
1080
1102
  const fn = t.value;
@@ -1085,8 +1107,14 @@ class Parser {
1085
1107
  this.expect('op', ')');
1086
1108
  aggregate = { type: fn, column: col, distinct: aggDistinct2 };
1087
1109
  aggregate = this.parseOptionalOver(aggregate);
1088
- aggregate.alias = this.parseOptionalAlias();
1089
- columns.push({ expr: col, aggregate: fn, column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct2 });
1110
+ // 同 COUNT 分支:聚合参与后续算术时降级成 scalar 表达式
1111
+ const fnTail = this.parseArithTail(aggregate);
1112
+ if (fnTail) {
1113
+ columns.push({ expr: null, scalar: fnTail, alias: this.parseOptionalAlias() });
1114
+ } else {
1115
+ aggregate.alias = this.parseOptionalAlias();
1116
+ columns.push({ expr: col, aggregate: fn, column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct2 });
1117
+ }
1090
1118
  } else if (t.type === 'op' && t.value === '*') {
1091
1119
  this.next();
1092
1120
  columns.push({ expr: '*' });
@@ -1762,9 +1790,49 @@ class Parser {
1762
1790
  this.peek(1) && this.peek(1).type === 'keyword' && this.peek(1).value === 'SELECT';
1763
1791
  if (!isSubqueryParen && this.peek().type === 'op' && this.peek().value === '(') {
1764
1792
  this.next();
1765
- const e = this.parseExpr();
1793
+ // 递归解析括号内的完整表达式(到 ')' 为止)。
1794
+ // 此处不能用 parseExpr():它一路走到 parseComparison,
1795
+ // 而内层看不到 ')' 会抛 "Expected comparison operator, got ')'"。
1796
+ // 用 bareExprDepth>0 的 parseArgExpr 走同一套优先级,但允许裸操作数收尾。
1797
+ this.bareExprDepth = (this.bareExprDepth || 0) + 1;
1798
+ let left;
1799
+ try {
1800
+ left = this.parseOr();
1801
+ } finally {
1802
+ this.bareExprDepth--;
1803
+ }
1766
1804
  this.expect('op', ')');
1767
- return e;
1805
+ // 括号整体是一个完整的左操作数,后面若紧跟运算符必须继续参与运算:
1806
+ // SELECT (1+2)*3 / SELECT (a+b)/2 / WHERE (x+1) > 5
1807
+ // 直接 return 会把运算符留在流里 → "Unexpected token '*' after statement"
1808
+ // (2*(3+4) 正常是因为 '(' 出现在右操作数位置,不走这个分支。)
1809
+ // 先乘除后加减,与 parseTerm/parseAdditive 的优先级一致。
1810
+ for (;;) {
1811
+ const t = this.peek();
1812
+ if (t.type !== 'op' || !['*', '/', '%'].includes(t.value)) break;
1813
+ this.next();
1814
+ const right = this.parseExpr();
1815
+ left = { type: 'arith', op: t.value, left, right };
1816
+ }
1817
+ for (;;) {
1818
+ const t = this.peek();
1819
+ if (t.type !== 'op' || !['+', '-'].includes(t.value)) break;
1820
+ this.next();
1821
+ const right = this.parseExpr();
1822
+ left = { type: 'arith', op: t.value, left, right };
1823
+ }
1824
+ // 尾随比较/条件运算符:WHERE (a) = 1 / (a) IN (...) / (a) LIKE 'x' /
1825
+ // (a) IS NULL / (a) BETWEEN 1 AND 5 / (a) NOT IN (...)。
1826
+ // 一律交给 parseComparison —— 它是完整实现,覆盖这些全部形式。
1827
+ const nx = this.peek();
1828
+ const isCmp = nx.type === 'op' &&
1829
+ ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>', '@>', '<@'].includes(nx.value);
1830
+ const isCondKw = nx.type === 'keyword' &&
1831
+ ['IN', 'LIKE', 'ILIKE', 'IS', 'BETWEEN', 'REGEXP', 'RLIKE', 'NOT'].includes(nx.value);
1832
+ if (isCmp || isCondKw) {
1833
+ return this.parseComparison(left);
1834
+ }
1835
+ return left;
1768
1836
  }
1769
1837
  // EXISTS (SELECT ...):子查询是否存在至少一行
1770
1838
  if (this.isKeyword('EXISTS')) {
@@ -1777,6 +1845,42 @@ class Parser {
1777
1845
  return this.parseComparison();
1778
1846
  }
1779
1847
 
1848
+ /**
1849
+ * 聚合(或任意已解析好的左操作数)之后若紧跟算术运算符,继续解析并返回
1850
+ * 组合后的 arith 节点;没有运算符则返回 null(调用方走原有的 aggregate 列路径)。
1851
+ * SELECT SUM(v)/COUNT(*) → arith('/', SUM, arith('/', COUNT))
1852
+ * 优先级与 parseTerm / parseAdditive 一致:先乘除后加减。
1853
+ */
1854
+ parseArithTail(left) {
1855
+ // 投影分支里的 aggregate 对象是 { type: 'SUM', column, distinct } ——
1856
+ // type 直接是聚合函数名,不是 'aggregate'。而执行层的 _replaceAggregates
1857
+ // 只认 type === 'aggregate',不规范化就会漏替换 → 整个表达式求值为 null。
1858
+ // 这里包一层 { type:'aggregate', fn, column, … } 供替换逻辑识别。
1859
+ const seed = left && left.type !== 'aggregate' && typeof left.type === 'string' &&
1860
+ /^[A-Z][A-Z0-9_]*$/.test(left.type)
1861
+ ? { type: 'aggregate', fn: left.type, column: left.column,
1862
+ distinct: left.distinct, separator: left.separator, orderBy: left.orderBy }
1863
+ : left;
1864
+
1865
+ let node = seed;
1866
+ let touched = false;
1867
+ for (;;) {
1868
+ const t = this.peek();
1869
+ if (t.type !== 'op' || !['*', '/', '%'].includes(t.value)) break;
1870
+ this.next();
1871
+ node = { type: 'arith', op: t.value, left: node, right: this.parseScalar() };
1872
+ touched = true;
1873
+ }
1874
+ for (;;) {
1875
+ const t = this.peek();
1876
+ if (t.type !== 'op' || !['+', '-'].includes(t.value)) break;
1877
+ this.next();
1878
+ node = { type: 'arith', op: t.value, left: node, right: this.parseScalar() };
1879
+ touched = true;
1880
+ }
1881
+ return touched ? node : null;
1882
+ }
1883
+
1780
1884
  /**
1781
1885
  * 解析聚合调用:fn ( [DISTINCT] expr [, expr*] [ORDER BY ...] [SEPARATOR 'x'] )
1782
1886
  * 以及 COUNT(*) 这类无参形式。
@@ -1838,7 +1942,27 @@ class Parser {
1838
1942
  // 否则会被当作逐行标量函数:既返回 null,又不会折叠分组
1839
1943
  if (AGG_FUNCS.has(String(t.value).toUpperCase()) &&
1840
1944
  this.peek().type === 'op' && this.peek().value === '(') {
1841
- return this.parseAggregateCall(String(t.value).toUpperCase());
1945
+ const agg = this.parseAggregateCall(String(t.value).toUpperCase());
1946
+ // 聚合结果也是一个完整的左操作数,后面可紧跟运算符:
1947
+ // SELECT SUM(sal)/COUNT(*) AS avg ← 算平均值的标准写法
1948
+ // SELECT SUM(sal) - MIN(sal) ← 极差
1949
+ // SELECT MAX(sal) * 2
1950
+ // 不处理的话运算符会留在流里 → "Unexpected token '/' after statement"。
1951
+ // (加括号写成 (SUM(sal))/2 之所以正常,是走了括号分组分支。)
1952
+ let left = agg;
1953
+ for (;;) {
1954
+ const nx = this.peek();
1955
+ if (nx.type !== 'op' || !['*', '/', '%'].includes(nx.value)) break;
1956
+ this.next();
1957
+ left = { type: 'arith', op: nx.value, left, right: this.parseOperand() };
1958
+ }
1959
+ for (;;) {
1960
+ const nx = this.peek();
1961
+ if (nx.type !== 'op' || !['+', '-'].includes(nx.value)) break;
1962
+ this.next();
1963
+ left = { type: 'arith', op: nx.value, left, right: this.parseOperand() };
1964
+ }
1965
+ return left;
1842
1966
  }
1843
1967
  // 支持 alias.column 引用
1844
1968
  if (this.peek().type === 'op' && this.peek().value === '.') {
@@ -2039,10 +2163,33 @@ class Parser {
2039
2163
  }
2040
2164
  // 括号分组:支持 (1+2)、2*(3+4) 等出现在算术/函数参数任意位置
2041
2165
  // 注意:parseOperand 开头已通过 const t = this.next() 消费了 '(', 此处不可再 next()
2166
+ //
2167
+ // 括号整体是一个完整的左操作数,后面若紧跟运算符必须继续参与运算,
2168
+ // 否则运算符会留在流里:
2169
+ // SELECT (1+2)*3 → Unexpected token '*' after statement
2170
+ // WHERE (a) = 1 → Expected comparison operator, got ')'
2042
2171
  if (t.type === 'op' && t.value === '(') {
2043
- const inner = this.parseArgExpr(); // 容忍裸操作数,允许纯算术分组
2172
+ let left = this.parseArgExpr(); // 容忍裸操作数,允许纯算术分组
2044
2173
  this.expect('op', ')');
2045
- return inner;
2174
+ // 先乘除后加减,与 parseTerm / parseAdditive 的优先级一致
2175
+ for (;;) {
2176
+ const nx = this.peek();
2177
+ if (nx.type !== 'op' || !['*', '/', '%'].includes(nx.value)) break;
2178
+ this.next();
2179
+ left = { type: 'arith', op: nx.value, left, right: this.parseArgExpr() };
2180
+ }
2181
+ for (;;) {
2182
+ const nx = this.peek();
2183
+ if (nx.type !== 'op' || !['+', '-'].includes(nx.value)) break;
2184
+ this.next();
2185
+ left = { type: 'arith', op: nx.value, left, right: this.parseArgExpr() };
2186
+ }
2187
+ // 尾随比较运算符交给 parseComparison(完整实现,覆盖 =/</>/LIKE/IN/IS…)
2188
+ const cx = this.peek();
2189
+ if (cx.type === 'op' && ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>'].includes(cx.value)) {
2190
+ return this.parseComparison(left);
2191
+ }
2192
+ return left;
2046
2193
  }
2047
2194
  if (t.type === 'op' && (t.value === '-' || t.value === '+')) {
2048
2195
  const num = this.next();
@@ -2190,7 +2337,15 @@ class Parser {
2190
2337
  const v = this.parseOperand();
2191
2338
  cond = { type: 'compare', op: '=', left: base, right: v };
2192
2339
  } else {
2193
- cond = this.parseComparison();
2340
+ // 条件可能是完整比较(WHEN a > 1),也可能是裸值/裸列(WHEN 1、WHEN flag)。
2341
+ // parseComparison 只接受比较式,裸值会抛 "Expected comparison operator, got 'THEN'"。
2342
+ // 先看清下一个 token 决定走哪条路,避免用 try/catch 回溯吞掉真实错误。
2343
+ const nx = this.peek();
2344
+ const isCmp = (nx.type === 'op' && ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>'].includes(nx.value)) ||
2345
+ (nx.type === 'keyword' && ['IN', 'LIKE', 'ILIKE', 'IS', 'BETWEEN', 'REGEXP', 'RLIKE', 'NOT'].includes(nx.value)) ||
2346
+ this.isWord('NOT');
2347
+ cond = isCmp ? this.parseComparison()
2348
+ : { type: 'isTruth', operand: this.parseArgExpr(), not: false, truth: true };
2194
2349
  }
2195
2350
  this.expectKeyword('THEN');
2196
2351
  const val = this.parseOperand();
@@ -2202,8 +2357,13 @@ class Parser {
2202
2357
  return { type: 'case', branches, elseVal };
2203
2358
  }
2204
2359
 
2205
- parseComparison() {
2206
- const left = this.parseBitwise();
2360
+ /**
2361
+ * 解析比较表达式。
2362
+ * preLeft:已解析好的左操作数(括号分组场景 —— parseNot 消费到 ')' 后传进来),
2363
+ * 省略时正常从 parseBitwise() 取左操作数。
2364
+ */
2365
+ parseComparison(preLeft) {
2366
+ const left = preLeft !== undefined ? preLeft : this.parseBitwise();
2207
2367
  const t = this.peek();
2208
2368
  let not = false;
2209
2369
  if (t.type === 'keyword' && t.value === 'NOT') {
@@ -2347,7 +2507,10 @@ const OPERATORS = {
2347
2507
  function applyCast(value, dataType) {
2348
2508
  const t = String(dataType || '').toUpperCase().replace(/\(.*\)$/, '').trim();
2349
2509
  if (value === null || value === undefined) return null;
2350
- if (t === 'INTEGER' || t === 'INT' || t === 'BIGINT' || t === 'SMALLINT' || t === 'TINYINT') {
2510
+ // SIGNED / UNSIGNED 是 MySQL 的整型别名(CAST(x AS SIGNED)),
2511
+ // 此前没列入整型分支,导致 CAST('12abc' AS SIGNED) 原样返回字符串。
2512
+ if (t === 'INTEGER' || t === 'INT' || t === 'BIGINT' || t === 'SMALLINT' ||
2513
+ t === 'TINYINT' || t === 'SIGNED' || t === 'UNSIGNED' || t === 'MEDIUMINT') {
2351
2514
  // MySQL CAST(... AS SIGNED/INT):取数字前缀('12abc'→12),无法解析时返回 0(而非 null)。
2352
2515
  if (typeof value === 'boolean') return value ? 1 : 0;
2353
2516
  if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : 0;
@@ -2374,6 +2537,23 @@ function rowHasPrefixedKeys(row) {
2374
2537
  return false;
2375
2538
  }
2376
2539
 
2540
+ /**
2541
+ * 字符串能否当作数值参与算术运算。
2542
+ * MySQL 里 '5' + 5 = 10、'5' + '5' = 10、'abc' + 1 = 1(非数字按 0)。
2543
+ * 纯数字(含小数/负号/科学计数)才返回数值,否则返回 null。
2544
+ */
2545
+ function numericIfNumeric(v) {
2546
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
2547
+ if (typeof v === 'boolean') return v ? 1 : 0;
2548
+ if (typeof v !== 'string') return null;
2549
+ const s = v.trim();
2550
+ if (s === '') return null;
2551
+ // 严格数字:可选符号 + 数字(含小数与指数),不接受 '12abc' 这类前缀形式
2552
+ if (!/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
2553
+ const n = Number(s);
2554
+ return Number.isFinite(n) ? n : null;
2555
+ }
2556
+
2377
2557
  function resolveOperand(operand, row, ctx) {
2378
2558
  if (operand === null || operand === undefined) return null;
2379
2559
  if (typeof operand === 'string') return resolveOperand({ type: 'column', name: operand }, row, ctx);
@@ -2423,11 +2603,28 @@ function resolveOperand(operand, row, ctx) {
2423
2603
  const r = resolveOperand(operand.right, row, ctx);
2424
2604
  if (l === null || r === null || l === undefined || r === undefined) return null;
2425
2605
  switch (operand.op) {
2426
- case '+': return l + r;
2427
- case '-': return l - r;
2428
- case '*': return l * r;
2429
- case '/': return r === 0 ? null : l / r;
2430
- case '%': return r === 0 ? null : l % r;
2606
+ // MySQL/SQLite 里 + 永远是算术加法,字符串拼接只走 || 或 CONCAT。
2607
+ // 直接用 JS 的 l + r 会让 '5' + 5 得到 '55'(静默返回字符串),
2608
+ // 数字与字符串混合的算术会一路错下去。
2609
+ // 判据:任一侧是 number,或字符串能被完整解析为数字 → 走数值加法。
2610
+ case '+': {
2611
+ // MySQL / SQLite 语义(已用 sqlite3 实测核对):
2612
+ // '5' + 5 = 10 '5' + '5' = 10 'abc' + 1 = 1 'a' + 'b' = 0
2613
+ // 任一侧是「数值」时做算术加法,另一侧非数值按 0;
2614
+ // 两侧都不是数值才是 0(JS 的 l + r 会把 '5' + 5 拼成 '55')。
2615
+ if (typeof l === 'number' || typeof r === 'number') {
2616
+ const ln2 = numericIfNumeric(l), rn2 = numericIfNumeric(r);
2617
+ return (ln2 === null ? 0 : ln2) + (rn2 === null ? 0 : rn2);
2618
+ }
2619
+ const ln = numericIfNumeric(l), rn = numericIfNumeric(r);
2620
+ if (ln !== null) return ln + (rn === null ? 0 : rn);
2621
+ if (rn !== null) return rn; // 左侧非数值按 0
2622
+ return 0;
2623
+ }
2624
+ case '-': return Number(l) - Number(r);
2625
+ case '*': return Number(l) * Number(r);
2626
+ case '/': return r === 0 ? null : Number(l) / Number(r);
2627
+ case '%': return r === 0 ? null : Number(l) % Number(r);
2431
2628
  // MySQL 整数除法 DIV(结果向零取整)
2432
2629
  case 'DIV': return r === 0 ? null : Math.trunc(Number(l) / Number(r));
2433
2630
  // 位运算:先按整数归一,与 MySQL 的整数位运算语义一致
@@ -2894,7 +3091,12 @@ function applyScalarFunction(fnNode, row, ctx) {
2894
3091
  const b = parseSqlDate(args[1]);
2895
3092
  if (!a || !b) return null;
2896
3093
  const ms = a.getTime() - b.getTime();
2897
- const months = (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth());
3094
+ // MySQL 的整月差:先算日历月数差,再按「结束日的日 < 起始日的日」退一档。
3095
+ // 只算日历月差会高估:
3096
+ // TIMESTAMPDIFF(MONTH,'2026-01-15','2026-03-14') = 1(差 1 个月零 29 天,不足 2 个月)
3097
+ // TIMESTAMPDIFF(MONTH,'2026-01-31','2026-03-01') = 1(1/31+1月=2/28,2/28+1月=3/28,够不到 3/1)
3098
+ let months = (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth());
3099
+ if (a.getDate() < b.getDate()) months--;
2898
3100
  switch (unit) {
2899
3101
  case 'MICROSECOND': return Math.floor(ms * 1000);
2900
3102
  case 'SECOND': return Math.floor(ms / 1000);
@@ -2904,7 +3106,7 @@ function applyScalarFunction(fnNode, row, ctx) {
2904
3106
  case 'WEEK': return Math.floor(ms / (86400000 * 7));
2905
3107
  case 'MONTH': return months;
2906
3108
  case 'QUARTER': return Math.floor(months / 3);
2907
- case 'YEAR': return a.getFullYear() - b.getFullYear();
3109
+ case 'YEAR': return Math.floor(months / 12);
2908
3110
  default: return null;
2909
3111
  }
2910
3112
  }
@@ -2995,6 +3197,30 @@ function applyScalarFunction(fnNode, row, ctx) {
2995
3197
  case 'LTRIM': return args[0] == null ? null : String(args[0]).replace(/^\s+/, '');
2996
3198
  case 'RTRIM': return args[0] == null ? null : String(args[0]).replace(/\s+$/, '');
2997
3199
  case 'ABS': return args[0] == null ? null : Math.abs(args[0]);
3200
+ case 'SIGN': { // MySQL:正数 1 / 0 → 0 / 负数 -1;NULL → NULL
3201
+ const v = numericIfNumeric(args[0]);
3202
+ if (v === null) return args[0] == null ? null : 0;
3203
+ return v > 0 ? 1 : (v < 0 ? -1 : 0);
3204
+ }
3205
+ case 'TRUNCATE': { // MySQL:直接截断,不四舍五入;NULL → NULL
3206
+ const x = numericIfNumeric(args[0]);
3207
+ if (x === null) return args[0] == null ? null : null;
3208
+ const digits = args[1] === undefined || args[1] === null ? 0 : Math.trunc(Number(args[1])) || 0;
3209
+ const factor = Math.pow(10, digits);
3210
+ if (!Number.isFinite(factor)) return x;
3211
+ return Math.trunc(x * factor) / factor;
3212
+ }
3213
+ case 'SUBSTRING_INDEX': { // MySQL:按分隔符切,返回左边 count 段(count<0 从右数)
3214
+ const s = args[0] == null ? null : String(args[0]);
3215
+ const d = args[1] == null ? null : String(args[1]);
3216
+ if (s === null || d === null || d === '') return null;
3217
+ const n = Math.trunc(Number(args[2]));
3218
+ if (!Number.isFinite(n)) return null;
3219
+ if (n === 0) return '';
3220
+ const parts = s.split(d);
3221
+ if (n > 0) return parts.slice(0, n).join(d);
3222
+ return parts.slice(n).join(d); // n<0:取末尾 |n| 段
3223
+ }
2998
3224
  case 'ROUND': {
2999
3225
  // MySQL 语义:四舍五入「半远离零」(Math.round 是半向 +∞,对负数会错,如 -3.5→-3)
3000
3226
  // 位数 D 可为负(向小数点左侧舍入);非数值/非法输入返回 null,绝不抛 TypeError/RangeError。
@@ -3974,7 +4200,10 @@ class SQLExecutor {
3974
4200
  }
3975
4201
  if (typeof statement.limit === 'number') candidates = candidates.slice(0, statement.limit);
3976
4202
  for (const row of candidates) {
3977
- const id = this._rowPkId(row, pkCols);
4203
+ // 无主键表没有稳定行标识:_rowPkId 会回退到 row.id(业务列名),
4204
+ // 而存储层的 _resolveId 把 id 当 1-based 行号 —— 两者语义不一致,
4205
+ // DELETE ... WHERE id IN (3,5) 会删错行。此时直接传行对象,存储层按引用删。
4206
+ const id = pkCols.length > 0 ? this._rowPkId(row, pkCols) : row;
3978
4207
  if (id !== undefined) { ids.push(id); removedRows.push(row); }
3979
4208
  }
3980
4209
  if (ids.length > 0) {
@@ -4259,9 +4488,48 @@ class SQLExecutor {
4259
4488
  return row ? row.id : undefined;
4260
4489
  }
4261
4490
 
4262
- async _readTable(table) {
4491
+ /**
4492
+ * 从 WHERE 表达式里提取「可直接下推给存储层」的等值条件。
4493
+ *
4494
+ * 此前 _readTable 一律 `find(table, {}, ...)` 拉全表回内存再逐行 evaluateExpr,
4495
+ * WHERE 完全没有下推 —— 2 万行表上 `WHERE id = ?` 也要扫 2 万行(约 88ms/次),
4496
+ * 而主键索引本可以做到微秒级。
4497
+ *
4498
+ * 只认最安全的一类:column = <字面量>(含 AND 连接)。
4499
+ * 其它条件(范围、OR、函数、IN、子查询)一律不下推,交回内存过滤,
4500
+ * 避免为了快而改错结果。
4501
+ */
4502
+ _pushdownEqualities(statement) {
4503
+ const out = {};
4504
+ if (!statement || !statement.where || typeof statement.where !== 'object') return out;
4505
+ const visit = (node) => {
4506
+ if (!node || typeof node !== 'object') return;
4507
+ if (node.type === 'and') { visit(node.left); visit(node.right); return; }
4508
+ if (node.type !== 'compare' || node.op !== '=') return;
4509
+ const isCol = n => n && n.type === 'column' && typeof n.name === 'string';
4510
+ const isVal = n => n && n.type === 'value' && n.value !== null && n.value !== undefined;
4511
+ let col = null, val = null;
4512
+ if (isCol(node.left) && isVal(node.right)) { col = node.left.name; val = node.right.value; }
4513
+ else if (isCol(node.right) && isVal(node.left)) { col = node.right.name; val = node.left.value; }
4514
+ if (col === null) return;
4515
+ const key = col.includes('.') ? col.slice(col.lastIndexOf('.') + 1) : col;
4516
+ // 同一列出现多个等值条件(如 id=1 AND id=2)时不推,交给内存求值
4517
+ if (Object.prototype.hasOwnProperty.call(out, key)) delete out[key];
4518
+ else out[key] = val;
4519
+ };
4520
+ visit(statement.where);
4521
+ return out;
4522
+ }
4523
+
4524
+ async _readTable(table, statement) {
4263
4525
  const schema = await this._getSchema(table);
4264
- const rows = (await this.engine.find(table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
4526
+ // 有可下推的等值条件时交给存储层走索引;没有则维持原来的全表读取
4527
+ const filter = statement ? this._pushdownEqualities(statement) : {};
4528
+ const useIndex = Object.keys(filter).length > 0 &&
4529
+ (typeof this.engine.exists === 'function' || typeof this.engine.find === 'function');
4530
+ const rows = useIndex
4531
+ ? (await this.engine.find(table, filter, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema))
4532
+ : (await this.engine.find(table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
4265
4533
  return { schema, rows };
4266
4534
  }
4267
4535
 
@@ -4622,6 +4890,17 @@ class SQLExecutor {
4622
4890
  delete node.column;
4623
4891
  return;
4624
4892
  }
4893
+ // 投影分支构造的聚合节点形如 { type: 'SUM', column, distinct }(type 就是函数名),
4894
+ // 与 parseAggregateCall 产出的 { type:'aggregate', fn } 并存。
4895
+ // 两种都要替换,否则 SUM(v)/COUNT(*) 整式求值为 null。
4896
+ if (typeof node.type === 'string' && AGG_FUNCS.has(node.type) &&
4897
+ (node.column !== undefined || node.over === undefined)) {
4898
+ const fn = node.type;
4899
+ node.type = 'value';
4900
+ node.value = this._aggValue(group, fn, node.column, node.distinct, node.separator, node.orderBy);
4901
+ delete node.column;
4902
+ return;
4903
+ }
4625
4904
  for (const k of Object.keys(node)) {
4626
4905
  if (node[k] && typeof node[k] === 'object') this._replaceAggregates(node[k], group);
4627
4906
  }
@@ -5133,7 +5412,7 @@ class SQLExecutor {
5133
5412
  const res = await this.executeSelect(firstItem.subquery);
5134
5413
  rowsAll = { rows: this._subQueryRows(res), schema: null, columns: res.columns };
5135
5414
  } else {
5136
- rowsAll = await this._readTable(firstItem.table);
5415
+ rowsAll = await this._readTable(firstItem.table, statement);
5137
5416
  }
5138
5417
  schema = rowsAll.schema;
5139
5418
  const firstPrefix = firstItem.alias || firstItem.table;
@@ -5142,7 +5421,7 @@ class SQLExecutor {
5142
5421
  for (const j of statement.from.joins) {
5143
5422
  const rightRes = j.item.subquery
5144
5423
  ? await this.executeSelect(j.item.subquery)
5145
- : await this._readTable(j.item.table);
5424
+ : await this._readTable(j.item.table, statement);
5146
5425
  const rightPrefix = j.item.alias || j.item.table;
5147
5426
  const rightRows = (j.item.subquery ? this._subQueryRows(rightRes) : rightRes.rows).map(r => this._prefixRow(r, rightPrefix));
5148
5427
 
Binary file
@@ -60,7 +60,14 @@ impl HybridEngine {
60
60
  }
61
61
 
62
62
  /// 打开磁盘存储目录(mode: 'hybrid' | 'disk' | 'memory';memory 或空目录忽略)
63
- /// 每次 open 重置引擎状态,避免多个库目录/实例间残留表数据
63
+ ///
64
+ /// 注意(6.3.4):这里仍然会重置引擎状态,但语义已改变 ——
65
+ /// 以前全进程只有一个引擎,`open(dirB)` 会把 `open(dirA)` 的表全部清空,
66
+ /// 导致同进程无法持有两个独立数据库。现在每个 JS `Database` 实例有独立的
67
+ /// 引擎句柄,`open()` 只重置自己那一份。
68
+ ///
69
+ /// 但对**同一个句柄**重复 open 不同目录仍会丢弃上一个目录的内存表,
70
+ /// 因此调用方(native_client.js)应为每个 Database 分配独立句柄。
64
71
  pub fn open(&mut self, dir: &str, mode: &str) -> Result<(), String> {
65
72
  self.mem.borrow_mut().clear();
66
73
  self.meta.borrow_mut().clear();