jsql-neo 6.3.1 → 6.3.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/README.md +5 -5
- package/lib/database.js +4 -0
- package/lib/date-types.js +6 -3
- package/lib/mysql_compat.js +0 -2
- package/lib/sql.js +197 -33
- package/lib/table.js +9 -11
- package/package.json +1 -1
- package/test/join.test.js +10 -0
- package/test/query-opt.test.js +55 -0
- package/test/regress-5.1.0.js +32 -7
- package/test/sql-parser.test.js +125 -0
package/README.md
CHANGED
|
@@ -1991,11 +1991,11 @@ for big results; connection pooling; larger `saveInterval` + compressed snapshot
|
|
|
1991
1991
|
|
|
1992
1992
|
```bash
|
|
1993
1993
|
git clone https://github.com/vexify-org/JSQL-neo.git && cd JSQL-neo
|
|
1994
|
-
npm install
|
|
1995
|
-
npm test
|
|
1996
|
-
npm run test:
|
|
1997
|
-
npm run test:
|
|
1998
|
-
npm run
|
|
1994
|
+
npm install # postinstall 自动拉取平台原生模块
|
|
1995
|
+
npm test # 默认测试(smoke / sql-parser / readme-audit / query-opt)
|
|
1996
|
+
npm run test:all # 全量:追加 native / wasm / coverage / regress / join / btree
|
|
1997
|
+
npm run test:regress # 5.1.0 回归测试(会起 Redis / MySQL / WebUI 服务)
|
|
1998
|
+
npm run test:orms # ORM 集成示例
|
|
1999
1999
|
```
|
|
2000
2000
|
|
|
2001
2001
|
Conventions: feature branch + PR; tests for new SQL/commands; Conventional Commits
|
package/lib/database.js
CHANGED
|
@@ -1428,6 +1428,8 @@ class Database {
|
|
|
1428
1428
|
async stop() {
|
|
1429
1429
|
this._runHooks('onStop', []);
|
|
1430
1430
|
this._emit('stop', {});
|
|
1431
|
+
// 内存监控定时器:stop() 此前从不清理,重复创建/销毁实例会堆积定时器
|
|
1432
|
+
if (this._monitorTimer) { clearInterval(this._monitorTimer); this._monitorTimer = null; }
|
|
1431
1433
|
if (this._dirMode) {
|
|
1432
1434
|
if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; }
|
|
1433
1435
|
try {
|
|
@@ -1635,6 +1637,8 @@ class Database {
|
|
|
1635
1637
|
clearTimeout(this._autoSaveTimer);
|
|
1636
1638
|
this._autoSaveTimer = null;
|
|
1637
1639
|
}
|
|
1640
|
+
// 同样清理内存监控定时器,否则 close() 后仍在后台触发 _checkMemory
|
|
1641
|
+
if (this._monitorTimer) { clearInterval(this._monitorTimer); this._monitorTimer = null; }
|
|
1638
1642
|
if (this._dirMode) {
|
|
1639
1643
|
try {
|
|
1640
1644
|
this._flushDirty();
|
package/lib/date-types.js
CHANGED
|
@@ -175,10 +175,13 @@ function validateTime(value, fieldName) {
|
|
|
175
175
|
function now(type = 'datetime') {
|
|
176
176
|
const d = new Date();
|
|
177
177
|
switch (type) {
|
|
178
|
-
|
|
178
|
+
// 必须与 validateDate/validateDateTime 一致使用本地时区:
|
|
179
|
+
// toISOString() 是 UTC,东八区 00:00 本地会被算成前一天 16:00Z,
|
|
180
|
+
// 导致 now() 写入的日期比实际早一天。
|
|
181
|
+
case 'date': return toLocalDateString(d);
|
|
179
182
|
case 'timestamp': return Math.floor(d.getTime() / 1000);
|
|
180
|
-
case 'time': return d.
|
|
181
|
-
default: return d
|
|
183
|
+
case 'time': return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
|
184
|
+
default: return toLocalDateTimeString(d);
|
|
182
185
|
}
|
|
183
186
|
}
|
|
184
187
|
|
package/lib/mysql_compat.js
CHANGED
|
@@ -212,7 +212,6 @@ class Connection extends EventEmitter {
|
|
|
212
212
|
this.state = 'closed';
|
|
213
213
|
this.stream.destroy();
|
|
214
214
|
this.emit('close');
|
|
215
|
-
return undefined;
|
|
216
215
|
})();
|
|
217
216
|
if (cb) p.then(() => cb(null), err => cb(err));
|
|
218
217
|
return p;
|
|
@@ -373,7 +372,6 @@ class Pool {
|
|
|
373
372
|
const engines = Array.from(this._sharedEngines.values());
|
|
374
373
|
this._sharedEngines.clear();
|
|
375
374
|
await Promise.allSettled(engines.map(e => (typeof e.stop === 'function' ? e.stop() : null)));
|
|
376
|
-
return undefined;
|
|
377
375
|
})();
|
|
378
376
|
if (cb) p.then(() => cb(null), err => cb(err));
|
|
379
377
|
return p;
|
package/lib/sql.js
CHANGED
|
@@ -1096,8 +1096,11 @@ class Parser {
|
|
|
1096
1096
|
alias = this.parseOptionalAlias();
|
|
1097
1097
|
columns.push({ expr: null, caseExpr, alias });
|
|
1098
1098
|
} else {
|
|
1099
|
-
// 列 / 常量 / 函数 / 算术表达式
|
|
1100
|
-
|
|
1099
|
+
// 列 / 常量 / 函数 / 算术表达式 / 括号分组 / 标量布尔(NOT、比较、LIKE…)。
|
|
1100
|
+
// 此前用 parseScalar(),导致 SELECT (1+2) / SELECT NOT 0 / SELECT 'a' LIKE 'b'
|
|
1101
|
+
// 这类合法标量在投影列表里解析报错。parseArgExpr() 允许裸操作数,
|
|
1102
|
+
// 又能在存在运算符时走完整表达式解析,列/常量/函数行为不变。
|
|
1103
|
+
const expr = this.parseArgExpr();
|
|
1101
1104
|
let alias = null;
|
|
1102
1105
|
alias = this.parseOptionalAlias();
|
|
1103
1106
|
if (expr.type === 'star') {
|
|
@@ -1165,7 +1168,19 @@ class Parser {
|
|
|
1165
1168
|
}
|
|
1166
1169
|
} catch (e) {
|
|
1167
1170
|
this.pos = save;
|
|
1168
|
-
|
|
1171
|
+
// ORDER BY 可以引用输出列别名,而别名可能与聚合函数同名:
|
|
1172
|
+
// SELECT AVG(v) AS avg FROM t GROUP BY g ORDER BY avg DESC
|
|
1173
|
+
// 这种 token 已被词法器标成 keyword(AVG/SUM/COUNT/MAX/MIN 都在关键字表里),
|
|
1174
|
+
// parseColumnRef 只认 ident 会失败,parseScalar 又会把它当函数调用 → "got 'AVG'"。
|
|
1175
|
+
// 此处把裸 keyword 当作列名,与 SQLite / MySQL 的行为一致。
|
|
1176
|
+
const tk = this.peek();
|
|
1177
|
+
if (tk.type === 'keyword' && !this.isWord('ASC') && !this.isWord('DESC')
|
|
1178
|
+
&& !ORDER_END.some(w => this.isWord(w))) {
|
|
1179
|
+
this.next();
|
|
1180
|
+
col = tk.value;
|
|
1181
|
+
} else {
|
|
1182
|
+
col = this.parseScalar();
|
|
1183
|
+
}
|
|
1169
1184
|
}
|
|
1170
1185
|
let dir = 'asc';
|
|
1171
1186
|
if (this.isKeyword('ASC')) { this.next(); }
|
|
@@ -2005,6 +2020,14 @@ class Parser {
|
|
|
2005
2020
|
this.peek().type === 'op' && this.peek().value === '(') {
|
|
2006
2021
|
return this.parseAggregateCall(String(t.value).toUpperCase());
|
|
2007
2022
|
}
|
|
2023
|
+
// 裸关键字、且后面不跟 '(' → 当作列名/别名。
|
|
2024
|
+
// 典型场景:SELECT AVG(v) AS avg FROM t GROUP BY g ORDER BY avg DESC
|
|
2025
|
+
// 'avg' 已被词法器标成 keyword(AVG 在关键字表里),既进不了 parseColumnRef(只认 ident),
|
|
2026
|
+
// 也不满足上面的聚合调用分支(后面是 DESC 不是 '('),最终会抛 "got 'AVG'"。
|
|
2027
|
+
// ORDER BY 引用输出别名是标准 SQL 行为,SQLite / MySQL 都允许。
|
|
2028
|
+
if (t.type === 'keyword' && !(this.peek().type === 'op' && this.peek().value === '(')) {
|
|
2029
|
+
return { type: 'column', name: t.value };
|
|
2030
|
+
}
|
|
2008
2031
|
// 一元按位取反 ~x
|
|
2009
2032
|
if (t.type === 'op' && t.value === '~') {
|
|
2010
2033
|
return { type: 'bitnot', expr: this.parseOperand() };
|
|
@@ -2014,6 +2037,13 @@ class Parser {
|
|
|
2014
2037
|
this.expect('op', ')');
|
|
2015
2038
|
return { type: 'subquery', select: sub };
|
|
2016
2039
|
}
|
|
2040
|
+
// 括号分组:支持 (1+2)、2*(3+4) 等出现在算术/函数参数任意位置
|
|
2041
|
+
// 注意:parseOperand 开头已通过 const t = this.next() 消费了 '(', 此处不可再 next()
|
|
2042
|
+
if (t.type === 'op' && t.value === '(') {
|
|
2043
|
+
const inner = this.parseArgExpr(); // 容忍裸操作数,允许纯算术分组
|
|
2044
|
+
this.expect('op', ')');
|
|
2045
|
+
return inner;
|
|
2046
|
+
}
|
|
2017
2047
|
if (t.type === 'op' && (t.value === '-' || t.value === '+')) {
|
|
2018
2048
|
const num = this.next();
|
|
2019
2049
|
if (num.type !== 'number') throw new Error('Expected number after sign');
|
|
@@ -2318,8 +2348,11 @@ function applyCast(value, dataType) {
|
|
|
2318
2348
|
const t = String(dataType || '').toUpperCase().replace(/\(.*\)$/, '').trim();
|
|
2319
2349
|
if (value === null || value === undefined) return null;
|
|
2320
2350
|
if (t === 'INTEGER' || t === 'INT' || t === 'BIGINT' || t === 'SMALLINT' || t === 'TINYINT') {
|
|
2321
|
-
|
|
2322
|
-
|
|
2351
|
+
// MySQL CAST(... AS SIGNED/INT):取数字前缀('12abc'→12),无法解析时返回 0(而非 null)。
|
|
2352
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
2353
|
+
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : 0;
|
|
2354
|
+
const m = String(value).trim().match(/^[+-]?\d+(\.\d+)?/);
|
|
2355
|
+
return m ? Math.trunc(Number(m[0])) : 0;
|
|
2323
2356
|
}
|
|
2324
2357
|
if (t === 'FLOAT' || t === 'DOUBLE' || t === 'REAL' || t === 'NUMERIC' || t === 'DECIMAL') {
|
|
2325
2358
|
const n = Number(value);
|
|
@@ -2659,6 +2692,23 @@ function pgDatePart(part, d) {
|
|
|
2659
2692
|
}
|
|
2660
2693
|
}
|
|
2661
2694
|
|
|
2695
|
+
/**
|
|
2696
|
+
* 按自然月推进并在月末做「钳制」,对齐 MySQL 的日期加减语义。
|
|
2697
|
+
* JS 的 setMonth()/setFullYear() 会溢出(2023-01-31 +1 月 → 2023-03-03),
|
|
2698
|
+
* 而 MySQL 会把超出目标月天数的日期夹到当月最后一天(→ 2023-02-28)。
|
|
2699
|
+
* 先 setDate(1) 再切月,避免切换过程中先溢出再被钳制。
|
|
2700
|
+
*/
|
|
2701
|
+
function addMonthsClamped(d, n) {
|
|
2702
|
+
const day = d.getDate();
|
|
2703
|
+
const t = new Date(d.getTime());
|
|
2704
|
+
t.setDate(1);
|
|
2705
|
+
t.setMonth(t.getMonth() + n);
|
|
2706
|
+
const lastDay = new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate();
|
|
2707
|
+
t.setDate(Math.min(day, lastDay));
|
|
2708
|
+
d.setTime(t.getTime());
|
|
2709
|
+
return d;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2662
2712
|
/** PG AGE(from, to) 的间隔文本:'6 years 7 mons 11 days' */
|
|
2663
2713
|
function pgAge(from, to) {
|
|
2664
2714
|
let years = to.getFullYear() - from.getFullYear();
|
|
@@ -2760,9 +2810,9 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
2760
2810
|
switch (String(iv.unit || '').toUpperCase()) {
|
|
2761
2811
|
case 'DAY': case 'DAYS': d.setDate(d.getDate() + n); break;
|
|
2762
2812
|
case 'WEEK': case 'WEEKS': d.setDate(d.getDate() + n * 7); break;
|
|
2763
|
-
case 'MONTH': case 'MONTHS':
|
|
2764
|
-
case 'QUARTER':
|
|
2765
|
-
case 'YEAR': case 'YEARS':
|
|
2813
|
+
case 'MONTH': case 'MONTHS': addMonthsClamped(d, n); break;
|
|
2814
|
+
case 'QUARTER': addMonthsClamped(d, n * 3); break;
|
|
2815
|
+
case 'YEAR': case 'YEARS': addMonthsClamped(d, n * 12); break;
|
|
2766
2816
|
case 'HOUR': case 'HOURS': d.setHours(d.getHours() + n); break;
|
|
2767
2817
|
case 'MINUTE': case 'MINUTES': d.setMinutes(d.getMinutes() + n); break;
|
|
2768
2818
|
case 'SECOND': case 'SECONDS': d.setSeconds(d.getSeconds() + n); break;
|
|
@@ -2956,7 +3006,14 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
2956
3006
|
if (!Number.isFinite(factor)) return x; // 位数过大:值不变
|
|
2957
3007
|
if (factor === 0) return 0; // 位数过小:舍入到 0
|
|
2958
3008
|
const sign = x < 0 ? -1 : 1;
|
|
2959
|
-
|
|
3009
|
+
const scaled = Math.abs(x) * factor;
|
|
3010
|
+
const floor = Math.floor(scaled);
|
|
3011
|
+
const frac = scaled - floor;
|
|
3012
|
+
// 二进制浮点误差会把恰好落在中点的值存成略小(1.005*100 = 100.49999999999999),
|
|
3013
|
+
// 在中点附近按相对误差判定为「半」,以「半远离零」进位,避免静默少 1。
|
|
3014
|
+
const eps = Math.abs(scaled) * Number.EPSILON * 4 + Number.EPSILON;
|
|
3015
|
+
const rounded = Math.abs(frac - 0.5) < eps ? floor + 1 : Math.round(scaled);
|
|
3016
|
+
return sign * rounded / factor;
|
|
2960
3017
|
}
|
|
2961
3018
|
case 'FLOOR': return args[0] == null ? null : Math.floor(args[0]);
|
|
2962
3019
|
case 'CEIL': case 'CEILING': return args[0] == null ? null : Math.ceil(args[0]);
|
|
@@ -2968,6 +3025,13 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
2968
3025
|
case 'NULLIF': return args[0] === args[1] ? null : args[0];
|
|
2969
3026
|
case 'IF': return args[0] ? args[1] : args[2];
|
|
2970
3027
|
case 'REPLACE': return args[0] == null ? null : String(args[0]).split(args[1]).join(args[2]);
|
|
3028
|
+
case 'REPEAT': {
|
|
3029
|
+
// 此前完全缺失,静默返回 null
|
|
3030
|
+
if (args[0] == null || args[1] == null) return null;
|
|
3031
|
+
const n = Math.trunc(Number(args[1]));
|
|
3032
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
3033
|
+
return String(args[0]).repeat(n);
|
|
3034
|
+
}
|
|
2971
3035
|
case 'SUBSTRING': case 'SUBSTR': {
|
|
2972
3036
|
if (args[0] == null) return null;
|
|
2973
3037
|
const s = String(args[0]);
|
|
@@ -2985,11 +3049,18 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
2985
3049
|
}
|
|
2986
3050
|
case 'LEFT': return args[0] == null ? null : String(args[0]).slice(0, Number(args[1]));
|
|
2987
3051
|
case 'RIGHT': return args[0] == null ? null : String(args[0]).slice(-Number(args[1]));
|
|
2988
|
-
case 'LOCATE':
|
|
3052
|
+
case 'LOCATE': {
|
|
2989
3053
|
if (args[0] == null || args[1] == null) return null;
|
|
2990
3054
|
const idx = String(args[1]).indexOf(String(args[0]));
|
|
2991
3055
|
return idx + 1;
|
|
2992
3056
|
}
|
|
3057
|
+
case 'INSTR': {
|
|
3058
|
+
// INSTR(str, substr):参数顺序与 LOCATE(substr, str) 相反。
|
|
3059
|
+
// 此前两者共用一段代码,导致 INSTR 静默返回 0。
|
|
3060
|
+
if (args[0] == null || args[1] == null) return null;
|
|
3061
|
+
const idx = String(args[0]).indexOf(String(args[1]));
|
|
3062
|
+
return idx + 1;
|
|
3063
|
+
}
|
|
2993
3064
|
case 'REVERSE': return args[0] == null ? null : String(args[0]).split('').reverse().join('');
|
|
2994
3065
|
case 'LPAD': {
|
|
2995
3066
|
if (args[0] == null) return null;
|
|
@@ -3163,8 +3234,10 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
3163
3234
|
case 'xor': return Boolean(evaluateExpr(expr.left, row, ctx)) !== Boolean(evaluateExpr(expr.right, row, ctx));
|
|
3164
3235
|
case 'not': {
|
|
3165
3236
|
// 三值逻辑:NOT UNKNOWN = UNKNOWN(返回 null,在 WHERE/ON/HAVING 中视为不选中)
|
|
3166
|
-
|
|
3167
|
-
|
|
3237
|
+
// 必须用 resolveOperand 取值:evaluateExpr 只认布尔类节点,
|
|
3238
|
+
// 对 value/column/arith 会落到 default 返回 false,导致 NOT 恒为真。
|
|
3239
|
+
const v = resolveOperand(expr.expr, row, ctx);
|
|
3240
|
+
return v === null || v === undefined ? null : !v;
|
|
3168
3241
|
}
|
|
3169
3242
|
case 'compare': {
|
|
3170
3243
|
const l = resolveOperand(expr.left, row, ctx);
|
|
@@ -3239,7 +3312,9 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
3239
3312
|
const v = resolveOperand(expr.operand, row, ctx);
|
|
3240
3313
|
const lo = resolveOperand(expr.low, row, ctx);
|
|
3241
3314
|
const hi = resolveOperand(expr.high, row, ctx);
|
|
3242
|
-
|
|
3315
|
+
// 三值逻辑:任一操作数为 NULL 时结果是 UNKNOWN(用 null 表示),
|
|
3316
|
+
// 与普通比较运算符一致;不能返回 false,否则 NOT BETWEEN 会被误判为真。
|
|
3317
|
+
if (v === null || v === undefined || lo === null || lo === undefined || hi === null || hi === undefined) return null;
|
|
3243
3318
|
const inRange = typeof v === 'number' && typeof lo === 'number' && typeof hi === 'number'
|
|
3244
3319
|
? v >= lo && v <= hi
|
|
3245
3320
|
: String(v) >= String(lo) && String(v) <= String(hi);
|
|
@@ -3309,6 +3384,25 @@ function windowColumnValue(c, r) {
|
|
|
3309
3384
|
return v === undefined ? null : v;
|
|
3310
3385
|
}
|
|
3311
3386
|
|
|
3387
|
+
// 表达式树里是否含 aggregate 节点(用于识别 ROUND(AVG(x),1) 这类嵌套聚合)
|
|
3388
|
+
function containsAggregateNode(node) {
|
|
3389
|
+
if (!node || typeof node !== 'object') return false;
|
|
3390
|
+
if (Array.isArray(node)) return node.some(containsAggregateNode);
|
|
3391
|
+
if (node.type === 'aggregate') return true;
|
|
3392
|
+
for (const k of Object.keys(node)) {
|
|
3393
|
+
const v = node[k];
|
|
3394
|
+
if (v && typeof v === 'object' && containsAggregateNode(v)) return true;
|
|
3395
|
+
}
|
|
3396
|
+
return false;
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
// 结果集中的 SQL 布尔值按 MySQL 语义呈现为 1/0(如 SELECT 'a' LIKE 'b' → 0)
|
|
3400
|
+
function projectScalar(v) {
|
|
3401
|
+
if (v === true) return 1;
|
|
3402
|
+
if (v === false) return 0;
|
|
3403
|
+
return v;
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3312
3406
|
function scalarColumnValue(c, r, ctx) {
|
|
3313
3407
|
if (c.over || (c.scalar && c.scalar.over)) return windowColumnValue(c, r);
|
|
3314
3408
|
// 聚合列优先:GROUP_CONCAT / STDDEV 等同时带 scalar 与 aggregate
|
|
@@ -3324,13 +3418,21 @@ function scalarColumnValue(c, r, ctx) {
|
|
|
3324
3418
|
const col = s.args && s.args[0];
|
|
3325
3419
|
return ctx._aggValue(ctx.group, fn, col, c.aggregate ? c.distinct : (c.scalar && c.scalar.distinct));
|
|
3326
3420
|
}
|
|
3327
|
-
|
|
3421
|
+
// 嵌套聚合:ROUND(AVG(x),1)、UPPER(MIN(s)) 等 —— 顶层不是聚合函数,
|
|
3422
|
+
// 但参数里可能夹着 aggregate 节点。resolveOperand 不认识这种节点会返回 null,
|
|
3423
|
+
// 所以先把整棵 scalar 树里的聚合按当前分组求值替换成字面值(每行都要替换,故用深拷贝)。
|
|
3424
|
+
if (ctx && ctx._aggValue && s && containsAggregateNode(s)) {
|
|
3425
|
+
const copy = JSON.parse(JSON.stringify(s));
|
|
3426
|
+
ctx._replaceAggregates(copy, ctx.group || [r]);
|
|
3427
|
+
return projectScalar(resolveOperand(copy, r, ctx));
|
|
3428
|
+
}
|
|
3429
|
+
return projectScalar(resolveOperand(c.scalar, r, ctx));
|
|
3328
3430
|
}
|
|
3329
3431
|
|
|
3330
3432
|
if (c.literal !== undefined) return c.literal;
|
|
3331
|
-
if (c.caseExpr) return evaluateCaseVal(c.caseExpr, r, ctx);
|
|
3433
|
+
if (c.caseExpr) return projectScalar(evaluateCaseVal(c.caseExpr, r, ctx));
|
|
3332
3434
|
if (c.expr === '*') return r[Object.keys(r).find(k => !k.startsWith('_'))];
|
|
3333
|
-
return resolveOperand({ type: 'column', name: c.expr }, r, ctx);
|
|
3435
|
+
return projectScalar(resolveOperand({ type: 'column', name: c.expr }, r, ctx));
|
|
3334
3436
|
}
|
|
3335
3437
|
|
|
3336
3438
|
function sqlTypeName(type) {
|
|
@@ -3385,21 +3487,21 @@ class SQLExecutor {
|
|
|
3385
3487
|
async execute(statement) {
|
|
3386
3488
|
switch (statement.type) {
|
|
3387
3489
|
case 'createTable': {
|
|
3388
|
-
if (statement.ifNotExists && this.engine.hasTable && this.engine.hasTable(statement.name)) {
|
|
3490
|
+
if (statement.ifNotExists && this.engine.hasTable && await this.engine.hasTable(statement.name)) {
|
|
3389
3491
|
return { ok: true, type: 'createTable', table: statement.name, affectedRows: 0, skipped: true };
|
|
3390
3492
|
}
|
|
3391
3493
|
const r = await this.engine.createTable(statement.name, statement.schema);
|
|
3392
3494
|
return { ok: true, type: 'createTable', table: statement.name, affectedRows: 0, result: r };
|
|
3393
3495
|
}
|
|
3394
3496
|
case 'dropTable': {
|
|
3395
|
-
if (statement.ifExists && !this.engine.hasTable(statement.table)) {
|
|
3497
|
+
if (statement.ifExists && !(await this.engine.hasTable(statement.table))) {
|
|
3396
3498
|
return { ok: true, type: 'dropTable', table: statement.table, affectedRows: 0 };
|
|
3397
3499
|
}
|
|
3398
3500
|
await this.engine.dropTable(statement.table);
|
|
3399
3501
|
return { ok: true, type: 'dropTable', table: statement.table, affectedRows: 0 };
|
|
3400
3502
|
}
|
|
3401
3503
|
case 'truncate': {
|
|
3402
|
-
if (this.engine.hasTable(statement.name)) await this.engine.truncate(statement.name);
|
|
3504
|
+
if (await this.engine.hasTable(statement.name)) await this.engine.truncate(statement.name);
|
|
3403
3505
|
return { ok: true, type: 'truncate', table: statement.name, affectedRows: 0 };
|
|
3404
3506
|
}
|
|
3405
3507
|
case 'alterTable': {
|
|
@@ -3752,7 +3854,7 @@ class SQLExecutor {
|
|
|
3752
3854
|
}
|
|
3753
3855
|
case 'createIndex': {
|
|
3754
3856
|
const tName = statement.table;
|
|
3755
|
-
if (typeof this.engine.hasTable === 'function' && !this.engine.hasTable(tName)) {
|
|
3857
|
+
if (typeof this.engine.hasTable === 'function' && !(await this.engine.hasTable(tName))) {
|
|
3756
3858
|
throw new Error(`Table '${tName}' does not exist`);
|
|
3757
3859
|
}
|
|
3758
3860
|
let handled = false;
|
|
@@ -4134,7 +4236,7 @@ class SQLExecutor {
|
|
|
4134
4236
|
}
|
|
4135
4237
|
|
|
4136
4238
|
async _getSchema(name) {
|
|
4137
|
-
if (this.engine.hasTable && !this.engine.hasTable(name)) {
|
|
4239
|
+
if (this.engine.hasTable && !(await this.engine.hasTable(name))) {
|
|
4138
4240
|
throw new Error(`Table '${name}' does not exist`);
|
|
4139
4241
|
}
|
|
4140
4242
|
const schema = this.engine.getTableSchema
|
|
@@ -4434,7 +4536,12 @@ class SQLExecutor {
|
|
|
4434
4536
|
return typeof agg.result === 'function' ? agg.result(state) : state;
|
|
4435
4537
|
}
|
|
4436
4538
|
}
|
|
4437
|
-
if (fn === 'SUM')
|
|
4539
|
+
if (fn === 'SUM') {
|
|
4540
|
+
// MySQL 语义:空集(或无任何非 NULL 值)的 SUM 为 NULL,而非 0。
|
|
4541
|
+
// AVG/MIN/MAX 已按此处理,此前只有 SUM 漏了。
|
|
4542
|
+
if (!values.length) return null;
|
|
4543
|
+
return values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0);
|
|
4544
|
+
}
|
|
4438
4545
|
if (fn === 'AVG') return values.length ? values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0) / values.length : null;
|
|
4439
4546
|
if (fn === 'MIN' || fn === 'MAX') {
|
|
4440
4547
|
const vs = values.filter(v => v !== null && v !== undefined);
|
|
@@ -5010,7 +5117,7 @@ class SQLExecutor {
|
|
|
5010
5117
|
if (firstItem && firstItem.table && String(firstItem.table).toLowerCase().startsWith('information_schema.')) {
|
|
5011
5118
|
const view = String(firstItem.table).toLowerCase().split('.')[1];
|
|
5012
5119
|
const all = await this._infoSchemaRows(view);
|
|
5013
|
-
const filtered = statement.where ? all.filter(r => evaluateExpr(statement.where, r)) : all;
|
|
5120
|
+
const filtered = statement.where ? all.filter(r => evaluateExpr(statement.where, r, this.ctx)) : all;
|
|
5014
5121
|
const cols = statement.columns.map(c => scalarColumnName(c));
|
|
5015
5122
|
const isStar = cols.length === 1 && cols[0] === '*';
|
|
5016
5123
|
let outCols;
|
|
@@ -5055,7 +5162,7 @@ class SQLExecutor {
|
|
|
5055
5162
|
// 一对多:一个左行可能匹配多个右行,必须全部收进来(此前只取第一个)
|
|
5056
5163
|
const hits = [];
|
|
5057
5164
|
for (let ri = 0; ri < rightRows.length; ri++) {
|
|
5058
|
-
if (evaluateExpr(j.on, { ...l, ...rightRows[ri] })) hits.push(ri);
|
|
5165
|
+
if (evaluateExpr(j.on, { ...l, ...rightRows[ri] }, this.ctx)) hits.push(ri);
|
|
5059
5166
|
}
|
|
5060
5167
|
if (hits.length > 0) {
|
|
5061
5168
|
for (const ri of hits) {
|
|
@@ -5163,7 +5270,11 @@ class SQLExecutor {
|
|
|
5163
5270
|
// 聚合输出(无 GROUP BY 时)
|
|
5164
5271
|
const hasCustomAgg = this.ctx && this.ctx.aggregates && statement.columns.some(c => c.scalar && c.scalar.type === 'func' && Object.prototype.hasOwnProperty.call(this.ctx.aggregates, String(c.scalar.name).toUpperCase()));
|
|
5165
5272
|
// 注意:带 OVER 的聚合是窗口函数,不是“整表塌缩成一行”的聚合,必须排除
|
|
5166
|
-
|
|
5273
|
+
// 无 GROUP BY 的整表聚合:顶层是聚合(AVG(x))、自定义聚合(total(x)),
|
|
5274
|
+
// 以及嵌套聚合(ROUND(AVG(x),1) —— 聚合藏在 scalar 的参数里,顶层 c.aggregate 为空)
|
|
5275
|
+
const hasNestedAgg = !statement.groupBy &&
|
|
5276
|
+
statement.columns.some(c => c.scalar && containsAggregateNode(c.scalar));
|
|
5277
|
+
if (!statement.groupBy && (statement.columns.some(c => c.aggregate && !c.window) || hasCustomAgg || hasNestedAgg)) {
|
|
5167
5278
|
const cols = statement.columns.map(c => scalarColumnName(c));
|
|
5168
5279
|
const tableName2 = statement.from ? (statement.from.tables[0].table || null) : null;
|
|
5169
5280
|
// HAVING 判定不通过时 rows 已为空,此时整表聚合也应为空,不能无条件补一行
|
|
@@ -5178,9 +5289,17 @@ class SQLExecutor {
|
|
|
5178
5289
|
return this._aggValue(rows, fn, c.scalar.args && c.scalar.args[0]);
|
|
5179
5290
|
}
|
|
5180
5291
|
}
|
|
5181
|
-
if (c.scalar)
|
|
5182
|
-
|
|
5183
|
-
|
|
5292
|
+
if (c.scalar) {
|
|
5293
|
+
// 先把参数里的聚合按整表求值替换掉,否则 ROUND(AVG(x),1) 会得到 null
|
|
5294
|
+
if (containsAggregateNode(c.scalar)) {
|
|
5295
|
+
const nested = JSON.parse(JSON.stringify(c.scalar));
|
|
5296
|
+
this._replaceAggregates(nested, rows);
|
|
5297
|
+
return projectScalar(resolveOperand(nested, rows[0] || {}, this.ctx));
|
|
5298
|
+
}
|
|
5299
|
+
return projectScalar(resolveOperand(c.scalar, rows[0] || {}, this.ctx));
|
|
5300
|
+
}
|
|
5301
|
+
if (c.caseExpr) return projectScalar(resolveOperand(c.caseExpr, rows[0] || {}, this.ctx));
|
|
5302
|
+
if (c.expr !== null && c.expr !== '*') return rows[0] ? projectScalar(resolveOperand({ type: 'column', name: c.expr }, rows[0])) : null;
|
|
5184
5303
|
return null;
|
|
5185
5304
|
};
|
|
5186
5305
|
return { ok: true, type: 'select', table: tableName2, columns: cols, rows: [[...statement.columns.map(valueOf)]], aggregate: statement.aggregate };
|
|
@@ -5195,9 +5314,54 @@ class SQLExecutor {
|
|
|
5195
5314
|
: rows;
|
|
5196
5315
|
const mapped = await this._projectRows(statement, rowsForGroups, (r) => {
|
|
5197
5316
|
const group = r._group || [r];
|
|
5198
|
-
return statement.columns.map(c => scalarColumnValue(c, r, {
|
|
5317
|
+
return statement.columns.map(c => scalarColumnValue(c, r, {
|
|
5318
|
+
_aggValue: this._aggValue.bind(this),
|
|
5319
|
+
_replaceAggregates: this._replaceAggregates.bind(this),
|
|
5320
|
+
group,
|
|
5321
|
+
ctxAggregates: this.ctx ? this.ctx.aggregates : null,
|
|
5322
|
+
}));
|
|
5199
5323
|
});
|
|
5200
|
-
|
|
5324
|
+
// GROUP BY 路径此前直接 return,ORDER BY 完全没有生效
|
|
5325
|
+
//(SELECT dept, SUM(sal) AS total FROM emp GROUP BY dept ORDER BY total DESC
|
|
5326
|
+
// 返回的仍是分组出现顺序)。这里补上排序,规则与非分组路径一致。
|
|
5327
|
+
let outRows = mapped;
|
|
5328
|
+
if (statement.orderBy && mapped.length > 1) {
|
|
5329
|
+
const ordVal = (row, o) => {
|
|
5330
|
+
// row 是投影后的 [v1, v2, ...],按列名下标取
|
|
5331
|
+
const name = typeof o.column === 'string' ? o.column
|
|
5332
|
+
: (o.column && o.column.type === 'column' ? o.column.name : null);
|
|
5333
|
+
const pos = (o.column && typeof o.column === 'object' && o.column.type === 'value'
|
|
5334
|
+
&& typeof o.column.value === 'number') ? o.column.value
|
|
5335
|
+
: (typeof o.column === 'number' ? o.column : null);
|
|
5336
|
+
if (name === null && pos !== null && pos >= 1 && pos <= cols.length) {
|
|
5337
|
+
return row[pos - 1];
|
|
5338
|
+
}
|
|
5339
|
+
if (name === null) return null;
|
|
5340
|
+
const idx = cols.indexOf(name);
|
|
5341
|
+
return idx === -1 ? null : row[idx];
|
|
5342
|
+
};
|
|
5343
|
+
const cmp = (a, b) => {
|
|
5344
|
+
for (const o of statement.orderBy) {
|
|
5345
|
+
const av = ordVal(a.row, o);
|
|
5346
|
+
const bv = ordVal(b.row, o);
|
|
5347
|
+
if (av === bv || (av === undefined && bv === undefined)) continue;
|
|
5348
|
+
if (av === undefined || av === null) return o.dir === 'asc' ? -1 : 1;
|
|
5349
|
+
if (bv === undefined || bv === null) return o.dir === 'asc' ? 1 : -1;
|
|
5350
|
+
const r = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv));
|
|
5351
|
+
if (r !== 0) return o.dir === 'asc' ? r : -r;
|
|
5352
|
+
}
|
|
5353
|
+
return 0;
|
|
5354
|
+
};
|
|
5355
|
+
outRows = mapped
|
|
5356
|
+
.map((row, i) => ({ row, src: rowsForGroups[i] }))
|
|
5357
|
+
.sort(cmp)
|
|
5358
|
+
.map(x => x.row);
|
|
5359
|
+
}
|
|
5360
|
+
if (statement.limit !== null) {
|
|
5361
|
+
const start = statement.offset || 0;
|
|
5362
|
+
outRows = outRows.slice(start, start + statement.limit);
|
|
5363
|
+
}
|
|
5364
|
+
return { ok: true, type: 'select', table: statement.from ? (statement.from.tables[0].table || null) : null, columns: cols, rows: outRows, raw: rowsForGroups };
|
|
5201
5365
|
}
|
|
5202
5366
|
|
|
5203
5367
|
if (statement.orderBy) {
|
|
@@ -5255,11 +5419,11 @@ class SQLExecutor {
|
|
|
5255
5419
|
const cols = statement.columns.map(c => scalarColumnName(c));
|
|
5256
5420
|
const mapped = await this._projectRows(statement, rows, (r) => statement.columns.map(c => {
|
|
5257
5421
|
if (c.over || (c.scalar && c.scalar.over)) return windowColumnValue(c, r);
|
|
5258
|
-
if (c.scalar) return resolveOperand(c.scalar, r, this.ctx);
|
|
5422
|
+
if (c.scalar) return projectScalar(resolveOperand(c.scalar, r, this.ctx));
|
|
5259
5423
|
if (c.expr === '*') return null;
|
|
5260
5424
|
if (c.literal !== undefined) return c.literal;
|
|
5261
|
-
if (c.caseExpr) return evaluateCaseVal(c.caseExpr, r);
|
|
5262
|
-
return resolveOperand({ type: 'column', name: c.expr }, r, this.ctx);
|
|
5425
|
+
if (c.caseExpr) return projectScalar(evaluateCaseVal(c.caseExpr, r));
|
|
5426
|
+
return projectScalar(resolveOperand({ type: 'column', name: c.expr }, r, this.ctx));
|
|
5263
5427
|
}));
|
|
5264
5428
|
return { ok: true, type: 'select', table: tableName, columns: cols, rows: mapped, raw: rows };
|
|
5265
5429
|
}
|
package/lib/table.js
CHANGED
|
@@ -234,8 +234,6 @@ class Table {
|
|
|
234
234
|
if (row[field] !== undefined && row[field] !== null) uniqueTracker[field].add(row[field]);
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
-
const dateFields = this._cachedDateFields;
|
|
238
|
-
const hasDateFields = dateFields.length > 0;
|
|
239
237
|
const autoIncField = this._autoIncrementField;
|
|
240
238
|
const defaults = this._defaults;
|
|
241
239
|
const computedFields = this._computedFields;
|
|
@@ -259,17 +257,16 @@ class Table {
|
|
|
259
257
|
}
|
|
260
258
|
}
|
|
261
259
|
let autoIncSeq = 0;
|
|
260
|
+
const result = new Array(N);
|
|
262
261
|
for (let i = 0; i < N; i++) {
|
|
263
|
-
|
|
262
|
+
// 在副本上做默认值/计算列/类型归一,避免污染调用方传入的原始对象
|
|
263
|
+
let data = { ...items[i] };
|
|
264
264
|
for (const [f, dv] of Object.entries(defaults)) {
|
|
265
265
|
if (data[f] === undefined) data[f] = typeof dv === 'function' ? dv() : dv === 'CURRENT_TIMESTAMP' ? now() : dv;
|
|
266
266
|
}
|
|
267
267
|
for (const f of Object.keys(computedFields)) data[f] = computedFields[f](data);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (data[f] !== undefined && data[f] !== null) data[f] = validateDateType(data[f], this._dateFields[f], f);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
268
|
+
// 与单条 insert 保持一致:归一时间/数值/布尔/字符串类型(此前 insertMany 跳过了这一步)
|
|
269
|
+
data = this._validateDataTypes(data);
|
|
273
270
|
for (const field of this._cachedSchemaFields) {
|
|
274
271
|
const def = schema[field];
|
|
275
272
|
if (def.required && (data[field] === undefined || data[field] === null)
|
|
@@ -312,15 +309,16 @@ class Table {
|
|
|
312
309
|
const val = data[field];
|
|
313
310
|
if (val !== undefined && val !== null) tree.insert(val, rowIdx);
|
|
314
311
|
}
|
|
315
|
-
|
|
312
|
+
// 不写回调用方传入的 items,改为返回归一后的新数组
|
|
313
|
+
result[i] = data;
|
|
316
314
|
}
|
|
317
315
|
this._dirty = true;
|
|
318
316
|
this._db._markDirty(this._name);
|
|
319
317
|
for (const hook of this._hooks.afterInsert) {
|
|
320
|
-
for (let i = 0; i < N; i++) hook(
|
|
318
|
+
for (let i = 0; i < N; i++) hook(result[i]);
|
|
321
319
|
}
|
|
322
320
|
this._db._emitChange('insertMany', this._name, { count: N });
|
|
323
|
-
return
|
|
321
|
+
return result;
|
|
324
322
|
}
|
|
325
323
|
|
|
326
324
|
upsert(data) {
|
package/package.json
CHANGED
package/test/join.test.js
CHANGED
|
@@ -105,6 +105,16 @@ async function setup(db) {
|
|
|
105
105
|
ok('J7 chained left joins', r.rows.length === 3 && r.rows[1][1] === 10 && r.rows[1][2] === 100 && r.rows[1][3] === 'C10' && r.rows[0][1] === null && r.rows[0][2] === null, r.rows);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/* ============ J8 JOIN ON 透传 ctx ============ */
|
|
109
|
+
{
|
|
110
|
+
const db = new Database(':memory:', { autoSave: false });
|
|
111
|
+
await setup(db);
|
|
112
|
+
// ON 条件引用 @@会话变量:此前 evaluateExpr(j.on, row) 漏传 ctx,
|
|
113
|
+
// @@jv 解析为 null 导致匹配全丢,这里锁定必须透传 this.ctx。
|
|
114
|
+
const r = await executeSQL(db, 'SELECT a.id AS aid, b.id AS bid FROM a INNER JOIN b ON a.id = b.a_id AND b.id = @@jv', { session: { sysvars: { jv: 10 } } });
|
|
115
|
+
ok('J8 JOIN ON 透传 ctx(@@会话变量可解析)', r.rows.length === 1 && r.rows[0][0] === 2 && r.rows[0][1] === 10, r.rows);
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
console.log(failed === 0 ? `\nALL ${passed} JOIN TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
|
|
109
119
|
process.exit(failed === 0 ? 0 : 1);
|
|
110
120
|
})().catch((e) => { console.error('FATAL', e); process.exit(1); });
|
package/test/query-opt.test.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const Database = require('../lib/database');
|
|
6
6
|
const BTree = require('../lib/btree');
|
|
7
|
+
const { executeSQL } = require('../lib/sql');
|
|
8
|
+
const { now } = require('../lib/date-types');
|
|
7
9
|
|
|
8
10
|
let passed = 0, failed = 0;
|
|
9
11
|
function ok(name, cond, extra) {
|
|
@@ -104,6 +106,59 @@ function ok(name, cond, extra) {
|
|
|
104
106
|
ok('batch afterInsert', afterInsertHits === 2, afterInsertHits);
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
/* ============ BUG 修复: now() 时区 / 监控定时器 / insertMany 归一 & 不污染 ============ */
|
|
110
|
+
{
|
|
111
|
+
// BUG-1: now() 必须与 validateDate/DateTime 一致使用本地时区(此前用 toISOString=UTC)
|
|
112
|
+
const savedTZ = process.env.TZ;
|
|
113
|
+
process.env.TZ = 'Asia/Shanghai';
|
|
114
|
+
try {
|
|
115
|
+
const d = new Date();
|
|
116
|
+
const p2 = n => String(n).padStart(2, '0');
|
|
117
|
+
const localDate = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
|
|
118
|
+
const localTime = `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
|
|
119
|
+
ok('BUG-1 now("date") 本地时区', now('date') === localDate, { got: now('date'), want: localDate });
|
|
120
|
+
ok('BUG-1 now("time") 本地时区', now('time') === localTime, { got: now('time'), want: localTime });
|
|
121
|
+
ok('BUG-1 now() 本地时区', now() === `${localDate} ${localTime}`, { got: now(), want: `${localDate} ${localTime}` });
|
|
122
|
+
} finally {
|
|
123
|
+
if (savedTZ === undefined) delete process.env.TZ; else process.env.TZ = savedTZ;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// BUG-5: 内存监控定时器必须被 stop()/close() 清理
|
|
127
|
+
const mdb = new Database(':memory:', { autoSave: false });
|
|
128
|
+
await mdb.createTable('m', { id: 'integer' });
|
|
129
|
+
mdb._startMonitor();
|
|
130
|
+
ok('BUG-5 monitor 已启动', !!mdb._monitorTimer);
|
|
131
|
+
await mdb.stop();
|
|
132
|
+
ok('BUG-5 stop() 清理 monitorTimer', mdb._monitorTimer === null);
|
|
133
|
+
mdb._startMonitor();
|
|
134
|
+
mdb.close();
|
|
135
|
+
ok('BUG-5 close() 清理 monitorTimer', mdb._monitorTimer === null);
|
|
136
|
+
|
|
137
|
+
// BUG-6 / BUG-8: insertMany 在副本上归一,且不污染调用方对象
|
|
138
|
+
const tdb = new Database(':memory:', { autoSave: false });
|
|
139
|
+
await tdb.createTable('t', {
|
|
140
|
+
id: { type: 'integer', primaryKey: true, autoIncrement: true },
|
|
141
|
+
d: 'date', st: { type: 'string', default: 'x' }, age: 'integer'
|
|
142
|
+
});
|
|
143
|
+
const srcDate = new Date('2024-01-15T00:00:00Z');
|
|
144
|
+
const caller = [{ d: srcDate, age: '25' }];
|
|
145
|
+
const before = JSON.stringify(caller[0]);
|
|
146
|
+
const ret = tdb.t.insertMany(caller);
|
|
147
|
+
ok('BUG-6 insertMany 不污染调用方对象', JSON.stringify(caller[0]) === before, caller[0]);
|
|
148
|
+
ok('BUG-6 insertMany 返回归一后的新数组', Array.isArray(ret) && ret !== caller && ret[0].st === 'x' && ret[0].id === 1, ret);
|
|
149
|
+
const stored = tdb.t.find({})[0];
|
|
150
|
+
ok('BUG-8 insertMany 归一年龄为 number', typeof stored.age === 'number' && stored.age === 25, stored.age);
|
|
151
|
+
const p2 = n => String(n).padStart(2, '0');
|
|
152
|
+
const wantDate = `${srcDate.getFullYear()}-${p2(srcDate.getMonth() + 1)}-${p2(srcDate.getDate())}`;
|
|
153
|
+
ok('BUG-8 insertMany 归一日期为本地字符串', stored.d === wantDate, { got: stored.d, want: wantDate });
|
|
154
|
+
|
|
155
|
+
// BUG-4: information_schema 过滤条件必须透传 ctx(@@会话变量)
|
|
156
|
+
const idb = new Database(':memory:', { autoSave: false });
|
|
157
|
+
await idb.createTable('ifx', { id: 'integer' });
|
|
158
|
+
const ir = await executeSQL(idb, 'SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_NAME = @@tn', { session: { sysvars: { tn: 'ifx' } } });
|
|
159
|
+
ok('BUG-4 information_schema WHERE 透传 ctx', ir.rows.length === 1 && ir.rows[0][0] === 'ifx', ir.rows);
|
|
160
|
+
}
|
|
161
|
+
|
|
107
162
|
console.log(failed === 0 ? `\nALL ${passed} QUERY-OPT TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
|
|
108
163
|
process.exit(failed === 0 ? 0 : 1);
|
|
109
164
|
})().catch(e => { console.error('FATAL', e); process.exit(1); });
|
package/test/regress-5.1.0.js
CHANGED
|
@@ -17,7 +17,7 @@ const path = require('path');
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const os = require('os');
|
|
19
19
|
const net = require('net');
|
|
20
|
-
const { execFileSync } = require('child_process');
|
|
20
|
+
const { execFileSync, spawn } = require('child_process');
|
|
21
21
|
|
|
22
22
|
const ROOT = path.join(__dirname, '..');
|
|
23
23
|
const Database = require(path.join(ROOT, 'lib/database'));
|
|
@@ -180,12 +180,37 @@ function respClient(port) {
|
|
|
180
180
|
/* ============ CLI (N1) ============ */
|
|
181
181
|
{
|
|
182
182
|
const pkg = require(path.join(ROOT, 'package.json')).version;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
// Windows 上 spawnSync/execFileSync 复用同一个 node.exe 常报 EBUSY
|
|
184
|
+
// (文件被占用),会让整份测试崩在最前面。改用异步 spawn 并容忍失败。
|
|
185
|
+
const runCli = (args) => new Promise((resolve) => {
|
|
186
|
+
let child;
|
|
187
|
+
try {
|
|
188
|
+
child = spawn(process.execPath, [path.join(ROOT, 'bin/jsql'), ...args]);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
return resolve({ ok: false, out: '', err: String(e.message || e) });
|
|
191
|
+
}
|
|
192
|
+
let out = '', err = '';
|
|
193
|
+
child.stdout.on('data', d => { out += d; });
|
|
194
|
+
child.stderr.on('data', d => { err += d; });
|
|
195
|
+
child.on('error', e => resolve({ ok: false, out, err: String(e.message || e) }));
|
|
196
|
+
child.on('close', code => resolve({ ok: code === 0, out: out.trim(), err: err.trim() }));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const skipCli = (why) => {
|
|
200
|
+
console.log('[SKIP] CLI 测试无法在本环境执行: ' + why);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const v = await runCli(['--version']);
|
|
204
|
+
if (v.ok) ok('cli --version reports package version', v.out === pkg, v.out);
|
|
205
|
+
else skipCli(v.err || 'spawn 失败');
|
|
206
|
+
|
|
207
|
+
const v2 = await runCli(['version']);
|
|
208
|
+
if (v2.ok) ok('cli version command matches', v2.out === pkg, v2.out);
|
|
209
|
+
else skipCli(v2.err || 'spawn 失败');
|
|
210
|
+
|
|
211
|
+
const help = await runCli(['ui', '--help']);
|
|
212
|
+
if (help.ok) ok('cli subcommand --help does not crash', help.out.includes('127.0.0.1') && help.out.includes('auth-token'));
|
|
213
|
+
else skipCli(help.err || 'spawn 失败');
|
|
189
214
|
}
|
|
190
215
|
|
|
191
216
|
/* ============ WebUI CORS + auth (N2 / S2) ============ */
|
package/test/sql-parser.test.js
CHANGED
|
@@ -282,6 +282,17 @@ const rowsOf = (r) => (Array.isArray(r) ? r[0] : r).rows;
|
|
|
282
282
|
ok('ROUND(1.5, 101) 不再抛 RangeError', await roundOf('ROUND(1.5, 101)') === 1.5);
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/* ================= BUG 修复: BETWEEN 含 NULL 的三值逻辑 ================= */
|
|
286
|
+
console.log('\n--- BUG 修复: BETWEEN NULL 三值逻辑 ---');
|
|
287
|
+
{
|
|
288
|
+
const nrows = async (sql) => rowsOf(await executeSQL(engine, sql, OPTS)).length;
|
|
289
|
+
// engine 行:sal 100/200/300/300
|
|
290
|
+
ok('sal BETWEEN NULL AND 400 不选行', await nrows('SELECT id FROM emp WHERE sal BETWEEN NULL AND 400') === 0);
|
|
291
|
+
ok('NOT sal BETWEEN NULL AND 400 不误选行', await nrows('SELECT id FROM emp WHERE NOT sal BETWEEN NULL AND 400') === 0);
|
|
292
|
+
ok('对照 NOT sal = NULL 也不选行', await nrows('SELECT id FROM emp WHERE NOT sal = NULL') === 0);
|
|
293
|
+
ok('正常 BETWEEN 区间仍工作', await nrows('SELECT id FROM emp WHERE sal BETWEEN 100 AND 200') === 2);
|
|
294
|
+
}
|
|
295
|
+
|
|
285
296
|
/* ================= 5.6.0: 相关子查询(曾静默返回 null) ================= */
|
|
286
297
|
console.log('\n--- 5.6.0: 相关子查询 ---');
|
|
287
298
|
{
|
|
@@ -459,6 +470,120 @@ const rowsOf = (r) => (Array.isArray(r) ? r[0] : r).rows;
|
|
|
459
470
|
rbErr instanceof Error && /not supported/.test(rbErr.message));
|
|
460
471
|
}
|
|
461
472
|
|
|
473
|
+
/* ================= BUG 修复: 静默算错的标量函数(对齐 MySQL 8.0) ================= */
|
|
474
|
+
console.log('\n--- BUG 修复: INSTR / REPEAT / SUM 空集 / CAST / ROUND ---');
|
|
475
|
+
{
|
|
476
|
+
const scalar = async (sql) => rowsOf(await executeSQL(engine, sql, OPTS))[0][0];
|
|
477
|
+
// INSTR(str, substr):参数顺序与 LOCATE 相反,此前共用代码导致恒为 0
|
|
478
|
+
ok("INSTR('abc','bc') → 2", await scalar("SELECT INSTR('abc', 'bc') AS x") === 2);
|
|
479
|
+
ok("INSTR('abc','z') → 0", await scalar("SELECT INSTR('abc', 'z') AS x") === 0);
|
|
480
|
+
ok("LOCATE 不受影响", await scalar("SELECT LOCATE('bc', 'abc') AS x") === 2);
|
|
481
|
+
// REPEAT 此前完全缺失
|
|
482
|
+
ok("REPEAT('ab',3) → 'ababab'", await scalar("SELECT REPEAT('ab', 3) AS x") === 'ababab');
|
|
483
|
+
ok("REPEAT('ab',0) → ''", await scalar("SELECT REPEAT('ab', 0) AS x") === '');
|
|
484
|
+
// SUM 空集为 NULL(此前返回 0)
|
|
485
|
+
ok('SUM 空结果集 → null',
|
|
486
|
+
await scalar('SELECT SUM(sal) AS x FROM emp WHERE sal > 100000') === null);
|
|
487
|
+
ok('SUM 非空仍正确', await scalar('SELECT SUM(sal) AS x FROM emp') === 900);
|
|
488
|
+
// CAST(x AS INT):数字前缀解析、非法为 0(此前返回 null)
|
|
489
|
+
ok("CAST('abc' AS INT) → 0", await scalar("SELECT CAST('abc' AS INT) AS x") === 0);
|
|
490
|
+
ok("CAST('12abc' AS INT) → 12", await scalar("SELECT CAST('12abc' AS INT) AS x") === 12);
|
|
491
|
+
ok("CAST('3.9' AS INT) → 3", await scalar("SELECT CAST('3.9' AS INT) AS x") === 3);
|
|
492
|
+
// ROUND 中点浮点偏差
|
|
493
|
+
ok('ROUND(1.005, 2) → 1.01', await scalar('SELECT ROUND(1.005, 2) AS x') === 1.01);
|
|
494
|
+
ok('ROUND(2.5, 0) → 3(半远离零)', await scalar('SELECT ROUND(2.5, 0) AS x') === 3);
|
|
495
|
+
ok('ROUND(-3.5, 0) → -4', await scalar('SELECT ROUND(-3.5, 0) AS x') === -4);
|
|
496
|
+
ok('ROUND(123.456, -1) → 120(负位数不崩溃)', await scalar('SELECT ROUND(123.456, -1) AS x') === 120);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/* ================= BUG 修复: SELECT 列表的括号/标量布尔 ================= */
|
|
500
|
+
console.log('\n--- BUG 修复: SELECT 列表支持括号与标量布尔 ---');
|
|
501
|
+
{
|
|
502
|
+
const scalar = async (sql) => rowsOf(await executeSQL(engine, sql, OPTS))[0][0];
|
|
503
|
+
ok('SELECT (1+2) → 3', await scalar('SELECT (1+2) AS x') === 3);
|
|
504
|
+
ok('SELECT (2*(3+4)) → 14', await scalar('SELECT (2*(3+4)) AS x') === 14);
|
|
505
|
+
ok('SELECT NOT 0 → 1', await scalar('SELECT NOT 0 AS x') === 1);
|
|
506
|
+
ok('SELECT NOT 1 → 0', await scalar('SELECT NOT 1 AS x') === 0);
|
|
507
|
+
ok("SELECT 'a' LIKE 'b' → 0", await scalar("SELECT 'a' LIKE 'b' AS x") === 0);
|
|
508
|
+
ok("SELECT 'a' LIKE 'a' → 1", await scalar("SELECT 'a' LIKE 'a' AS x") === 1);
|
|
509
|
+
ok('SELECT 1 = 1 → 1', await scalar('SELECT 1 = 1 AS x') === 1);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* ================= BUG 修复: 日期加减的月末钳制(MySQL 语义) ================= */
|
|
513
|
+
console.log('\n--- BUG 修复: DATE_ADD/SUB 月末钳制 ---');
|
|
514
|
+
{
|
|
515
|
+
const expr = async (sql) => rowsOf(await executeSQL(engine, sql, OPTS))[0][0];
|
|
516
|
+
// JS setMonth/setFullYear 会溢出(1-31 +1 月 → 3-03),MySQL 夹到月末(→ 2-28)
|
|
517
|
+
ok('1-31 +1 MONTH → 2-28',
|
|
518
|
+
await expr("SELECT DATE_ADD('2023-01-31', INTERVAL 1 MONTH) AS x") === '2023-02-28');
|
|
519
|
+
ok('闰年 2-29 +1 YEAR → 2-28',
|
|
520
|
+
await expr("SELECT DATE_ADD('2024-02-29', INTERVAL 1 YEAR) AS x") === '2025-02-28');
|
|
521
|
+
ok('3-31 +1 QUARTER → 6-30',
|
|
522
|
+
await expr("SELECT DATE_ADD('2023-03-31', INTERVAL 1 QUARTER) AS x") === '2023-06-30');
|
|
523
|
+
ok('2-29 +1 YEAR → 2-28',
|
|
524
|
+
await expr("SELECT DATE_ADD('2020-02-29', INTERVAL 1 YEAR) AS x") === '2021-02-28');
|
|
525
|
+
ok('非月末 +1 MONTH 不变',
|
|
526
|
+
await expr("SELECT DATE_ADD('2023-01-15', INTERVAL 1 MONTH) AS x") === '2023-02-15');
|
|
527
|
+
ok('DATE_SUB 月末同样钳制',
|
|
528
|
+
await expr("SELECT DATE_SUB('2023-03-31', INTERVAL 1 MONTH) AS x") === '2023-02-28');
|
|
529
|
+
ok('12-31 +2 MONTH → 闰年 2-29',
|
|
530
|
+
await expr("SELECT DATE_ADD('2023-12-31', INTERVAL 2 MONTH) AS x") === '2024-02-29');
|
|
531
|
+
ok('DAY 单位不受影响',
|
|
532
|
+
await expr("SELECT DATE_ADD('2023-01-31', INTERVAL 1 DAY) AS x") === '2023-02-01');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* ---- ORDER BY 引用与聚合函数同名的输出别名(曾报 got 'AVG')---- */
|
|
536
|
+
console.log('\n--- BUG 修复: 分组查询的 ORDER BY ---');
|
|
537
|
+
{
|
|
538
|
+
const run = async (sql) => executeSQL(engine, sql, OPTS);
|
|
539
|
+
// mock 的 emp 表:eng(sal 100,200) / ops(sal 300,300)
|
|
540
|
+
// → SUM: eng=300 ops=600 | AVG: eng=150 ops=300 | MAX: eng=200 ops=300 | MIN: eng=100 ops=300
|
|
541
|
+
|
|
542
|
+
// (1) 别名恰好是聚合函数名:AVG/SUM/COUNT/MAX/MIN 都在关键字表里,
|
|
543
|
+
// 词法器标成 keyword 后既进不了 parseColumnRef,也不满足「后面跟 (」的聚合调用条件。
|
|
544
|
+
for (const [fn, agg, expectFirst] of [
|
|
545
|
+
['AVG', 'ROUND(AVG(sal),1)', 300],
|
|
546
|
+
['SUM', 'SUM(sal)', 600],
|
|
547
|
+
['MAX', 'MAX(sal)', 300],
|
|
548
|
+
['MIN', 'MIN(sal)', 300],
|
|
549
|
+
]) {
|
|
550
|
+
const sql = `SELECT dept, ${agg} AS ${fn} FROM emp GROUP BY dept ORDER BY ${fn} DESC`;
|
|
551
|
+
let rows = null, err = null;
|
|
552
|
+
try { rows = (await run(sql)).rows; } catch (e) { err = e; }
|
|
553
|
+
ok(`ORDER BY ${fn}(别名与聚合函数同名)`, err === null && Array.isArray(rows) && rows.length === 2,
|
|
554
|
+
err ? String(err.message || err) : rows);
|
|
555
|
+
ok(` └ ${fn} DESC 排序结果正确(首行 ${expectFirst})`,
|
|
556
|
+
!!rows && rows[0] && rows[0][1] === expectFirst, rows);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// (2) COUNT 两组都是 2,只能验证可解析 + 稳定
|
|
560
|
+
const c = await run(`SELECT dept, COUNT(*) AS COUNT FROM emp GROUP BY dept ORDER BY COUNT DESC`);
|
|
561
|
+
ok('ORDER BY COUNT(别名与聚合函数同名)', c.rows.length === 2, c.rows);
|
|
562
|
+
|
|
563
|
+
// (3) 分组查询此前 ORDER BY 完全不生效(直接 return,跳过了排序)
|
|
564
|
+
const desc = await run(`SELECT dept, SUM(sal) AS total FROM emp GROUP BY dept ORDER BY total DESC`);
|
|
565
|
+
ok('分组 + ORDER BY 别名 DESC 生效(此前被忽略)',
|
|
566
|
+
desc.rows.length === 2 && desc.rows[0][0] === 'ops' && desc.rows[0][1] === 600, desc.rows);
|
|
567
|
+
const asc = await run(`SELECT dept, SUM(sal) AS total FROM emp GROUP BY dept ORDER BY total ASC`);
|
|
568
|
+
ok('分组 + ORDER BY 别名 ASC 生效',
|
|
569
|
+
asc.rows[0][0] === 'eng' && asc.rows[0][1] === 300, asc.rows);
|
|
570
|
+
const byPos = await run(`SELECT dept, SUM(sal) AS total FROM emp GROUP BY dept ORDER BY 2 DESC`);
|
|
571
|
+
ok('分组 + ORDER BY 位置序号生效',
|
|
572
|
+
byPos.rows[0][0] === 'ops' && byPos.rows[0][1] === 600, byPos.rows);
|
|
573
|
+
const byCol = await run(`SELECT dept, sal FROM emp GROUP BY dept ORDER BY dept DESC`);
|
|
574
|
+
ok('分组 + ORDER BY 普通列生效',
|
|
575
|
+
byCol.rows[0][0] === 'ops', byCol.rows);
|
|
576
|
+
|
|
577
|
+
// (4) 嵌套聚合:ROUND(AVG(x),1) 曾恒为 null
|
|
578
|
+
const avg = await run(`SELECT dept, ROUND(AVG(sal),1) AS AVG FROM emp GROUP BY dept ORDER BY AVG DESC`);
|
|
579
|
+
ok('嵌套聚合 ROUND(AVG(sal),1) 返回数值而非 null',
|
|
580
|
+
avg.rows[0][1] === 300 && avg.rows[1][1] === 150, avg.rows);
|
|
581
|
+
|
|
582
|
+
// (5) LIMIT 在分组路径上也要生效
|
|
583
|
+
const lim = await run(`SELECT dept, SUM(sal) AS total FROM emp GROUP BY dept ORDER BY total DESC LIMIT 1`);
|
|
584
|
+
ok('分组 + ORDER BY + LIMIT 只返回 1 行', lim.rows.length === 1 && lim.rows[0][0] === 'ops', lim.rows);
|
|
585
|
+
}
|
|
586
|
+
|
|
462
587
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
463
588
|
process.exit(fail === 0 ? 0 : 1);
|
|
464
589
|
})();
|