jsql-neo 6.5.28 → 6.5.29

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **One engine to rule them all** — a Rust-powered embedded database that speaks your language:
4
4
  > MySQL, PostgreSQL, MongoDB, Redis, SQL, TypeScript, **and the browser**.
5
5
 
6
- > **v6.5.28** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
6
+ > **v6.5.29** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
7
7
 
8
8
  ![Engines](https://img.shields.io/badge/engines-Native%20%7C%20WASM%20%7C%20Pure%20JS-7ee787)
9
9
  ![MySQL](https://img.shields.io/badge/protocol-MySQL%20compatible-1f6feb)
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSQL-NEO v6.5.28 — Rust-Powered Embedded Database (WASM + HTTP)
2
+ * JSQL-NEO v6.5.29 — Rust-Powered Embedded Database (WASM + HTTP)
3
3
  *
4
4
  * @example
5
5
  * const jsql = require('jsql-neo');
package/lib/ast.js CHANGED
@@ -128,40 +128,47 @@ function transform(ast, fn) {
128
128
  return visit(ast);
129
129
  }
130
130
 
131
- /** 原地改写:fn(node) 返回值非 undefined 时替换原节点。 */
131
+ /**
132
+ * 原地改写:fn(node) 返回值非 undefined 时替换原节点。
133
+ *
134
+ * 根节点同样可替换(#58):此前 visit 只在 `parent && key` 时才调用 fn 并回写,
135
+ * 根节点 parent===null 一律被跳过,于是 rewrite 无法替换整棵树,而 transform 可以。
136
+ * 现在数组容器本身作为父级传入(数组元素按 index 回写),根节点替换通过
137
+ * 返回值交回调用方。
138
+ */
132
139
  function rewrite(ast, fn) {
133
140
  const seen = new Set();
141
+ let root = ast;
142
+
143
+ const assign = (parent, key, value) => {
144
+ if (parent === null || parent === undefined) { root = value; return; }
145
+ parent[key] = value;
146
+ };
134
147
 
135
- const visit = (value, parent, key, index) => {
148
+ const visit = (value, parent, key) => {
136
149
  if (value === null || typeof value !== 'object') {
137
- if (parent && key) {
138
- const r = fn(value);
139
- if (r !== undefined) {
140
- if (index === undefined) parent[key] = r; else parent[key][index] = r;
141
- }
142
- }
150
+ const r = fn(value);
151
+ if (r !== undefined) assign(parent, key, r);
143
152
  return;
144
153
  }
145
154
  if (seen.has(value)) return;
146
155
  seen.add(value);
147
156
 
148
157
  if (Array.isArray(value)) {
149
- for (let i = 0; i < value.length; i++) visit(value[i], parent, key, i);
158
+ for (let i = 0; i < value.length; i++) visit(value[i], value, i);
150
159
  return;
151
160
  }
152
161
 
153
162
  for (const k of childKeys(value)) visit(value[k], value, k);
154
163
 
155
- if (isNode(value) && parent && key) {
164
+ if (isNode(value)) {
156
165
  const r = fn(value);
157
- if (r !== undefined) {
158
- if (index === undefined) parent[key] = r; else parent[key][index] = r;
159
- }
166
+ if (r !== undefined) assign(parent, key, r);
160
167
  }
161
168
  };
162
169
 
163
170
  visit(ast, null, null);
164
- return ast;
171
+ return root;
165
172
  }
166
173
 
167
174
  /** 语句引用的所有表名(含 JOIN / 子查询)。 */
package/lib/btree.js CHANGED
@@ -296,31 +296,37 @@ class BTree {
296
296
  remove(key, rowIndex) {
297
297
  if (!this._root) return false;
298
298
  const removed = this._removeFromNode(this._root, key, rowIndex);
299
- // 删掉整个键(而非仅删某个 rowIndex)后,叶子的键域可能缩小,
300
- // 沿途的分隔键副本可能已指向不存在的键 —— 自顶向下重建一次。
301
- if (removed) {
299
+ // 返回值区分三种情况:
300
+ // 0 → 什么都没删(key 或 rowIndex 不存在)
301
+ // 1 / 'key'→ 整个键被删掉(该 key 的最后一个 rowIndex)
302
+ // 2 / 'row'→ 只删掉该 key 下的某一行,键仍在(#53)
303
+ // size 统计的是 distinct keys,所以只有「键消失」时才递减。
304
+ if (removed === 2) {
305
+ // 键仍存在,size 不变;分隔键也无需重建
306
+ } else if (removed) {
302
307
  this._size--;
303
308
  if (!this._root.leaf) this._fixSeparators(this._root);
304
309
  }
305
310
  if (this._root.keys.length === 0 && !this._root.leaf && this._root.children.length > 0) {
306
311
  this._root = this._root.children[0];
307
312
  }
308
- return removed;
313
+ // 对外契约是布尔值:删掉了行就是 true(此前重复 key 场景错误地返回 false)
314
+ return !!removed;
309
315
  }
310
316
 
311
317
  _removeFromNode(node, key, rowIndex) {
312
318
  if (node.leaf) {
313
319
  const i = this._lowerBound(node.keys, key);
314
- if (i >= node.keys.length || node.keys[i] !== key) return false;
315
- if (!node.values[i].includes(rowIndex)) return false;
320
+ if (i >= node.keys.length || node.keys[i] !== key) return 0;
321
+ if (!node.values[i].includes(rowIndex)) return 0;
316
322
  const vals = node.values[i].filter(v => v !== rowIndex);
317
323
  if (vals.length === 0) {
318
324
  node.keys.splice(i, 1);
319
325
  node.values.splice(i, 1);
320
- return true;
326
+ return 1;
321
327
  }
322
328
  node.values[i] = vals;
323
- return false;
329
+ return 2;
324
330
  }
325
331
 
326
332
  let i = this._route(node, key);
package/lib/date-types.js CHANGED
@@ -35,6 +35,25 @@ function validateDateType(value, type, fieldName) {
35
35
 
36
36
  function pad2(n) { return String(n).padStart(2, '0'); }
37
37
 
38
+ /**
39
+ * 解析 ISO-8601 字符串尾部残余部分里的时区标记,返回「相对 UTC 的分钟偏移」。
40
+ * 输入形如 'Z'、'z'、'+08:00'、'-0500'、'.500Z'、' +02:00'。
41
+ * 没有时区标记(或无法识别)返回 null —— 调用方按本地时间字面值处理。
42
+ */
43
+ function parseIsoTimezone(rest) {
44
+ let s = String(rest == null ? '' : rest).trim();
45
+ // 去掉小数秒
46
+ if (s.startsWith('.')) s = s.replace(/^\.\d+/, '');
47
+ if (s === 'Z' || s === 'z') return 0;
48
+ const m = s.match(/^([+-])(\d{2}):?(\d{2})$/);
49
+ if (!m) return null;
50
+ const hh = parseInt(m[2], 10);
51
+ const mm = parseInt(m[3], 10);
52
+ if (hh > 23 || mm > 59) return null; // 非法偏移:交给后续校验报错
53
+ const sign = m[1] === '-' ? -1 : 1;
54
+ return sign * (hh * 60 + mm);
55
+ }
56
+
38
57
  /**
39
58
  * 本地时区的 'YYYY-MM-DD'。
40
59
  * 不能用 toISOString().slice(0,10) —— 那是 UTC:东八区 2023-01-01 00:00 本地
@@ -115,7 +134,7 @@ function validateDateTime(value, fieldName) {
115
134
  return toLocalDateTimeString(new Date(value * 1000));
116
135
  }
117
136
  if (typeof value === 'string') {
118
- // ISO format: 2024-01-15T10:30:00.000Z(带时区标记,按字面取日期+时间)
137
+ // ISO format: 2024-01-15T10:30:00.000Z / +08:00(可能带时区标记)
119
138
  const isoMatch = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
120
139
  if (isoMatch) {
121
140
  // P2-35: 验证日期和时间有效性
@@ -129,6 +148,15 @@ function validateDateTime(value, fieldName) {
129
148
  const dt = new Date(y, mo - 1, d);
130
149
  if (dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d &&
131
150
  h >= 0 && h < 24 && mi >= 0 && mi < 60 && s >= 0 && s < 60) {
151
+ // #50:带 Z / ±HH:MM 时区标记时必须换算到本地时区。
152
+ // 此前无条件按字面值存储,'2024-01-15T10:30:00Z'(UTC)在 UTC+8
153
+ // 会被存成本地 10:30,整体偏 8 小时。
154
+ const tz = parseIsoTimezone(value.slice(isoMatch[0].length));
155
+ if (tz !== null) {
156
+ const ms = Date.UTC(y, mo - 1, d, h, mi, s) - tz * 60000;
157
+ return toLocalDateTimeString(new Date(ms));
158
+ }
159
+ // 无时区标记:按本地时间字面值(MySQL 语义)
132
160
  return datePart + ' ' + timePart;
133
161
  }
134
162
  }
package/lib/query.js CHANGED
@@ -6,6 +6,36 @@
6
6
 
7
7
  const { createError } = require('./errors');
8
8
 
9
+ /**
10
+ * 稳定序列化(#55):把查询参数变成可比较的缓存键。
11
+ * 普通对象的键排序,函数/RegExp/BigInt/Date 保留可辨识信息,循环引用截断。
12
+ */
13
+ function stableKey(value, seen) {
14
+ seen = seen || new Set();
15
+ if (value === null) return 'null';
16
+ const t = typeof value;
17
+ if (t === 'bigint') return value.toString() + 'n';
18
+ if (t === 'number') return Object.is(value, -0) ? '-0' : String(value);
19
+ if (t === 'string') return JSON.stringify(value);
20
+ if (t === 'boolean') return String(value);
21
+ if (t === 'undefined') return 'undefined';
22
+ if (t === 'function') return 'fn:' + String(value);
23
+ if (t === 'symbol') return 'sym:' + String(value);
24
+ if (value instanceof RegExp) return 're:' + value.source + '/' + value.flags;
25
+ if (value instanceof Date) return 'date:' + value.getTime();
26
+ if (seen.has(value)) return '[circular]';
27
+ seen.add(value);
28
+ let out;
29
+ if (Array.isArray(value)) {
30
+ out = '[' + value.map(v => stableKey(v, seen)).join(',') + ']';
31
+ } else {
32
+ out = '{' + Object.keys(value).sort()
33
+ .map(k => JSON.stringify(k) + ':' + stableKey(value[k], seen)).join(',') + '}';
34
+ }
35
+ seen.delete(value);
36
+ return out;
37
+ }
38
+
9
39
  class Query {
10
40
  constructor(table, conditions = {}) {
11
41
  this._table = table;
@@ -210,12 +240,46 @@ class Query {
210
240
  // 缓存
211
241
  // ============================================================
212
242
 
243
+ /**
244
+ * 缓存键(#55):必须覆盖所有影响结果的参数。
245
+ * 旧实现只按 TTL 判断,同一 Query 实例换 where/orderBy/limit/offset 后
246
+ * 仍命中旧缓存,返回过期结果。
247
+ */
248
+ _cacheKey(depth = 0) {
249
+ if (depth > 5) return '[deep]';
250
+ const joins = this._joins.map(j => ({
251
+ type: j.type,
252
+ table: j.table && (j.table._name || j.table.name) || j.table,
253
+ localField: j.localField,
254
+ foreignField: j.foreignField,
255
+ as: j.as,
256
+ }));
257
+ return stableKey({
258
+ table: this._table && this._table._name,
259
+ conditions: this._conditions,
260
+ orderBy: this._orderBy,
261
+ limit: this._limitCount,
262
+ offset: this._offsetCount,
263
+ select: this._selectFields,
264
+ groupBy: this._groupBy,
265
+ having: this._having,
266
+ distinct: this._distinct,
267
+ distinctField: this._distinctField,
268
+ joins,
269
+ union: this._union ? this._union._cacheKey(depth + 1) : null,
270
+ caseExpr: this._caseExpr,
271
+ caseAs: this._caseAs,
272
+ window: this._window,
273
+ forUpdate: this._forUpdate,
274
+ });
275
+ }
276
+
213
277
  /**
214
278
  * 缓存查询结果
215
279
  * @param {number} ttlMs - 缓存有效期(毫秒),默认 60000
216
280
  */
217
281
  cache(ttlMs = 60000) {
218
- this._cache = { ttl: ttlMs, result: null, timestamp: 0 };
282
+ this._cache = { ttl: ttlMs, result: null, timestamp: 0, key: null };
219
283
  return this;
220
284
  }
221
285
 
@@ -227,8 +291,11 @@ class Query {
227
291
  const startTime = Date.now();
228
292
  if (this._explain) return this._getExplain();
229
293
 
294
+ // 缓存键要包含 where/orderBy/limit/offset 等全部参数(#55)
295
+ const cacheKey = this._cache ? this._cacheKey() : null;
230
296
  // 缓存命中: 返回每行的浅拷贝,与未命中路径一致,避免调用方修改污染缓存
231
- if (this._cache && this._cache.result && (Date.now() - this._cache.timestamp < this._cache.ttl)) {
297
+ if (this._cache && this._cache.result && this._cache.key === cacheKey &&
298
+ (Date.now() - this._cache.timestamp < this._cache.ttl)) {
232
299
  // #37:命中返回副本,而不是内部数组引用——调用方对返回值做变更
233
300
  // (push/splice/改字段)会污染缓存;未命中路径返回的是新数组,行为应一致。
234
301
  return this._cache.result.map(row => (row && typeof row === 'object' ? { ...row } : row));
@@ -334,6 +401,7 @@ class Query {
334
401
  if (this._cache) {
335
402
  this._cache.result = rows.map(row => (row && typeof row === 'object' ? { ...row } : row));
336
403
  this._cache.timestamp = Date.now();
404
+ this._cache.key = cacheKey;
337
405
  }
338
406
 
339
407
  this._table._db._logSlowQuery(`get ${this._table._name}`, Date.now() - startTime, rows.length);
@@ -428,6 +496,7 @@ class Query {
428
496
  if (this._cache) {
429
497
  this._cache.result = null;
430
498
  this._cache.timestamp = 0;
499
+ this._cache.key = null;
431
500
  }
432
501
  return this;
433
502
  }
package/lib/sql.js CHANGED
@@ -1533,7 +1533,25 @@ class Parser {
1533
1533
  this.expectKeyword('GROUP'); this.expectKeyword('BY');
1534
1534
  groupBy = [];
1535
1535
  while (true) {
1536
- groupBy.push(this.parseColumnRef());
1536
+ // 支持表达式分组:GROUP BY a%2、GROUP BY UPPER(name)(#51)。
1537
+ // 裸列名仍保留字符串形态,避免破坏既有别名解析与下游处理。
1538
+ let g;
1539
+ const save = this.pos;
1540
+ const GROUP_END = ['HAVING', 'ORDER', 'LIMIT', 'OFFSET', 'UNION', 'INTERSECT',
1541
+ 'EXCEPT', 'RETURNING', 'WITH'];
1542
+ const endsGroupItem = (tk) => {
1543
+ if (tk.type === 'eof') return true;
1544
+ if (tk.type === 'op' && (tk.value === ',' || tk.value === ';' || tk.value === ')')) return true;
1545
+ return GROUP_END.some(w => this.isWord(w));
1546
+ };
1547
+ try {
1548
+ g = this.parseColumnRef();
1549
+ if (!endsGroupItem(this.peek())) { this.pos = save; g = this.parseScalar(); }
1550
+ } catch (e) {
1551
+ this.pos = save;
1552
+ g = this.parseScalar();
1553
+ }
1554
+ groupBy.push(g);
1537
1555
  if (this.peek().type === 'op' && this.peek().value === ',') { this.next(); continue; }
1538
1556
  break;
1539
1557
  }
@@ -1631,6 +1649,17 @@ class Parser {
1631
1649
  else if (this.isKeyword('DISTINCT')) this.next();
1632
1650
  except = { all, select: this.parseSelect() };
1633
1651
  }
1652
+ // 标准 SQL:组合查询尾部的 ORDER BY / LIMIT / OFFSET 作用于整个结果。
1653
+ // 语法上它们会被解析进最右侧的 SELECT(#57),这里逐层提升到外层,
1654
+ // 否则执行层在 concat 后直接返回,尾部子句被静默丢弃。
1655
+ {
1656
+ const rhsSel = (union || intersect || except || {}).select;
1657
+ if (rhsSel) {
1658
+ if (rhsSel.orderBy) { orderBy = rhsSel.orderBy; rhsSel.orderBy = null; }
1659
+ if (rhsSel.limit !== null && rhsSel.limit !== undefined) { limit = rhsSel.limit; rhsSel.limit = null; }
1660
+ if (rhsSel.offset) { offset = rhsSel.offset; rhsSel.offset = 0; }
1661
+ }
1662
+ }
1634
1663
  // FETCH FIRST n ROWS ONLY(SQL 标准的 LIMIT 写法)
1635
1664
  if (this.isWord('FETCH')) {
1636
1665
  this.next();
@@ -4964,6 +4993,24 @@ function evaluateCaseVal(caseExpr, row, ctx) {
4964
4993
  return caseExpr.elseVal ? resolveOperand(caseExpr.elseVal, row, ctx) : null;
4965
4994
  }
4966
4995
 
4996
+ /**
4997
+ * 聚合参数的可读列名(#54):c.column 通常是 AST 节点({type:'column',name:'b'}),
4998
+ * 直接字符串拼理会得到 "SUM([object Object])"。
4999
+ */
5000
+ function columnLabel(col) {
5001
+ if (col === undefined || col === null) return '*';
5002
+ if (typeof col === 'string') return col;
5003
+ if (typeof col === 'number' || typeof col === 'bigint') return String(col);
5004
+ if (typeof col === 'object') {
5005
+ if (col.type === 'column' && col.name !== undefined) return String(col.name);
5006
+ if (col.type === 'star' || col.type === '*') return '*';
5007
+ if (col.type === 'value' || col.type === 'literal') return String(col.value);
5008
+ if (col.type === 'tuple' && Array.isArray(col.items)) return col.items.map(columnLabel).join(', ');
5009
+ try { return scalarName(col); } catch (e) { return 'expr'; }
5010
+ }
5011
+ return String(col);
5012
+ }
5013
+
4967
5014
  function scalarName(node) {
4968
5015
  if (!node) return 'expr';
4969
5016
  switch (node.type) {
@@ -4976,7 +5023,7 @@ function scalarName(node) {
4976
5023
  case 'literal': return String(node.value);
4977
5024
  case 'arith': return scalarName(node.left) + ' ' + node.op + ' ' + scalarName(node.right);
4978
5025
  case 'case': return 'CASE';
4979
- case 'aggregate': return node.fn + '(' + (node.column || '*') + ')';
5026
+ case 'aggregate': return node.fn + '(' + columnLabel(node.column) + ')';
4980
5027
  default: return 'expr';
4981
5028
  }
4982
5029
  }
@@ -4989,7 +5036,7 @@ function scalarColumnName(c) {
4989
5036
  }
4990
5037
  if (c.alias) return c.alias;
4991
5038
  if (c.scalar) return scalarName(c.scalar);
4992
- if (c.aggregate) return c.alias || (c.aggregate === 'COUNT' ? 'COUNT(*)' : c.aggregate + '(' + c.column + ')');
5039
+ if (c.aggregate) return c.alias || c.aggregate + '(' + columnLabel(c.column) + ')';
4993
5040
  if (c.literal !== undefined) return String(c.literal);
4994
5041
  if (c.caseExpr) return 'CASE';
4995
5042
  if (c.expr === '*') return '*';
@@ -5050,7 +5097,16 @@ function scalarColumnValue(c, r, ctx) {
5050
5097
  }
5051
5098
 
5052
5099
  if (c.literal !== undefined) return c.literal;
5053
- if (c.caseExpr) return projectScalar(evaluateCaseVal(c.caseExpr, r, ctx));
5100
+ if (c.caseExpr) {
5101
+ // CASE WHEN 内嵌聚合(#52):先把聚合按当前分组替换成字面值,
5102
+ // 否则 resolveOperand 不认识 aggregate 节点,条件恒为假 → 恒走 ELSE。
5103
+ if (ctx && ctx._replaceAggregates && containsAggregateNode(c.caseExpr)) {
5104
+ const copy = JSON.parse(JSON.stringify(c.caseExpr));
5105
+ ctx._replaceAggregates(copy, ctx.group || [r]);
5106
+ return projectScalar(evaluateCaseVal(copy, r, ctx));
5107
+ }
5108
+ return projectScalar(evaluateCaseVal(c.caseExpr, r, ctx));
5109
+ }
5054
5110
  if (c.expr === '*') return r[Object.keys(r).find(k => !k.startsWith('_'))];
5055
5111
  return projectScalar(resolveOperand({ type: 'column', name: c.expr }, r, ctx));
5056
5112
  }
@@ -6524,13 +6580,42 @@ class SQLExecutor {
6524
6580
  return typeof agg.result === 'function' ? agg.result(state) : state;
6525
6581
  }
6526
6582
  }
6583
+ // 整数精确累加(#56):SUM/AVG 若全为整数,用 BigInt 累加,
6584
+ // 避免 >2^53 时 Number 浮点加法把 9007199254740993 + 9007199254740992
6585
+ // 舍入成 18014398509481984。结果在安全范围内仍返回 number。
6586
+ const exactIntSum = () => {
6587
+ let total = 0n;
6588
+ for (const v of values) {
6589
+ if (typeof v === 'bigint') { total += v; continue; }
6590
+ if (typeof v === 'number' && Number.isInteger(v)) { total += BigInt(v); continue; }
6591
+ if (typeof v === 'string' && /^[-+]?\d+$/.test(v.trim())) {
6592
+ try { total += BigInt(v.trim()); continue; } catch (e) { return undefined; }
6593
+ }
6594
+ return undefined;
6595
+ }
6596
+ return total;
6597
+ };
6598
+ const SAFE_MAX = BigInt(Number.MAX_SAFE_INTEGER);
6599
+ const SAFE_MIN = BigInt(Number.MIN_SAFE_INTEGER);
6527
6600
  if (fn === 'SUM') {
6528
6601
  // MySQL 语义:空集(或无任何非 NULL 值)的 SUM 为 NULL,而非 0。
6529
6602
  // AVG/MIN/MAX 已按此处理,此前只有 SUM 漏了。
6530
6603
  if (!values.length) return null;
6604
+ const exact = exactIntSum();
6605
+ if (exact !== undefined) {
6606
+ return (exact >= SAFE_MIN && exact <= SAFE_MAX) ? Number(exact) : exact;
6607
+ }
6531
6608
  return values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0);
6532
6609
  }
6533
- if (fn === 'AVG') return values.length ? values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0) / values.length : null;
6610
+ if (fn === 'AVG') {
6611
+ if (!values.length) return null;
6612
+ const exact = exactIntSum();
6613
+ if (exact !== undefined) {
6614
+ // 先精确求和,再除以行数;Number(total) 只在最后一步转换
6615
+ return Number(exact) / values.length;
6616
+ }
6617
+ return values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0) / values.length;
6618
+ }
6534
6619
  if (fn === 'MIN' || fn === 'MAX') {
6535
6620
  const vs = values.filter(v => v !== null && v !== undefined);
6536
6621
  if (!vs.length) return null;
@@ -7327,36 +7412,108 @@ class SQLExecutor {
7327
7412
  return statement;
7328
7413
  }
7329
7414
 
7415
+ /**
7416
+ * 组合查询(UNION/INTERSECT/EXCEPT)尾部的 ORDER BY / LIMIT / OFFSET(#57)。
7417
+ * 此时 rows 已是投影后的数组,排序键按左分支列名或 1-based 列序号解析。
7418
+ */
7419
+ _finalizeSetOp(rows, columns, statement) {
7420
+ let out = rows;
7421
+ const orderBy = statement.orderBy;
7422
+ if (Array.isArray(orderBy) && orderBy.length) {
7423
+ const idxOf = (name) => {
7424
+ const n = String(name).toLowerCase();
7425
+ for (let i = 0; i < columns.length; i++) {
7426
+ if (String(columns[i]).toLowerCase() === n) return i;
7427
+ }
7428
+ return -1;
7429
+ };
7430
+ const objOf = (r) => {
7431
+ if (r && !Array.isArray(r) && typeof r === 'object') return r;
7432
+ const o = {};
7433
+ for (let i = 0; i < columns.length; i++) o[columns[i]] = r ? r[i] : undefined;
7434
+ return o;
7435
+ };
7436
+ const keyed = out.map((r, i) => ({ r, i, o: objOf(r) }));
7437
+ const keyOf = (entry, o) => {
7438
+ const c = o.column;
7439
+ if (typeof c === 'number') {
7440
+ if (c >= 1 && c <= columns.length) return entry.r[c - 1];
7441
+ throw new Error(`ORDER BY position ${c} is out of range`);
7442
+ }
7443
+ if (typeof c === 'string' || (c && (c.type === 'column' || (c.type === 'value' && typeof c.value === 'number')))) {
7444
+ if (c && c.type === 'value') {
7445
+ const n = Number(c.value);
7446
+ if (n >= 1 && n <= columns.length) return entry.r[n - 1];
7447
+ throw new Error(`ORDER BY position ${n} is out of range`);
7448
+ }
7449
+ const name = typeof c === 'string' ? c : c.name;
7450
+ const idx = idxOf(name);
7451
+ if (idx === -1) throw new Error(`no such column: ${name}`);
7452
+ return entry.r[idx];
7453
+ }
7454
+ // 表达式排序:按列名构造对象后求值
7455
+ return resolveOperand(c, entry.o, this.ctx);
7456
+ };
7457
+ keyed.sort((a, b) => {
7458
+ for (const o of orderBy) {
7459
+ const av = keyOf(a, o);
7460
+ const bv = keyOf(b, o);
7461
+ if (av === bv) continue;
7462
+ const aNull = av === null || av === undefined;
7463
+ const bNull = bv === null || bv === undefined;
7464
+ if (aNull || bNull) {
7465
+ const r = aNull && bNull ? 0 : (aNull ? -1 : 1);
7466
+ return o.dir === 'desc' ? -r : r;
7467
+ }
7468
+ const d = typeof av === 'number' && typeof bv === 'number'
7469
+ ? av - bv : String(av).localeCompare(String(bv));
7470
+ if (d !== 0) return o.dir === 'desc' ? -d : d;
7471
+ }
7472
+ return a.i - b.i;
7473
+ });
7474
+ out = keyed.map(x => x.r);
7475
+ }
7476
+ if (statement.offset) out = out.slice(statement.offset);
7477
+ if (statement.limit !== null && statement.limit !== undefined) out = out.slice(0, statement.limit);
7478
+ return out;
7479
+ }
7480
+
7330
7481
  async executeSelect(statement) {
7331
7482
  // 视图:FROM 里的视图名展开成子查询
7332
7483
  if (this.engine && this.engine._views) this._expandViews(statement);
7333
7484
 
7485
+ // 集合运算:左分支不应用尾部 ORDER BY/LIMIT/OFFSET(它们属于整个组合结果)
7486
+ const setOpBase = { orderBy: null, limit: null, offset: 0, fetch: undefined };
7487
+
7334
7488
  // UNION 处理
7335
7489
  if (statement.union) {
7336
- const left = await this.executeSelect({ ...statement, union: null });
7490
+ const left = await this.executeSelect({ ...statement, ...setOpBase, union: null });
7337
7491
  const right = await this.executeSelect(statement.union.select);
7338
7492
  let rows = left.rows.concat(right.rows);
7339
7493
  if (!statement.union.all) rows = dedupeRows(rows);
7494
+ rows = this._finalizeSetOp(rows, left.columns, statement);
7340
7495
  return { ok: true, type: 'select', columns: left.columns, rows, raw: rows };
7341
7496
  }
7342
7497
 
7343
7498
  // INTERSECT:保留同时出现在右侧结果中的行(默认 DISTINCT,与 UNION 的处理保持一致)
7344
7499
  if (statement.intersect) {
7345
- const left = await this.executeSelect({ ...statement, intersect: null, except: null });
7500
+ const left = await this.executeSelect({ ...statement, ...setOpBase, intersect: null, except: null });
7346
7501
  const right = await this.executeSelect(statement.intersect.select);
7347
7502
  const seen = new Set(right.rows.map(r => JSON.stringify(r)));
7348
7503
  let out = left.rows.filter(r => seen.has(JSON.stringify(r)));
7349
7504
  if (!statement.intersect.all) out = dedupeRows(out);
7505
+ out = this._finalizeSetOp(out, left.columns, statement);
7350
7506
  return { ok: true, type: 'select', columns: left.columns, rows: out, raw: out };
7351
7507
  }
7352
7508
 
7353
7509
  // EXCEPT:去掉出现在右侧结果中的行(默认 DISTINCT)
7354
7510
  if (statement.except) {
7355
- const left = await this.executeSelect({ ...statement, intersect: null, except: null });
7511
+ const left = await this.executeSelect({ ...statement, ...setOpBase, intersect: null, except: null });
7356
7512
  const right = await this.executeSelect(statement.except.select);
7357
7513
  const seen = new Set(right.rows.map(r => JSON.stringify(r)));
7358
7514
  let out = left.rows.filter(r => !seen.has(JSON.stringify(r)));
7359
7515
  if (!statement.except.all) out = dedupeRows(out);
7516
+ out = this._finalizeSetOp(out, left.columns, statement);
7360
7517
  return { ok: true, type: 'select', columns: left.columns, rows: out, raw: out };
7361
7518
  }
7362
7519
 
@@ -7555,7 +7712,8 @@ class SQLExecutor {
7555
7712
  // 此前全用 resolveOperand({type:'column', name:g}) 按列名取,导致 GROUP BY b
7556
7713
  // 取 row['b'],而原始行里没有 b 列(b 是别名),全返回 undefined 并成一组。
7557
7714
  const aliases = {}; // alias名字 → true(存在性检查用)
7558
- const aliasToCol = {}; // alias名字 → 对应的表达式(用于从原始行取值)
7715
+ const aliasToCol = {}; // alias名字 → 对应的列名(用于从原始行取值)
7716
+ const aliasExpr = {}; // alias名字 → 对应的表达式 AST(GROUP BY 表达式别名,#51)
7559
7717
  if (statement.columns) {
7560
7718
  for (const c of statement.columns) {
7561
7719
  if (c.alias) {
@@ -7565,18 +7723,28 @@ class SQLExecutor {
7565
7723
  // expr 可能是字符串 'a'(列名)或对象 {type:'column', name:'a'}
7566
7724
  if (typeof src === 'string') aliasToCol[c.alias.toLowerCase()] = src;
7567
7725
  else if (src && src.type === 'column') aliasToCol[c.alias.toLowerCase()] = src.name;
7726
+ else if (src && typeof src === 'object') aliasExpr[c.alias.toLowerCase()] = src;
7568
7727
  }
7569
7728
  }
7570
7729
  }
7571
7730
  const groups = new Map();
7572
7731
  for (const row of rows) {
7573
7732
  const key = JSON.stringify(statement.groupBy.map(g => {
7733
+ // 表达式分组(#51):直接对原始行求值表达式
7734
+ if (g && typeof g === 'object') {
7735
+ const v = resolveOperand(g, row, this.ctx);
7736
+ return typeof v === 'bigint' ? v.toString() + 'n' : v;
7737
+ }
7574
7738
  const key = String(g).toLowerCase();
7575
7739
  if (aliases[key]) {
7576
7740
  // 优先用行中已有的 alias 值(投影后才有),没有则尝试从原始列取值
7577
7741
  if (row[key] !== undefined) return row[key];
7578
7742
  // alias → 原始列名 的映射(如 b → a),从原始行取该列的值
7579
7743
  if (aliasToCol[key]) return resolveOperand({ type: 'column', name: aliasToCol[key] }, row, this.ctx);
7744
+ if (aliasExpr[key]) {
7745
+ const v = resolveOperand(aliasExpr[key], row, this.ctx);
7746
+ return typeof v === 'bigint' ? v.toString() + 'n' : v;
7747
+ }
7580
7748
  return row[key]; // row[key] 为 undefined,但保持一致
7581
7749
  }
7582
7750
  return resolveOperand({ type: 'column', name: g }, row, this.ctx);
@@ -7647,7 +7815,14 @@ class SQLExecutor {
7647
7815
  return allKeys.map(k => r[k]);
7648
7816
  }
7649
7817
  if (c.scalar) return resolveOperand(c.scalar, r, this.ctx);
7650
- if (c.caseExpr) return evaluateCaseVal(c.caseExpr, r);
7818
+ if (c.caseExpr) {
7819
+ if (containsAggregateNode(c.caseExpr)) {
7820
+ const cc = JSON.parse(JSON.stringify(c.caseExpr));
7821
+ this._replaceAggregates(cc, r._group || [r]);
7822
+ return evaluateCaseVal(cc, r, this.ctx);
7823
+ }
7824
+ return evaluateCaseVal(c.caseExpr, r, this.ctx);
7825
+ }
7651
7826
  if (c.aggregate) return this._aggValue(r._group || [r], c.aggregate || 'COUNT', c.column, c.distinct, c.separator, c.orderBy);
7652
7827
  return resolveOperand({ type: 'column', name: c.expr }, r, this.ctx);
7653
7828
  }));
@@ -7663,7 +7838,8 @@ class SQLExecutor {
7663
7838
  // 无 GROUP BY 的整表聚合:顶层是聚合(AVG(x))、自定义聚合(total(x)),
7664
7839
  // 以及嵌套聚合(ROUND(AVG(x),1) —— 聚合藏在 scalar 的参数里,顶层 c.aggregate 为空)
7665
7840
  const hasNestedAgg = !statement.groupBy &&
7666
- statement.columns.some(c => c.scalar && containsAggregateNode(c.scalar));
7841
+ statement.columns.some(c => (c.scalar && containsAggregateNode(c.scalar)) ||
7842
+ (c.caseExpr && containsAggregateNode(c.caseExpr)));
7667
7843
  if (!statement.groupBy && (statement.columns.some(c => c.aggregate && !c.window) || hasCustomAgg || hasNestedAgg)) {
7668
7844
  const cols = statement.columns.map(c => scalarColumnName(c));
7669
7845
  const tableName2 = statement.from ? (statement.from.tables[0].table || null) : null;
@@ -7688,7 +7864,15 @@ class SQLExecutor {
7688
7864
  }
7689
7865
  return projectScalar(resolveOperand(c.scalar, rows[0] || {}, this.ctx));
7690
7866
  }
7691
- if (c.caseExpr) return projectScalar(resolveOperand(c.caseExpr, rows[0] || {}, this.ctx));
7867
+ if (c.caseExpr) {
7868
+ // CASE 内嵌聚合按整表求值替换(#52)
7869
+ if (containsAggregateNode(c.caseExpr)) {
7870
+ const nestedCase = JSON.parse(JSON.stringify(c.caseExpr));
7871
+ this._replaceAggregates(nestedCase, rows);
7872
+ return projectScalar(evaluateCaseVal(nestedCase, rows[0] || {}, this.ctx));
7873
+ }
7874
+ return projectScalar(evaluateCaseVal(c.caseExpr, rows[0] || {}, this.ctx));
7875
+ }
7692
7876
  if (c.expr !== null && c.expr !== '*') return rows[0] ? projectScalar(resolveOperand({ type: 'column', name: c.expr }, rows[0])) : null;
7693
7877
  return null;
7694
7878
  };
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "v6.5.29",
3
+ "sha256": "e39295e4bd4f2b705a8b686454c5f2bbe519261bc839810185775146acbadcde",
4
+ "packageVersion": "6.5.29",
5
+ "installedAt": "2026-11-09T04:35:25.960Z",
6
+ "platform": "linux",
7
+ "arch": "x64"
8
+ }
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.5.28",
4
- "description": "JSQL-NEO \u2014 Rust-powered embedded database with WASM, REST API, B-Tree indexes, WAL, crash recovery",
3
+ "version": "6.5.29",
4
+ "description": "JSQL-NEO — Rust-powered embedded database with WASM, REST API, B-Tree indexes, WAL, crash recovery",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
7
  "exports": {
@@ -50,12 +50,12 @@
50
50
  "scripts": {
51
51
  "postinstall": "node postinstall.js",
52
52
  "build:native": "node scripts/build-native.js",
53
- "test": "node test/smoke.js && node test/result-raw.test.js && node test/native-install.test.js && node test/native-cli.test.js && node test/finalize-bigint.test.js && node test/native.test.js && node test/json-functions.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/dml-constraints.test.js && node test/star-nag.test.js && node test/plugin-xql.test.js && node test/bug-regression-2.test.js && node test/issue-34-35-36.test.js && node test/issue-g4.test.js && node test/issue-38.test.js && node test/issue-39.test.js && node test/issue-40.test.js && node test/issue-44.test.js && node test/issue-45.test.js && node test/issue-46.test.js && node test/issue-47.test.js && node test/issue-48.test.js",
53
+ "test": "node test/smoke.js && node test/result-raw.test.js && node test/native-install.test.js && node test/native-cli.test.js && node test/finalize-bigint.test.js && node test/native.test.js && node test/json-functions.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/dml-constraints.test.js && node test/star-nag.test.js && node test/plugin-xql.test.js && node test/bug-regression-2.test.js && node test/issue-34-35-36.test.js && node test/issue-g4.test.js && node test/issue-38.test.js && node test/issue-39.test.js && node test/issue-40.test.js && node test/issue-44.test.js && node test/issue-45.test.js && node test/issue-46.test.js && node test/issue-47.test.js && node test/issue-48.test.js && node test/issue-50.test.js && node test/issue-51.test.js && node test/issue-52.test.js && node test/issue-53.test.js && node test/issue-54.test.js && node test/issue-55.test.js && node test/issue-56.test.js && node test/issue-57.test.js && node test/issue-58.test.js",
54
54
  "test:parser": "node test/sql-parser.test.js",
55
55
  "test:btree": "node test/btree.test.js",
56
56
  "test:native": "node test/native.test.js",
57
57
  "test:regress": "node test/regress-5.1.0.js",
58
- "test:all": "node test/smoke.js && node test/result-raw.test.js && node test/native-install.test.js && node test/native-cli.test.js && node test/finalize-bigint.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js && node test/savepoint.test.js && node test/alter-table.test.js && node test/null-semantics.test.js && node test/identifier-keywords.test.js && node test/issue-34-35-36.test.js && node test/sql-semantics.test.js && node test/extra-coverage.test.js && node test/filter-compile.test.js && node test/recursive-cte.test.js && node test/index-ddl.test.js && node test/mongo-group.test.js && node test/multiprotocol.test.js && node test/issue-g4.test.js && node test/issue-38.test.js && node test/issue-39.test.js && node test/issue-40.test.js && node test/issue-44.test.js && node test/issue-45.test.js && node test/issue-46.test.js && node test/issue-47.test.js && node test/issue-48.test.js",
58
+ "test:all": "node test/smoke.js && node test/result-raw.test.js && node test/native-install.test.js && node test/native-cli.test.js && node test/finalize-bigint.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js && node test/savepoint.test.js && node test/alter-table.test.js && node test/null-semantics.test.js && node test/identifier-keywords.test.js && node test/issue-34-35-36.test.js && node test/sql-semantics.test.js && node test/extra-coverage.test.js && node test/filter-compile.test.js && node test/recursive-cte.test.js && node test/index-ddl.test.js && node test/mongo-group.test.js && node test/multiprotocol.test.js && node test/issue-g4.test.js && node test/issue-38.test.js && node test/issue-39.test.js && node test/issue-40.test.js && node test/issue-44.test.js && node test/issue-45.test.js && node test/issue-46.test.js && node test/issue-47.test.js && node test/issue-48.test.js && node test/issue-50.test.js && node test/issue-51.test.js && node test/issue-52.test.js && node test/issue-53.test.js && node test/issue-54.test.js && node test/issue-55.test.js && node test/issue-56.test.js && node test/issue-57.test.js && node test/issue-58.test.js",
59
59
  "test:coverage": "c8 --all --include='lib/**' --exclude='lib/web_ui.js' --reporter=text --reporter=lcov node test/coverage.js",
60
60
  "test:orms": "node examples/orms/run-all.js",
61
61
  "test:dml": "node test/dml-constraints.test.js",
@@ -103,4 +103,4 @@
103
103
  "devDependencies": {
104
104
  "c8": "^10.1.2"
105
105
  }
106
- }
106
+ }
@@ -0,0 +1,83 @@
1
+ /*
2
+ * Issue #50 — DATETIME 列把 ISO-8601 的 Z / 时区偏移当成本地时间原样存储。
3
+ *
4
+ * '2024-01-15T10:30:00Z' 表示 UTC 10:30,在 UTC+8 环境落库应为 '2024-01-15 18:30:00',
5
+ * 而不是丢掉 Z 变成 10:30:00。不带时区标记的写法仍按本地时间字面值处理(MySQL 语义)。
6
+ *
7
+ * 注意:断言按运行环境时区动态计算期望值,CI 在 UTC 下同样成立。
8
+ */
9
+ const path = require('path');
10
+ const ROOT = path.join(__dirname, '..');
11
+ const { Database } = require(path.join(ROOT, 'index.js'));
12
+ const { executeSQL } = require(path.join(ROOT, 'lib', 'sql.js'));
13
+
14
+ let pass = 0, fail = 0;
15
+ function ok(name, cond, extra) {
16
+ if (cond) { pass++; console.log('[OK] ', name); }
17
+ else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 300) : ''); }
18
+ }
19
+
20
+ const pad2 = (n) => String(n).padStart(2, '0');
21
+ function localStr(utcMs) {
22
+ const d = new Date(utcMs);
23
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` +
24
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
25
+ }
26
+
27
+ (async () => {
28
+ const db = new Database(':memory:');
29
+ const q = (sql) => executeSQL(db, sql, { safety: false });
30
+ await q('CREATE TABLE t (id INTEGER PRIMARY KEY, dt DATETIME)');
31
+
32
+ // 1) 带 Z:UTC → 本地
33
+ await q("INSERT INTO t (id, dt) VALUES (1, '2024-01-15T10:30:00Z')");
34
+ let r = await q('SELECT dt FROM t WHERE id = 1');
35
+ const expectZ = localStr(Date.parse('2024-01-15T10:30:00Z'));
36
+ ok('#50 ISO Z 按 UTC 转本地时区', r.rows[0][0] === expectZ, { got: r.rows[0][0], want: expectZ });
37
+ if (new Date().getTimezoneOffset() === -480) {
38
+ ok('#50 UTC+8 下 Z 时间 +8 小时', r.rows[0][0] === '2024-01-15 18:30:00', r.rows[0][0]);
39
+ }
40
+
41
+ // 2) 带正偏移 +02:00
42
+ await q("INSERT INTO t (id, dt) VALUES (2, '2024-01-15T10:30:00+02:00')");
43
+ r = await q('SELECT dt FROM t WHERE id = 2');
44
+ const expectPlus2 = localStr(Date.parse('2024-01-15T10:30:00+02:00'));
45
+ ok('#50 ISO +02:00 偏移正确换算', r.rows[0][0] === expectPlus2, { got: r.rows[0][0], want: expectPlus2 });
46
+
47
+ // 3) 带负偏移 -05:00
48
+ await q("INSERT INTO t (id, dt) VALUES (3, '2024-01-15T10:30:00-05:00')");
49
+ r = await q('SELECT dt FROM t WHERE id = 3');
50
+ const expectMinus5 = localStr(Date.parse('2024-01-15T10:30:00-05:00'));
51
+ ok('#50 ISO -05:00 偏移正确换算', r.rows[0][0] === expectMinus5, { got: r.rows[0][0], want: expectMinus5 });
52
+
53
+ // 4) 带毫秒 + Z
54
+ await q("INSERT INTO t (id, dt) VALUES (4, '2024-01-15T10:30:00.500Z')");
55
+ r = await q('SELECT dt FROM t WHERE id = 4');
56
+ ok('#50 带毫秒的 Z 同样换算(毫秒截断)', r.rows[0][0] === expectZ, r.rows[0][0]);
57
+
58
+ // 5) 不带时区标记:仍是字面值(本地时间),不能被当成 UTC
59
+ await q("INSERT INTO t (id, dt) VALUES (5, '2024-01-15T10:30:00')");
60
+ await q("INSERT INTO t (id, dt) VALUES (6, '2024-01-15 10:30:00')");
61
+ r = await q('SELECT dt FROM t WHERE id IN (5,6)');
62
+ ok('#50 无时区的 ISO 串按字面值保存', r.rows[0][0] === '2024-01-15 10:30:00', r.rows[0][0]);
63
+ ok('#50 MySQL 格式原样保留', r.rows[1][0] === '2024-01-15 10:30:00', r.rows[1][0]);
64
+
65
+ // 6) 纯日期列不受影响
66
+ await q('CREATE TABLE d (id INTEGER PRIMARY KEY, dd DATE)');
67
+ await q("INSERT INTO d (id, dd) VALUES (1, '2024-01-15')");
68
+ r = await q('SELECT dd FROM d');
69
+ ok('#50 DATE 列行为不变', r.rows[0][0] === '2024-01-15', r.rows[0][0]);
70
+
71
+ // 7) 直接调用 date-types 的规范化函数
72
+ const { validateDateType } = require(path.join(ROOT, 'lib', 'date-types.js'));
73
+ ok('#50 validateDateType datetime Z',
74
+ validateDateType('2024-01-15T10:30:00Z', 'datetime', 'dt') === expectZ,
75
+ validateDateType('2024-01-15T10:30:00Z', 'datetime', 'dt'));
76
+ ok('#50 validateDateType 非法日期仍报错',
77
+ (() => { try { validateDateType('2024-13-45T10:30:00Z', 'datetime', 'dt'); return false; } catch (e) { return true; } })());
78
+ })();
79
+
80
+ setTimeout(() => {
81
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
82
+ if (fail > 0) process.exit(1);
83
+ }, 10);
@@ -0,0 +1,56 @@
1
+ /*
2
+ * Issue #51 — GROUP BY <expression> 崩溃。
3
+ *
4
+ * GROUP BY 解析此前只接受裸列引用(parseColumnRef),遇到 a%2、b+1 这类表达式
5
+ * 会在 GROUP BY 之后报 "Unexpected token ... after statement"。
6
+ * 修复后应支持任意标量表达式分组,且结果正确。
7
+ */
8
+ const path = require('path');
9
+ const ROOT = path.join(__dirname, '..');
10
+ const { Database } = require(path.join(ROOT, 'index.js'));
11
+ const { executeSQL } = require(path.join(ROOT, 'lib', 'sql.js'));
12
+
13
+ let pass = 0, fail = 0;
14
+ function ok(name, cond, extra) {
15
+ if (cond) { pass++; console.log('[OK] ', name); }
16
+ else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 400) : ''); }
17
+ }
18
+ const norm = (rows) => rows.map(r => r.map(v => (typeof v === 'bigint' ? Number(v) : v))).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
19
+
20
+ (async () => {
21
+ const db = new Database(':memory:');
22
+ const q = (sql) => executeSQL(db, sql, { safety: false });
23
+ await q('CREATE TABLE g (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER)');
24
+ await q('INSERT INTO g VALUES (1,1,10),(2,2,20),(3,3,30),(4,4,40)');
25
+
26
+ let r = await q('SELECT a%2 AS p, COUNT(*) FROM g GROUP BY a%2');
27
+ ok('#51 GROUP BY a%2 不抛错且结果正确',
28
+ JSON.stringify(norm(r.rows)) === JSON.stringify([[0, 2], [1, 2]]), r.rows);
29
+
30
+ r = await q('SELECT a%2 AS p, COUNT(*) FROM g GROUP BY p');
31
+ ok('#51 GROUP BY 别名仍然可用',
32
+ JSON.stringify(norm(r.rows)) === JSON.stringify([[0, 2], [1, 2]]), r.rows);
33
+
34
+ r = await q('SELECT b+1 AS bp, COUNT(*) FROM g GROUP BY b+1');
35
+ ok('#51 GROUP BY 算术表达式 b+1',
36
+ JSON.stringify(norm(r.rows)) === JSON.stringify([[11, 1], [21, 1], [31, 1], [41, 1]]), r.rows);
37
+
38
+ r = await q("SELECT UPPER('x') AS u, COUNT(*) FROM g GROUP BY UPPER('x')");
39
+ ok('#51 GROUP BY 函数表达式', r.rows.length === 1 && Number(r.rows[0][1]) === 4, r.rows);
40
+
41
+ r = await q('SELECT a%2 AS p, SUM(b) AS s FROM g GROUP BY a%2 ORDER BY p');
42
+ ok('#51 表达式分组 + 聚合 + ORDER BY',
43
+ JSON.stringify(norm(r.rows)) === JSON.stringify([[0, 60], [1, 40]]), r.rows);
44
+
45
+ r = await q('SELECT a%2 AS p, COUNT(*) FROM g GROUP BY a%2 HAVING COUNT(*) > 2');
46
+ ok('#51 表达式分组 + HAVING', r.rows.length === 0, r.rows);
47
+
48
+ r = await q('SELECT a, COUNT(*) FROM g GROUP BY a');
49
+ ok('#51 朴素列分组未被破坏', r.rows.length === 4, r.rows);
50
+
51
+ r = await q('SELECT a, b, COUNT(*) FROM g GROUP BY a, b');
52
+ ok('#51 多列分组未被破坏', r.rows.length === 4, r.rows);
53
+
54
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
55
+ if (fail > 0) process.exit(1);
56
+ })();
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Issue #52 — CASE WHEN 内嵌聚合恒为 0。
3
+ *
4
+ * `SELECT CASE WHEN COUNT(*) > 2 THEN 1 ELSE 0 END FROM t` 中,
5
+ * 聚合节点没有按分组求值替换(resolveOperand 看不到 aggregate → null → 条件为假),
6
+ * 且无 GROUP BY 时整表聚合识别只检查 c.scalar,漏了 c.caseExpr,
7
+ * 于是输出逐行 0 而不是一行 1。
8
+ */
9
+ const path = require('path');
10
+ const ROOT = path.join(__dirname, '..');
11
+ const { Database } = require(path.join(ROOT, 'index.js'));
12
+ const { executeSQL } = require(path.join(ROOT, 'lib', 'sql.js'));
13
+
14
+ let pass = 0, fail = 0;
15
+ function ok(name, cond, extra) {
16
+ if (cond) { pass++; console.log('[OK] ', name); }
17
+ else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 400) : ''); }
18
+ }
19
+ const j = (rows) => JSON.stringify(rows, (k, v) => (typeof v === 'bigint' ? v.toString() + 'n' : v));
20
+
21
+ (async () => {
22
+ const db = new Database(':memory:');
23
+ const q = (sql) => executeSQL(db, sql, { safety: false });
24
+ await q('CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER, g TEXT)');
25
+ await q("INSERT INTO t VALUES (1,1,'a'),(2,2,'a'),(3,3,'b'),(4,4,'b')");
26
+
27
+ let r = await q('SELECT CASE WHEN COUNT(*)>2 THEN 1 ELSE 0 END FROM t');
28
+ ok('#52 整表聚合 CASE WHEN COUNT(*)>2 => [[1]]', j(r.rows) === j([[1]]), r.rows);
29
+
30
+ r = await q('SELECT CASE WHEN COUNT(*)>10 THEN 1 ELSE 0 END FROM t');
31
+ ok('#52 条件为假走 ELSE => [[0]]', j(r.rows) === j([[0]]), r.rows);
32
+
33
+ r = await q('SELECT CASE WHEN SUM(v)>6 THEN 1 ELSE 0 END AS f FROM t');
34
+ ok('#52 SUM 内嵌 CASE', j(r.rows) === j([[1]]), r.rows);
35
+
36
+ r = await q('SELECT CASE WHEN SUM(v)>100 THEN 1 ELSE 0 END AS f FROM t');
37
+ ok('#52 SUM 条件为假', j(r.rows) === j([[0]]), r.rows);
38
+
39
+ r = await q('SELECT CASE WHEN COUNT(*)>2 THEN COUNT(*) ELSE 0 END FROM t');
40
+ ok('#52 THEN 分支也是聚合 => [[4]]', j(r.rows) === j([[4]]), r.rows);
41
+
42
+ r = await q('SELECT CASE WHEN COUNT(*)>2 THEN 1 ELSE 0 END AS f FROM t GROUP BY g');
43
+ ok('#52 GROUP BY 路径每组计数=2,条件假', j(r.rows) === j([[0], [0]]), r.rows);
44
+
45
+ r = await q('SELECT CASE WHEN COUNT(*)>=2 THEN 1 ELSE 0 END AS f FROM t GROUP BY g');
46
+ ok('#52 GROUP BY 路径每组计数=2,条件真 => [[1],[1]]', j(r.rows) === j([[1], [1]]), r.rows);
47
+
48
+ r = await q('SELECT g, CASE WHEN SUM(v)>3 THEN 1 ELSE 0 END AS f FROM t GROUP BY g');
49
+ ok('#52 GROUP BY 每组 SUM', j(r.rows) === j([['a', 0], ['b', 1]]), r.rows);
50
+
51
+ r = await q('SELECT CASE WHEN MAX(v)>=4 THEN 1 ELSE 0 END FROM t');
52
+ ok('#52 MAX 内嵌 CASE', j(r.rows) === j([[1]]), r.rows);
53
+
54
+ r = await q('SELECT COUNT(*) AS c, CASE WHEN COUNT(*)>2 THEN 1 ELSE 0 END AS f FROM t');
55
+ ok('#52 普通聚合列与 CASE 聚合列共存', j(r.rows) === j([[4, 1]]), r.rows);
56
+
57
+ r = await q('SELECT CASE WHEN COUNT(*)>2 THEN 1 ELSE 0 END AS f FROM t HAVING CASE WHEN COUNT(*)>2 THEN 1 ELSE 0 END = 1');
58
+ ok('#52 HAVING 里的 CASE 聚合', j(r.rows) === j([[1]]), r.rows);
59
+
60
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
61
+ if (fail > 0) process.exit(1);
62
+ })();
@@ -0,0 +1,50 @@
1
+ /*
2
+ * Issue #53 — BTree.remove(key, rowIndex) 在「同一 key 挂着多行」时返回 false,
3
+ * 即使那一行确实被删掉了。返回契约错误,调用方(索引维护)会以为删除失败。
4
+ */
5
+ const path = require('path');
6
+ const BTree = require(path.join(__dirname, '..', 'lib', 'btree.js'));
7
+
8
+ let pass = 0, fail = 0;
9
+ function ok(name, cond, extra) {
10
+ if (cond) { pass++; console.log('[OK] ', name); }
11
+ else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 300) : ''); }
12
+ }
13
+
14
+ try {
15
+ // 最小复现
16
+ const t = new BTree(4);
17
+ t.insert(46, 17);
18
+ t.insert(46, 18);
19
+
20
+ const r = t.remove(46, 17);
21
+ ok('#53 删除重复 key 中的一行返回 true', r === true, r);
22
+ ok('#53 剩余行仍可查到', JSON.stringify(t.search(46)) === JSON.stringify([18]), t.search(46));
23
+ ok('#53 size 正确递减', t.size === 1, t.size);
24
+
25
+ // 再删剩下的那一行:key 整个消失
26
+ const r2 = t.remove(46, 18);
27
+ ok('#53 删掉最后一行返回 true', r2 === true, r2);
28
+ ok('#53 key 已不存在', JSON.stringify(t.search(46)) === JSON.stringify([]), t.search(46));
29
+ ok('#53 size 归零', t.size === 0, t.size);
30
+
31
+ // 删不存在的行仍返回 false
32
+ t.insert(7, 1);
33
+ ok('#53 行号不存在返回 false', t.remove(7, 99) === false);
34
+ ok('#53 不存在的 key 返回 false', t.remove(12345, 1) === false);
35
+ ok('#53 误删后数据完好', JSON.stringify(t.search(7)) === JSON.stringify([1]), t.search(7));
36
+
37
+ // 大量重复 key + 删除,条目不应丢失
38
+ const big = new BTree(4);
39
+ for (let i = 0; i < 40; i++) big.insert(5, i);
40
+ for (let i = 0; i < 20; i++) ok(`#53 重复 key 批量删除第${i}行返回 true`, big.remove(5, i) === true);
41
+ const left = big.search(5);
42
+ ok('#53 批量删除后剩余行完整', JSON.stringify(left) === JSON.stringify(Array.from({ length: 20 }, (_, i) => i + 20)), left);
43
+ ok('#53 批量删除后 size 按 distinct keys 计(1)', big.size === 1, big.size);
44
+ } catch (e) {
45
+ fail++;
46
+ console.log('[FAIL] unexpected exception:', (e && e.stack) || e);
47
+ }
48
+
49
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
50
+ if (fail > 0) process.exit(1);
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Issue #54 — 未起别名的聚合列列名是 "SUM([object Object])"。
3
+ *
4
+ * scalarColumnName 对 c.aggregate 直接做字符串拼接,而 c.column 是 AST 节点对象,
5
+ * 于是被 String() 成 "[object Object]"。应输出 "SUM(b)"。
6
+ */
7
+ const path = require('path');
8
+ const ROOT = path.join(__dirname, '..');
9
+ const { Database } = require(path.join(ROOT, 'index.js'));
10
+ const { executeSQL } = require(path.join(ROOT, 'lib', 'sql.js'));
11
+
12
+ let pass = 0, fail = 0;
13
+ function ok(name, cond, extra) {
14
+ if (cond) { pass++; console.log('[OK] ', name); }
15
+ else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 300) : ''); }
16
+ }
17
+
18
+ (async () => {
19
+ const db = new Database(':memory:');
20
+ const q = (sql) => executeSQL(db, sql, { safety: false });
21
+ await q('CREATE TABLE s (id INTEGER PRIMARY KEY, b INTEGER, c INTEGER, name TEXT)');
22
+ await q("INSERT INTO s VALUES (1,10,1,'a'),(2,20,2,'b'),(3,30,3,'c')");
23
+
24
+ let r = await q('SELECT SUM(b) FROM s');
25
+ ok('#54 SUM 列名为 SUM(b)', r.columns[0] === 'SUM(b)', r.columns);
26
+ ok('#54 SUM 值正确', r.rows[0][0] === 60, r.rows);
27
+
28
+ r = await q('SELECT AVG(b), MIN(b), MAX(b), COUNT(b), COUNT(*) FROM s');
29
+ ok('#54 AVG/MIN/MAX/COUNT 列名可读',
30
+ JSON.stringify(r.columns) === JSON.stringify(['AVG(b)', 'MIN(b)', 'MAX(b)', 'COUNT(b)', 'COUNT(*)']), r.columns);
31
+
32
+ r = await q('SELECT SUM(b+c) FROM s');
33
+ ok('#54 聚合参数是表达式时列名不含 [object Object]', !/\[object Object\]/.test(r.columns[0]), r.columns);
34
+ ok('#54 表达式聚合值正确(1+10+2+20+3+30=66)', r.rows[0][0] === 66, r.rows);
35
+
36
+ r = await q('SELECT SUM(b) AS total FROM s');
37
+ ok('#54 有别名时别名优先', r.columns[0] === 'total', r.columns);
38
+
39
+ r = await q('SELECT MAX(LENGTH(name)) FROM s');
40
+ ok('#54 函数参数聚合列名不含 [object Object]', !/\[object Object\]/.test(r.columns[0]), r.columns);
41
+
42
+ r = await q('SELECT SUM(b), COUNT(*) FROM s');
43
+ ok('#54 多列各自命名', JSON.stringify(r.columns) === JSON.stringify(['SUM(b)', 'COUNT(*)']), r.columns);
44
+
45
+ r = await q('SELECT c, SUM(b) FROM s GROUP BY c');
46
+ ok('#54 GROUP BY 路径同样正确', JSON.stringify(r.columns) === JSON.stringify(['c', 'SUM(b)']), r.columns);
47
+
48
+ // raw 对象的键名也要可读
49
+ ok('#54 raw 键名为 SUM(b)', Object.keys(r.raw[0]).includes('SUM(b)'), Object.keys(r.raw[0]));
50
+
51
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
52
+ if (fail > 0) process.exit(1);
53
+ })();
@@ -0,0 +1,86 @@
1
+ /*
2
+ * Issue #55 — Query.cache() 复用不同过滤条件时返回过期结果。
3
+ *
4
+ * 缓存只按 TTL 判断,key 里不含 where/orderBy/limit/offset 等可变参数,
5
+ * 于是同一个 Query 实例改了条件后仍命中旧缓存。
6
+ * 修复后缓存键必须覆盖查询参数:参数变化 → 失配 → 重新执行。
7
+ */
8
+ const path = require('path');
9
+ const ROOT = path.join(__dirname, '..');
10
+ const { Database } = require(path.join(ROOT, 'index.js'));
11
+ const { executeSQL } = require(path.join(ROOT, 'lib', 'sql.js'));
12
+
13
+ let pass = 0, fail = 0;
14
+ function ok(name, cond, extra) {
15
+ if (cond) { pass++; console.log('[OK] ', name); }
16
+ else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 400) : ''); }
17
+ }
18
+ const ids = (rows) => rows.map(r => Number(r.id));
19
+
20
+ (async () => {
21
+ const db = new Database(':memory:');
22
+ const q = (sql) => executeSQL(db, sql, { safety: false });
23
+ await q('CREATE TABLE c (id INTEGER PRIMARY KEY AUTOINCREMENT, g TEXT, n INTEGER)');
24
+ await q("INSERT INTO c (g, n) VALUES ('a',1),('b',2),('a',3),('b',4)");
25
+ const t = db.c;
26
+
27
+ // 核心复现
28
+ const qb = t.where({ g: 'a' }).cache(60000);
29
+ const a1 = qb.get();
30
+ ok('#55 首次 g=a => [1,3]', JSON.stringify(ids(a1)) === JSON.stringify([1, 3]), a1);
31
+ const b1 = qb.where({ g: 'b' }).get();
32
+ ok('#55 改成 g=b 后不得返回旧缓存 => [2,4]', JSON.stringify(ids(b1)) === JSON.stringify([2, 4]), b1);
33
+ const a2 = qb.where({ g: 'a' }).get();
34
+ ok('#55 切回 g=a => [1,3]', JSON.stringify(ids(a2)) === JSON.stringify([1, 3]), a2);
35
+
36
+ // 参数变化都必须失配
37
+ const qo = t.where({}).cache(60000);
38
+ const asc = qo.orderBy('g', 'asc').get();
39
+ ok('#55 orderBy g asc => [1,3,2,4]', JSON.stringify(ids(asc)) === JSON.stringify([1, 3, 2, 4]), asc);
40
+ // 追加 id DESC 后组内顺序应反转 => [3,1,4,2],若命中旧缓存则仍是 [1,3,2,4]
41
+ const desc = qo.orderBy('id', 'desc').get();
42
+ ok('#55 orderBy 变化不复用旧排序 => [3,1,4,2]',
43
+ JSON.stringify(ids(desc)) === JSON.stringify([3, 1, 4, 2]), desc);
44
+
45
+ const ql = t.where({}).cache(60000);
46
+ const lim1 = ql.limit(1).get();
47
+ ok('#55 limit=1 => 1 行', lim1.length === 1, lim1);
48
+ const lim2 = ql.limit(3).get();
49
+ ok('#55 limit 变化 => 3 行', lim2.length === 3, lim2);
50
+ const off = ql.offset(1).get();
51
+ ok('#55 offset 变化 => 3 行', off.length === 3, off);
52
+
53
+ // 真正相同参数仍要走缓存(否则等于禁用缓存)
54
+ const qs = t.where({ g: 'b' }).cache(60000);
55
+ const s1 = qs.get();
56
+ await q("UPDATE c SET n = 99 WHERE g = 'b'");
57
+ const s2 = qs.get();
58
+ ok('#55 相同参数再次 get 命中缓存(未看到 UPDATE)', JSON.stringify(ids(s2)) === JSON.stringify(ids(s1)) && s2[0].n !== 99, s2);
59
+ await q("UPDATE c SET n = 2 WHERE id = 2");
60
+ await q("UPDATE c SET n = 4 WHERE id = 4");
61
+
62
+ // invalidateCache 仍然有效
63
+ const qi = t.where({ g: 'a' }).cache(60000);
64
+ qi.get();
65
+ await q("UPDATE c SET n = 77 WHERE id = 1");
66
+ qi.invalidateCache();
67
+ const iv = qi.get();
68
+ ok('#55 invalidateCache 后重新执行', iv.find(r => Number(r.id) === 1).n === 77, iv);
69
+ await q("UPDATE c SET n = 1 WHERE id = 1");
70
+
71
+ // 值类型不同的条件不能共用缓存
72
+ const qv = t.where({ n: 2 }).cache(60000);
73
+ const v1 = qv.get();
74
+ ok('#55 where n=2', JSON.stringify(ids(v1)) === JSON.stringify([2]), v1);
75
+ const v2 = qv.where({ n: '2' }).get();
76
+ ok('#55 where n="2" 语义等值,结果一致', JSON.stringify(ids(v2)) === JSON.stringify([2]), v2);
77
+
78
+ // select 变化
79
+ const qsel = t.where({}).cache(60000);
80
+ qsel.select(['id']).get();
81
+ const sel2 = qsel.select(['id', 'g']).get();
82
+ ok('#55 select 变化 => 新字段可见', Object.keys(sel2[0]).sort().join(',') === 'g,id', Object.keys(sel2[0]));
83
+
84
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
85
+ if (fail > 0) process.exit(1);
86
+ })();
@@ -0,0 +1,70 @@
1
+ /*
2
+ * Issue #56 — SUM/AVG 对 >2^53 的整数丢精度。
3
+ *
4
+ * 聚合累加用 Number(v) 的浮点加法,9007199254740993 + 9007199254740992
5
+ * 会被舍入成 18014398509481984。修复:整数输入用 BigInt 精确累加,
6
+ * 结果超出安全范围时以 BigInt 返回(executeSQL 收尾转字符串),
7
+ * 安全范围内仍返回 number,浮点输入保持原行为。
8
+ */
9
+ const path = require('path');
10
+ const ROOT = path.join(__dirname, '..');
11
+ const { Database } = require(path.join(ROOT, 'index.js'));
12
+ const { executeSQL } = require(path.join(ROOT, 'lib', 'sql.js'));
13
+
14
+ let pass = 0, fail = 0;
15
+ function ok(name, cond, extra) {
16
+ if (cond) { pass++; console.log('[OK] ', name); }
17
+ else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra, (k, v) => (typeof v === 'bigint' ? v.toString() + 'n' : v)).slice(0, 400) : ''); }
18
+ }
19
+ // 聚合结果可能是 number / bigint / 超出 2^53 后被 finalize 成字符串
20
+ const s = (v) => String(v);
21
+
22
+ (async () => {
23
+ const db = new Database(':memory:', { autoSave: false });
24
+ const q = (sql) => executeSQL(db, sql, { safety: false });
25
+ await q('CREATE TABLE b (id INTEGER PRIMARY KEY AUTOINCREMENT, x BIGINT)');
26
+ await q('INSERT INTO b (x) VALUES (9007199254740993),(9007199254740992)');
27
+
28
+ let r = await q('SELECT SUM(x) FROM b');
29
+ ok('#56 SUM >2^53 精确 => 18014398509481985', s(r.rows[0][0]) === '18014398509481985', r.rows);
30
+
31
+ r = await q('SELECT AVG(x) FROM b');
32
+ ok('#56 AVG => 9007199254740992.5(double 下等于 9007199254740992)',
33
+ Number(r.rows[0][0]) === 9007199254740992.5, r.rows);
34
+
35
+ r = await q('SELECT SUM(x), AVG(x) FROM b');
36
+ ok('#56 SUM+AVG 同列', s(r.rows[0][0]) === '18014398509481985' && Number(r.rows[0][1]) === 9007199254740992.5, r.rows);
37
+
38
+ r = await q('SELECT id, SUM(x) AS t FROM b GROUP BY id ORDER BY id');
39
+ ok('#56 GROUP BY 单行 SUM 不丢精度',
40
+ s(r.rows[0][1]) === '9007199254740993' && s(r.rows[1][1]) === '9007199254740992', r.rows);
41
+
42
+ // 安全范围 / 普通浮点不能回归
43
+ await q('CREATE TABLE small (n INTEGER, f DOUBLE)');
44
+ await q('INSERT INTO small (n, f) VALUES (1,1.5),(2,2.5),(3,3.5)');
45
+ r = await q('SELECT SUM(n), AVG(n) FROM small');
46
+ ok('#56 小整数 SUM 仍是 number 6', r.rows[0][0] === 6 && typeof r.rows[0][0] === 'number', r.rows);
47
+ ok('#56 小整数 AVG 仍是 number 2', r.rows[0][1] === 2 && typeof r.rows[0][1] === 'number', r.rows);
48
+ r = await q('SELECT SUM(f), AVG(f) FROM small');
49
+ ok('#56 浮点 SUM 保持 7.5', r.rows[0][0] === 7.5 && typeof r.rows[0][0] === 'number', r.rows);
50
+ ok('#56 浮点 AVG 保持 2.5', r.rows[0][1] === 2.5, r.rows);
51
+
52
+ // 空集语义不能被改坏
53
+ r = await q('SELECT SUM(n) FROM small WHERE n > 100');
54
+ ok('#56 空集 SUM 仍为 NULL', r.rows[0][0] === null, r.rows);
55
+ r = await q('SELECT AVG(n) FROM small WHERE n > 100');
56
+ ok('#56 空集 AVG 仍为 NULL', r.rows[0][0] === null, r.rows);
57
+
58
+ // 负值 / 混合字符串数字
59
+ await q("CREATE TABLE mix (k TEXT)");
60
+ await q("INSERT INTO mix (k) VALUES ('9007199254740993'),('1')");
61
+ r = await q('SELECT SUM(k) FROM mix');
62
+ ok('#56 十进制字符串参与精确求和', s(r.rows[0][0]) === '9007199254740994', r.rows);
63
+
64
+ // DISTINCT 仍然有效
65
+ r = await q('SELECT SUM(DISTINCT x) FROM b');
66
+ ok('#56 SUM(DISTINCT) 正常', s(r.rows[0][0]) === '18014398509481985', r.rows);
67
+
68
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
69
+ if (fail > 0) process.exit(1);
70
+ })();
@@ -0,0 +1,70 @@
1
+ /*
2
+ * Issue #57 — UNION / INTERSECT / EXCEPT 静默丢弃 ORDER BY / LIMIT / OFFSET。
3
+ *
4
+ * 语法层把组合查询尾部的 ORDER BY/LIMIT/OFFSET 挂在最右侧 SELECT 上,
5
+ * 执行层却在 concat 后直接返回,导致这些子句完全失效。
6
+ * 修复后:尾部子句提升为整个组合结果的语义(标准 SQL),按左列名/列序号排序分页。
7
+ */
8
+ const path = require('path');
9
+ const ROOT = path.join(__dirname, '..');
10
+ const { Database } = require(path.join(ROOT, 'index.js'));
11
+ const { executeSQL } = require(path.join(ROOT, 'lib', 'sql.js'));
12
+
13
+ let pass = 0, fail = 0;
14
+ function ok(name, cond, extra) {
15
+ if (cond) { pass++; console.log('[OK] ', name); }
16
+ else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 400) : ''); }
17
+ }
18
+ const j = (rows) => JSON.stringify(rows);
19
+
20
+ (async () => {
21
+ const db = new Database(':memory:');
22
+ const q = (sql) => executeSQL(db, sql, { safety: false });
23
+
24
+ let r = await q('SELECT 1 AS x UNION SELECT 2 UNION SELECT 3 LIMIT 1');
25
+ ok('#57 UNION ... LIMIT 1 => [[1]]', j(r.rows) === j([[1]]), r.rows);
26
+
27
+ r = await q('SELECT 1 AS x UNION SELECT 2 UNION SELECT 3 ORDER BY x DESC LIMIT 1');
28
+ ok('#57 UNION ... ORDER BY x DESC LIMIT 1 => [[3]]', j(r.rows) === j([[3]]), r.rows);
29
+
30
+ r = await q('SELECT 1 AS x UNION SELECT 2 UNION SELECT 3 ORDER BY 1 DESC LIMIT 1');
31
+ ok('#57 ORDER BY 列序号 => [[3]]', j(r.rows) === j([[3]]), r.rows);
32
+
33
+ r = await q('SELECT 1 AS x UNION SELECT 2 UNION SELECT 3 ORDER BY x DESC LIMIT 2 OFFSET 1');
34
+ ok('#57 LIMIT 2 OFFSET 1 => [[2],[1]]', j(r.rows) === j([[2], [1]]), r.rows);
35
+
36
+ r = await q('SELECT 1 AS x UNION ALL SELECT 1 UNION ALL SELECT 1 LIMIT 2');
37
+ ok('#57 UNION ALL LIMIT 2 => 2 行', r.rows.length === 2, r.rows);
38
+
39
+ r = await q('SELECT 1 AS x UNION SELECT 2 UNION SELECT 3 LIMIT 1 OFFSET 1');
40
+ ok('#57 LIMIT 1 OFFSET 1 => [[2]]', j(r.rows) === j([[2]]), r.rows);
41
+
42
+ r = await q('SELECT 1 AS x EXCEPT SELECT 2 LIMIT 1');
43
+ ok('#57 EXCEPT + LIMIT => [[1]]', j(r.rows) === j([[1]]), r.rows);
44
+
45
+ r = await q('SELECT 1 AS x INTERSECT SELECT 1 INTERSECT SELECT 1 LIMIT 1');
46
+ ok('#57 INTERSECT + LIMIT => [[1]]', j(r.rows) === j([[1]]), r.rows);
47
+
48
+ // 表数据上的组合查询
49
+ await q('CREATE TABLE u1 (id INTEGER PRIMARY KEY AUTOINCREMENT, v INTEGER)');
50
+ await q('CREATE TABLE u2 (id INTEGER PRIMARY KEY AUTOINCREMENT, v INTEGER)');
51
+ await q('INSERT INTO u1 (v) VALUES (1),(3)');
52
+ await q('INSERT INTO u2 (v) VALUES (2),(4)');
53
+ r = await q('SELECT v FROM u1 UNION SELECT v FROM u2 ORDER BY v DESC LIMIT 2');
54
+ ok('#57 两表 UNION + ORDER BY v DESC LIMIT 2 => [[4],[3]]', j(r.rows) === j([[4], [3]]), r.rows);
55
+
56
+ r = await q('SELECT v FROM u1 UNION SELECT v FROM u2 ORDER BY v LIMIT 3 OFFSET 1');
57
+ ok('#57 两表 UNION + LIMIT/OFFSET => [[2],[3],[4]]', j(r.rows) === j([[2], [3], [4]]), r.rows);
58
+
59
+ r = await q('SELECT v FROM u1 UNION ALL SELECT v FROM u2 ORDER BY 1 LIMIT 1');
60
+ ok('#57 表数据 ORDER BY 1', j(r.rows) === j([[1]]), r.rows);
61
+
62
+ // 不改变无尾部子句的行为
63
+ r = await q('SELECT 1 AS x UNION SELECT 2');
64
+ ok('#57 无 LIMIT 行为不变', j(r.rows) === j([[1], [2]]), r.rows);
65
+
66
+ // 子句顺序正常(ORDER BY 在 LIMIT 前)已验证;LIMIT 在 ORDER BY 之前非法,不测
67
+
68
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
69
+ if (fail > 0) process.exit(1);
70
+ })();
@@ -0,0 +1,52 @@
1
+ /*
2
+ * Issue #58 — ast.rewrite() 无法替换根节点(transform 可以)。
3
+ *
4
+ * rewrite 的 visit 只在 `parent && key` 成立时才调用 fn 并回写,
5
+ * 根节点 parent===null 直接被跳过;而根替换是合法需求
6
+ * (例如把整条 SELECT 换成改写后的版本)。
7
+ */
8
+ const path = require('path');
9
+ const ROOT = path.join(__dirname, '..');
10
+ const AST = require(path.join(ROOT, 'lib', 'ast.js'));
11
+ const { parseSQL } = require(path.join(ROOT, 'lib', 'sql.js'));
12
+
13
+ let pass = 0, fail = 0;
14
+ function ok(name, cond, extra) {
15
+ if (cond) { pass++; console.log('[OK] ', name); }
16
+ else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 300) : ''); }
17
+ }
18
+
19
+ try {
20
+ // 1) 最小复现:纯对象树
21
+ const tree = { type: 'select', x: 1 };
22
+ const out = AST.rewrite(tree, (n) => (n && n.type === 'select' ? { type: 'select', y: 2 } : undefined));
23
+ ok('#58 rewrite 能替换根节点', JSON.stringify(out) === JSON.stringify({ type: 'select', y: 2 }), out);
24
+ ok('#58 返回的就是替换后的根', out && out.y === 2 && out.x === undefined, out);
25
+
26
+ // 2) 根节点不被替换时保持原样
27
+ const same = { type: 'select', x: 1 };
28
+ AST.rewrite(same, () => undefined);
29
+ ok('#58 无返回值时根节点不变', JSON.stringify(same) === JSON.stringify({ type: 'select', x: 1 }), same);
30
+
31
+ // 3) 真实 SQL AST:把整条语句换成另一条
32
+ const ast = parseSQL('SELECT a FROM t');
33
+ const replaced = AST.rewrite(ast, (n) => (n && n.type === 'select' ? parseSQL('SELECT b FROM u') : undefined));
34
+ ok('#58 真实 AST 根替换生效', replaced && replaced.from && replaced.from.tables[0].table === 'u', replaced && replaced.from);
35
+
36
+ // 4) 嵌套节点替换仍然有效(不能为了根节点修复而回归旧行为)
37
+ const ast2 = parseSQL('SELECT a FROM t WHERE a > 10');
38
+ AST.rewrite(ast2, (n) => (n && n.type === 'value' && n.value === 10 ? { type: 'value', value: 20 } : undefined));
39
+ const v = AST.find(ast2, (n) => n.type === 'value' && typeof n.value === 'number');
40
+ ok('#58 嵌套节点替换未回归', v && v.value === 20, v);
41
+
42
+ // 5) 数组根节点
43
+ const arr = [{ type: 'select', x: 1 }, { type: 'select', x: 2 }];
44
+ AST.rewrite(arr, (n) => (n && n.type === 'select' ? { type: 'select', y: n.x + 10 } : undefined));
45
+ ok('#58 数组根元素替换', JSON.stringify(arr) === JSON.stringify([{ type: 'select', y: 11 }, { type: 'select', y: 12 }]), arr);
46
+ } catch (e) {
47
+ fail++;
48
+ console.log('[FAIL] unexpected exception:', (e && e.stack) || e);
49
+ }
50
+
51
+ console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
52
+ if (fail > 0) process.exit(1);