jsql-neo 6.5.9 → 6.5.11
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 +1 -1
- package/index.js +1 -1
- package/lib/sql.js +287 -34
- package/lib/table.js +5 -0
- package/lib/wasm_client.js +12 -0
- package/native/jsql-neo-native.node +0 -0
- package/package.json +1 -1
- package/test/bug-regression.test.js +87 -0
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.
|
|
6
|
+
> **v6.5.11** — 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
|

|
|
9
9
|

|
package/index.js
CHANGED
package/lib/sql.js
CHANGED
|
@@ -708,6 +708,11 @@ class Parser {
|
|
|
708
708
|
break;
|
|
709
709
|
} else {
|
|
710
710
|
const col = this.parseTableName();
|
|
711
|
+
// 重复列名必须报错:对象键覆盖会静默丢列(schema[col] = def 直接覆盖),
|
|
712
|
+
// 用户 CREATE TABLE d(a INT, a INT) 会悄悄变成单列 a。
|
|
713
|
+
if (schema[col] !== undefined) {
|
|
714
|
+
throw new Error(`Duplicate column name '${col}' in table '${name}'`);
|
|
715
|
+
}
|
|
711
716
|
const def = this.parseColumnDef();
|
|
712
717
|
schema[col] = def;
|
|
713
718
|
if (def.primaryKey) hasPk = true;
|
|
@@ -755,6 +760,8 @@ class Parser {
|
|
|
755
760
|
const mapped = typeMap[type];
|
|
756
761
|
if (!mapped) throw new Error(`Unsupported column type: ${typeTok.value}`);
|
|
757
762
|
def.type = mapped;
|
|
763
|
+
// JSON / JSONB 列保留原始标记,供数据层校验非法 JSON({bad json 这类字符串会被 JSON.parse 拒绝)
|
|
764
|
+
if (type === 'json' || type === 'jsonb') def.jsonType = true;
|
|
758
765
|
if (/^(BIG)?SERIAL$/i.test(typeRaw)) { def.autoIncrement = true; def.required = true; }
|
|
759
766
|
|
|
760
767
|
// 长度限制: TEXT(255) / VARCHAR(100) / INTEGER(11) ...
|
|
@@ -1079,6 +1086,22 @@ class Parser {
|
|
|
1079
1086
|
}
|
|
1080
1087
|
|
|
1081
1088
|
parseValueAtom() {
|
|
1089
|
+
// MySQL 前缀运算符:BINARY expr —— 强制大小写敏感比较。
|
|
1090
|
+
// 仅当后面确实是值起始 token 才当作运算符,避免误吞裸列名 `binary`
|
|
1091
|
+
// (`SELECT binary FROM t` / `WHERE binary = 1` 仍按列名解析)。
|
|
1092
|
+
if (this.isWord('BINARY')) {
|
|
1093
|
+
const nt = this.peek(1);
|
|
1094
|
+
const startVal = nt && (nt.type === 'number' || nt.type === 'string' || nt.type === 'ident' || nt.type === 'param' ||
|
|
1095
|
+
(nt.type === 'op' && (nt.value === '(' || nt.value === '-' || nt.value === '+')) ||
|
|
1096
|
+
(nt.type === 'keyword' && (nt.value === 'NULL' || nt.value === 'TRUE' || nt.value === 'FALSE' || nt.value === 'DEFAULT')));
|
|
1097
|
+
if (startVal) {
|
|
1098
|
+
this.next(); // 消费 BINARY
|
|
1099
|
+
const lt = this.peek();
|
|
1100
|
+
if (lt.type === 'string' || lt.type === 'number') { const lit = this.next(); return { type: 'binary', operand: { type: 'literal', value: lit.value } }; }
|
|
1101
|
+
const operand = this.parseValue();
|
|
1102
|
+
return { type: 'binary', operand };
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1082
1105
|
const t = this.next();
|
|
1083
1106
|
if (t.type === 'number' || t.type === 'string') return t.value;
|
|
1084
1107
|
// MySQL: ON DUPLICATE KEY UPDATE col = VALUES(col) —— 引用"本次本应插入的值"
|
|
@@ -1583,9 +1606,30 @@ class Parser {
|
|
|
1583
1606
|
let where = null;
|
|
1584
1607
|
if (this.isKeyword('WHERE')) { this.next(); where = this.parseExpr(); }
|
|
1585
1608
|
else console.warn(`⚠️ UPDATE without WHERE clause — this will modify ALL rows in '${table}'`);
|
|
1609
|
+
// UPDATE ... ORDER BY ... LIMIT n(与 DELETE 对称;按排序后的前 n 行更新)
|
|
1610
|
+
let orderBy = null, limit = null;
|
|
1611
|
+
if (this.isKeyword('ORDER')) {
|
|
1612
|
+
this.next(); this.expectKeyword('BY');
|
|
1613
|
+
orderBy = [];
|
|
1614
|
+
for (;;) {
|
|
1615
|
+
const col = this.parseColumnRef();
|
|
1616
|
+
let dir = 'asc';
|
|
1617
|
+
if (this.isKeyword('ASC')) this.next();
|
|
1618
|
+
else if (this.isKeyword('DESC')) { this.next(); dir = 'desc'; }
|
|
1619
|
+
orderBy.push({ column: col, dir });
|
|
1620
|
+
if (this.peek().type === 'op' && this.peek().value === ',') { this.next(); continue; }
|
|
1621
|
+
break;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
if (this.isKeyword('LIMIT')) {
|
|
1625
|
+
this.next();
|
|
1626
|
+
limit = this.parseValue();
|
|
1627
|
+
if (typeof limit === 'bigint') limit = Number(limit <= BigInt(Number.MAX_SAFE_INTEGER) ? limit : BigInt(Number.MAX_SAFE_INTEGER));
|
|
1628
|
+
if (typeof limit === 'number' && limit < 0) throw new Error(`LIMIT must be a non-negative integer, got ${limit}`);
|
|
1629
|
+
}
|
|
1586
1630
|
const returning = this.parseOptionalReturning();
|
|
1587
1631
|
this.optionalTailSemicolon();
|
|
1588
|
-
return { type: 'update', table, assignments, where, returning };
|
|
1632
|
+
return { type: 'update', table, assignments, where, orderBy, limit, returning };
|
|
1589
1633
|
}
|
|
1590
1634
|
|
|
1591
1635
|
parseDelete() {
|
|
@@ -2191,6 +2235,37 @@ class Parser {
|
|
|
2191
2235
|
}
|
|
2192
2236
|
|
|
2193
2237
|
parseOperand() {
|
|
2238
|
+
// MySQL 前缀运算符:BINARY expr —— 强制大小写敏感比较。
|
|
2239
|
+
if (this.isWord('BINARY')) {
|
|
2240
|
+
const nt = this.peek(1);
|
|
2241
|
+
const startVal = nt && (nt.type === 'number' || nt.type === 'string' || nt.type === 'ident' || nt.type === 'param' ||
|
|
2242
|
+
(nt.type === 'op' && (nt.value === '(' || nt.value === '-' || nt.value === '+')) ||
|
|
2243
|
+
(nt.type === 'keyword' && (nt.value === 'NULL' || nt.value === 'TRUE' || nt.value === 'FALSE' || nt.value === 'DEFAULT')));
|
|
2244
|
+
if (startVal) {
|
|
2245
|
+
this.next(); // 消费 BINARY
|
|
2246
|
+
// 字面量要包成 literal,否则 resolveOperand 把 'ABC' 当列名
|
|
2247
|
+
const lt = this.peek();
|
|
2248
|
+
if (lt.type === 'string' || lt.type === 'number') { const lit = this.next(); return { type: 'binary', operand: { type: 'literal', value: lit.value } }; }
|
|
2249
|
+
const operand = this.parseValue();
|
|
2250
|
+
return { type: 'binary', operand };
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
// MySQL 区间字面量:INTERVAL n UNIT(date + INTERVAL 3 DAY / date - INTERVAL 1 MONTH)
|
|
2254
|
+
if (this.isWord('INTERVAL')) {
|
|
2255
|
+
this.next();
|
|
2256
|
+
const nTok = this.peek();
|
|
2257
|
+
let nVal;
|
|
2258
|
+
if (nTok.type === 'op' && (nTok.value === '-' || nTok.value === '+')) {
|
|
2259
|
+
this.next();
|
|
2260
|
+
const numTok = this.next();
|
|
2261
|
+
nVal = (nTok.value === '-' ? -1 : 1) * Number(numTok.value);
|
|
2262
|
+
} else {
|
|
2263
|
+
this.next();
|
|
2264
|
+
nVal = Number(nTok.value);
|
|
2265
|
+
}
|
|
2266
|
+
const unitTok = this.next();
|
|
2267
|
+
return { type: 'interval', value: nVal, unit: String(unitTok.value).toUpperCase() };
|
|
2268
|
+
}
|
|
2194
2269
|
const t = this.next();
|
|
2195
2270
|
// P1-20: SELECT -col 报错——parseOperand 不处理负号
|
|
2196
2271
|
if (t.type === 'op' && t.value === '-') {
|
|
@@ -2871,6 +2946,8 @@ function resolveOperand(operand, row, ctx) {
|
|
|
2871
2946
|
case 'value':
|
|
2872
2947
|
case 'literal':
|
|
2873
2948
|
return operand.value;
|
|
2949
|
+
case 'binary':
|
|
2950
|
+
return resolveOperand(operand.operand, row, ctx);
|
|
2874
2951
|
case 'sysvar': {
|
|
2875
2952
|
const s = (ctx && ctx.session && ctx.session.sysvars) || {};
|
|
2876
2953
|
const name = String(operand.name).toLowerCase();
|
|
@@ -2909,6 +2986,32 @@ function resolveOperand(operand, row, ctx) {
|
|
|
2909
2986
|
return undefined;
|
|
2910
2987
|
}
|
|
2911
2988
|
case 'arith': {
|
|
2989
|
+
// date + INTERVAL n UNIT / date - INTERVAL n UNIT(MySQL 区间算术)
|
|
2990
|
+
const lIv = operand.left && operand.left.type === 'interval';
|
|
2991
|
+
const rIv = operand.right && operand.right.type === 'interval';
|
|
2992
|
+
if ((operand.op === '+' || operand.op === '-') && (lIv || rIv)) {
|
|
2993
|
+
if (lIv && operand.op === '-') return null; // INTERVAL - date 无意义
|
|
2994
|
+
const ivNode = lIv ? operand.left : operand.right;
|
|
2995
|
+
const dateSide = lIv ? resolveOperand(operand.right, row, ctx) : resolveOperand(operand.left, row, ctx);
|
|
2996
|
+
const base = parseSqlDate(dateSide);
|
|
2997
|
+
if (!base) return null;
|
|
2998
|
+
const sign = operand.op === '-' ? -1 : 1;
|
|
2999
|
+
const isDateOnly = String(dateSide).trim().length <= 10;
|
|
3000
|
+
const d = new Date(base.getTime());
|
|
3001
|
+
const n = sign * Number(ivNode.value || 0);
|
|
3002
|
+
switch (String(ivNode.unit || '').toUpperCase()) {
|
|
3003
|
+
case 'DAY': case 'DAYS': d.setDate(d.getDate() + n); break;
|
|
3004
|
+
case 'WEEK': case 'WEEKS': d.setDate(d.getDate() + n * 7); break;
|
|
3005
|
+
case 'MONTH': case 'MONTHS': addMonthsClamped(d, n); break;
|
|
3006
|
+
case 'QUARTER': addMonthsClamped(d, n * 3); break;
|
|
3007
|
+
case 'YEAR': case 'YEARS': addMonthsClamped(d, n * 12); break;
|
|
3008
|
+
case 'HOUR': case 'HOURS': d.setHours(d.getHours() + n); break;
|
|
3009
|
+
case 'MINUTE': case 'MINUTES': d.setMinutes(d.getMinutes() + n); break;
|
|
3010
|
+
case 'SECOND': case 'SECONDS': d.setSeconds(d.getSeconds() + n); break;
|
|
3011
|
+
default: return null;
|
|
3012
|
+
}
|
|
3013
|
+
return fmtSqlDate(isDateOnly, d);
|
|
3014
|
+
}
|
|
2912
3015
|
const l = resolveOperand(operand.left, row, ctx);
|
|
2913
3016
|
const r = resolveOperand(operand.right, row, ctx);
|
|
2914
3017
|
if (l === null || r === null || l === undefined || r === undefined) return null;
|
|
@@ -3014,10 +3117,6 @@ function resolveOperand(operand, row, ctx) {
|
|
|
3014
3117
|
// 布尔类节点出现在"取值"位置时(如 IF(status = 'paid', ...)、SUM(IF(a > b,1,0))),
|
|
3015
3118
|
// 求值成 true/false 供上层按真值判断
|
|
3016
3119
|
case 'compare':
|
|
3017
|
-
case 'and':
|
|
3018
|
-
case 'or':
|
|
3019
|
-
case 'xor':
|
|
3020
|
-
case 'not':
|
|
3021
3120
|
case 'in':
|
|
3022
3121
|
case 'isNull':
|
|
3023
3122
|
case 'isTruth':
|
|
@@ -3026,6 +3125,25 @@ function resolveOperand(operand, row, ctx) {
|
|
|
3026
3125
|
case 'like':
|
|
3027
3126
|
case 'between':
|
|
3028
3127
|
return evaluateExpr(operand, row, ctx);
|
|
3128
|
+
case 'and': {
|
|
3129
|
+
const a = sqlTruthy(resolveOperand(operand.left, row, ctx));
|
|
3130
|
+
const b = sqlTruthy(resolveOperand(operand.right, row, ctx));
|
|
3131
|
+
return sqlAnd(a, b);
|
|
3132
|
+
}
|
|
3133
|
+
case 'or': {
|
|
3134
|
+
const a = sqlTruthy(resolveOperand(operand.left, row, ctx));
|
|
3135
|
+
const b = sqlTruthy(resolveOperand(operand.right, row, ctx));
|
|
3136
|
+
return sqlOr(a, b);
|
|
3137
|
+
}
|
|
3138
|
+
case 'xor': {
|
|
3139
|
+
const a = sqlTruthy(resolveOperand(operand.left, row, ctx));
|
|
3140
|
+
const b = sqlTruthy(resolveOperand(operand.right, row, ctx));
|
|
3141
|
+
return sqlXor(a, b);
|
|
3142
|
+
}
|
|
3143
|
+
case 'not': {
|
|
3144
|
+
const v = sqlTruthy(resolveOperand(operand.expr, row, ctx));
|
|
3145
|
+
return v === null ? null : (v ? 0 : 1);
|
|
3146
|
+
}
|
|
3029
3147
|
case 'unit': {
|
|
3030
3148
|
// 裸时间单位(MONTH / DAY …):优先当列取,取不到就返回单位名本身
|
|
3031
3149
|
const lower = String(operand.name).toLowerCase();
|
|
@@ -4313,6 +4431,46 @@ function normalizeCmpValue(a, other) {
|
|
|
4313
4431
|
return otherIsNumeric ? (a ? 1 : 0) : a;
|
|
4314
4432
|
}
|
|
4315
4433
|
|
|
4434
|
+
// MySQL 默认 collation(utf8mb4_general_ci)下字符串比较不区分大小写,
|
|
4435
|
+
// 与 LIKE 保持一致。cs=true 表示显式 BINARY/COLLATE 强制大小写敏感。
|
|
4436
|
+
function ciEq(a, b, cs) {
|
|
4437
|
+
if (cs) return a === b;
|
|
4438
|
+
if (typeof a === 'string' && typeof b === 'string') return a.toLowerCase() === b.toLowerCase();
|
|
4439
|
+
return String(a) === String(b);
|
|
4440
|
+
}
|
|
4441
|
+
function ciCompare(a, b, cs) {
|
|
4442
|
+
if (cs) return a === b ? 0 : (a < b ? -1 : 1);
|
|
4443
|
+
const as = String(a).toLowerCase(), bs = String(b).toLowerCase();
|
|
4444
|
+
return as === bs ? 0 : (as < bs ? -1 : 1);
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
// ── 三值逻辑(SQL 标准:TRUE / FALSE / UNKNOWN)──────────────────────
|
|
4448
|
+
// NULL 参与布尔运算时结果不是 0/1,而是 UNKNOWN(用 null 表示)。
|
|
4449
|
+
// 此前 resolveOperand 没有 and/or/xor/not 分支,SELECT TRUE AND TRUE 落到
|
|
4450
|
+
// default 返回 undefined → 数值化成 0,所有布尔表达式全部失效。
|
|
4451
|
+
function sqlTruthy(v) {
|
|
4452
|
+
if (v === null || v === undefined) return null;
|
|
4453
|
+
if (typeof v === 'boolean') return v;
|
|
4454
|
+
if (typeof v === 'number') return v !== 0;
|
|
4455
|
+
if (typeof v === 'string') return v !== '' && !(Number(v) === 0 && String(v).trim() !== '');
|
|
4456
|
+
return Boolean(v);
|
|
4457
|
+
}
|
|
4458
|
+
// 三值真值表,入参已归一化为 true / false / null,返回 1 / 0 / null
|
|
4459
|
+
function sqlAnd(a, b) {
|
|
4460
|
+
if (a === false || b === false) return 0;
|
|
4461
|
+
if (a === null || b === null) return null;
|
|
4462
|
+
return 1;
|
|
4463
|
+
}
|
|
4464
|
+
function sqlOr(a, b) {
|
|
4465
|
+
if (a === true || b === true) return 1;
|
|
4466
|
+
if (a === null || b === null) return null;
|
|
4467
|
+
return 0;
|
|
4468
|
+
}
|
|
4469
|
+
function sqlXor(a, b) {
|
|
4470
|
+
if (a === null || b === null) return null;
|
|
4471
|
+
return a !== b ? 1 : 0;
|
|
4472
|
+
}
|
|
4473
|
+
|
|
4316
4474
|
function evaluateExpr(expr, row, ctx) {
|
|
4317
4475
|
if (expr === null || expr === undefined) return false;
|
|
4318
4476
|
switch (expr.type) {
|
|
@@ -4329,12 +4487,14 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
4329
4487
|
case 'compare': {
|
|
4330
4488
|
const l = resolveOperand(expr.left, row, ctx);
|
|
4331
4489
|
const r = resolveOperand(expr.right, row, ctx);
|
|
4490
|
+
// BINARY 限定符:任一侧是 BINARY 操作数即强制大小写敏感
|
|
4491
|
+
const cs = !!(expr.cs || (expr.left && expr.left.type === 'binary') || (expr.right && expr.right.type === 'binary'));
|
|
4332
4492
|
// MySQL NULL 安全相等:NULL <=> NULL 为真;NULL <=> 值 为假
|
|
4333
4493
|
if (expr.op === '<=>') {
|
|
4334
4494
|
const ln = l === null || l === undefined;
|
|
4335
4495
|
const rn = r === null || r === undefined;
|
|
4336
4496
|
if (ln || rn) return ln && rn;
|
|
4337
|
-
return l
|
|
4497
|
+
return ciEq(l, r, cs);
|
|
4338
4498
|
}
|
|
4339
4499
|
// SQL 标准:与 NULL 的比较结果是 UNKNOWN,这里用 null 表示。
|
|
4340
4500
|
// 不能返回 false —— 否则 NOT (a > 1) 会把 a 为 NULL 的行错误纳入结果。
|
|
@@ -4349,11 +4509,19 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
4349
4509
|
const mixedNum = (isNum(nl) && isNumStr(nr)) || (isNumStr(nl) && isNum(nr));
|
|
4350
4510
|
if (expr.op === '=') {
|
|
4351
4511
|
if (mixedNum) return Number(nl) === Number(nr);
|
|
4352
|
-
return nl
|
|
4512
|
+
return ciEq(nl, nr, cs);
|
|
4513
|
+
}
|
|
4514
|
+
if (mixedNum) { const fn = OPERATORS[expr.op]; return fn(Number(nl), Number(nr)); }
|
|
4515
|
+
if (typeof nl === 'number' && typeof nr === 'number') { const fn = OPERATORS[expr.op]; return fn(nl, nr); }
|
|
4516
|
+
const c = ciCompare(nl, nr, cs);
|
|
4517
|
+
switch (expr.op) {
|
|
4518
|
+
case '!=': case '<>': return c !== 0;
|
|
4519
|
+
case '<': return c < 0;
|
|
4520
|
+
case '<=': return c <= 0;
|
|
4521
|
+
case '>': return c > 0;
|
|
4522
|
+
case '>=': return c >= 0;
|
|
4523
|
+
default: return c === 0;
|
|
4353
4524
|
}
|
|
4354
|
-
const fn = OPERATORS[expr.op];
|
|
4355
|
-
if (mixedNum) return fn(Number(nl), Number(nr));
|
|
4356
|
-
return typeof nl === 'number' && typeof nr === 'number' ? fn(nl, nr) : fn(String(nl), String(nr));
|
|
4357
4525
|
}
|
|
4358
4526
|
case 'isNull': {
|
|
4359
4527
|
const v = resolveOperand(expr.operand, row, ctx);
|
|
@@ -4365,6 +4533,8 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
4365
4533
|
if (v === null || v === undefined) return null; // NULL IN (...) → NULL(三值逻辑)
|
|
4366
4534
|
if (expr.subquery && expr.subquery._values !== undefined) expr.list = expr.subquery._values;
|
|
4367
4535
|
if (!expr.list) return expr.not ? true : false;
|
|
4536
|
+
const cs = !!(expr.cs || (expr.operand && expr.operand.type === 'binary') ||
|
|
4537
|
+
(expr.list && expr.list.some(x => x && x.type === 'binary')));
|
|
4368
4538
|
|
|
4369
4539
|
// 先找匹配:只要有一个非 NULL 元素等于 v,整个 IN 就是 TRUE ——
|
|
4370
4540
|
// 列表里别的位置有没有 NULL 都不影响。
|
|
@@ -4372,9 +4542,12 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
4372
4542
|
// 此前是先判「列表里有 NULL 就返回 NULL」,于是 `v IN (10, NULL)`
|
|
4373
4543
|
// 连 v=10 的行都匹配不上(实测返回 0 行,应为 2 行)。
|
|
4374
4544
|
// 三值逻辑里 NULL 只在**没匹配上**的时候才让结果变 UNKNOWN。
|
|
4375
|
-
const matched = expr.list.some(x =>
|
|
4376
|
-
|
|
4377
|
-
|
|
4545
|
+
const matched = expr.list.some(x => {
|
|
4546
|
+
// list 元素可能是表达式节点(如 IN (BINARY 'ABC')),需先求值;
|
|
4547
|
+
// 原始字面量('ABC' / 10)直接比较,不能走 resolveOperand(会把字符串当列名)。
|
|
4548
|
+
const xv = (x && typeof x === 'object') ? resolveOperand(x, row, ctx) : x;
|
|
4549
|
+
return xv !== null && xv !== undefined && (xv === v || ciEq(xv, v, cs));
|
|
4550
|
+
});
|
|
4378
4551
|
if (matched) return expr.not ? false : true;
|
|
4379
4552
|
|
|
4380
4553
|
// 没匹配上:列表里有 NULL → UNKNOWN,否则是确定的 FALSE
|
|
@@ -5255,22 +5428,38 @@ class SQLExecutor {
|
|
|
5255
5428
|
throw createError('ER_PARSE_ERROR', `Table '${statement.table}' has no primary key, UPDATE requires a primary key`);
|
|
5256
5429
|
}
|
|
5257
5430
|
const all = (await this.engine.find(statement.table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
|
|
5431
|
+
// UPDATE ... ORDER BY ... LIMIT n:先过滤、排序、截取,再逐行更新
|
|
5432
|
+
let candidates = all.filter(row => !statement.where || evaluateExpr(statement.where, row, this.ctx));
|
|
5433
|
+
if (statement.orderBy) {
|
|
5434
|
+
const cmp = (a, b) => {
|
|
5435
|
+
for (const o of statement.orderBy) {
|
|
5436
|
+
const av = resolveOperand({ type: 'column', name: o.column }, a, this.ctx);
|
|
5437
|
+
const bv = resolveOperand({ type: 'column', name: o.column }, b, this.ctx);
|
|
5438
|
+
if (av === bv) continue;
|
|
5439
|
+
if (av === undefined || av === null) return o.dir === 'asc' ? -1 : 1;
|
|
5440
|
+
if (bv === undefined || bv === null) return o.dir === 'asc' ? 1 : -1;
|
|
5441
|
+
const d = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv));
|
|
5442
|
+
if (d !== 0) return o.dir === 'asc' ? d : -d;
|
|
5443
|
+
}
|
|
5444
|
+
return 0;
|
|
5445
|
+
};
|
|
5446
|
+
candidates = candidates.slice().sort(cmp);
|
|
5447
|
+
}
|
|
5448
|
+
if (typeof statement.limit === 'number') candidates = candidates.slice(0, statement.limit);
|
|
5258
5449
|
let count = 0;
|
|
5259
|
-
for (const row of
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
data[col] = typeof val === 'object' && val !== null && val.type ? resolveOperand(val, row, this.ctx) : val;
|
|
5266
|
-
}
|
|
5267
|
-
// 必须 await:wasm / native client 的 updateById 是异步的,
|
|
5268
|
-
// 不等就会把「主键/唯一约束冲突」的异常变成未处理的 Promise 拒绝 ——
|
|
5269
|
-
// 表现为 UPDATE 静默成功、产生两行同主键,而调用方拿不到任何错误。
|
|
5270
|
-
// lib/database.js 的实现是同步的,await 同步值同样安全。
|
|
5271
|
-
await this.engine.updateById(statement.table, id, data);
|
|
5272
|
-
count++;
|
|
5450
|
+
for (const row of candidates) {
|
|
5451
|
+
const id = this._rowPkId(row, pkCols);
|
|
5452
|
+
if (id !== undefined) {
|
|
5453
|
+
const data = {};
|
|
5454
|
+
for (const [col, val] of statement.assignments) {
|
|
5455
|
+
data[col] = typeof val === 'object' && val !== null && val.type ? resolveOperand(val, row, this.ctx) : val;
|
|
5273
5456
|
}
|
|
5457
|
+
// 必须 await:wasm / native client 的 updateById 是异步的,
|
|
5458
|
+
// 不等就会把「主键/唯一约束冲突」的异常变成未处理的 Promise 拒绝 ——
|
|
5459
|
+
// 表现为 UPDATE 静默成功、产生两行同主键,而调用方拿不到任何错误。
|
|
5460
|
+
// lib/database.js 的实现是同步的,await 同步值同样安全。
|
|
5461
|
+
await this.engine.updateById(statement.table, id, data);
|
|
5462
|
+
count++;
|
|
5274
5463
|
}
|
|
5275
5464
|
}
|
|
5276
5465
|
await this.engine.flush();
|
|
@@ -5670,17 +5859,26 @@ class SQLExecutor {
|
|
|
5670
5859
|
// - 带表限定的条件(如 "o.amount"):检查表名/别名是否匹配
|
|
5671
5860
|
// - 不带表限定的条件(如 "amount"):检查当前表的 schema 是否包含该列
|
|
5672
5861
|
const filter = {};
|
|
5862
|
+
// 字符串列默认 collation 为 CI(大小写不敏感),而存储层 find 的等值
|
|
5863
|
+
// 过滤是严格比较(CS)。字符串等值若下推会被 CS 预筛,绕过 evaluateExpr 的
|
|
5864
|
+
// ciEq,导致 `code = 'abc'` 匹配不到 'ABC'。所以字符串/JSON 列不参与下推,
|
|
5865
|
+
// 交给 evaluateExpr 按 CI 求值;数值/布尔仍下推走索引。
|
|
5866
|
+
const isNonPushdownCol = (d) => {
|
|
5867
|
+
const t = String((d && d.type) || '').toLowerCase();
|
|
5868
|
+
return t === 'text' || t === 'varchar' || t === 'char' || t === 'string' ||
|
|
5869
|
+
t === 'blob' || t === 'clob' || t === 'json' || t === 'object' || t === 'array';
|
|
5870
|
+
};
|
|
5673
5871
|
for (const [key, val] of Object.entries(allFilters)) {
|
|
5674
5872
|
if (key.includes('.')) {
|
|
5675
5873
|
// 带表限定:检查是否匹配当前表的别名或表名
|
|
5676
5874
|
const tablePart = key.slice(0, key.lastIndexOf('.'));
|
|
5677
5875
|
if (tablePart === alias || tablePart === table) {
|
|
5678
5876
|
const colPart = key.slice(key.lastIndexOf('.') + 1);
|
|
5679
|
-
if (schema[colPart]) filter[colPart] = this._coerceValue(val, schema[colPart]);
|
|
5877
|
+
if (schema[colPart] && !isNonPushdownCol(schema[colPart])) filter[colPart] = this._coerceValue(val, schema[colPart]);
|
|
5680
5878
|
}
|
|
5681
5879
|
} else {
|
|
5682
5880
|
// 不带表限定:只下推到持有该列的表
|
|
5683
|
-
if (schema[key]) filter[key] = this._coerceValue(val, schema[key]);
|
|
5881
|
+
if (schema[key] && !isNonPushdownCol(schema[key])) filter[key] = this._coerceValue(val, schema[key]);
|
|
5684
5882
|
}
|
|
5685
5883
|
}
|
|
5686
5884
|
const useIndex = Object.keys(filter).length > 0 &&
|
|
@@ -6198,6 +6396,18 @@ class SQLExecutor {
|
|
|
6198
6396
|
walk(statement.where);
|
|
6199
6397
|
walk(statement.having);
|
|
6200
6398
|
walk(statement.orderBy);
|
|
6399
|
+
// ORDER BY 的列在解析器里是裸字符串(非 {type:'column'} 对象),walk 检不到。
|
|
6400
|
+
// 字符串可能是输出别名/位置序号(已在 known 里豁免),也可能是真实列;
|
|
6401
|
+
// 两者都不是 → 必须报错(此前静默忽略,用户 ORDER BY nonexistent_col 拿到原序)。
|
|
6402
|
+
if (Array.isArray(statement.orderBy)) {
|
|
6403
|
+
for (const o of statement.orderBy) {
|
|
6404
|
+
const c = o && o.column;
|
|
6405
|
+
if (typeof c !== 'string') continue; // 位置序号(数字)跳过
|
|
6406
|
+
const n = c.toLowerCase();
|
|
6407
|
+
const base = n.indexOf('.') >= 0 ? n.slice(n.indexOf('.') + 1) : n;
|
|
6408
|
+
if (!known.has(n) && !known.has(base)) throw new Error(`no such column: ${c}`);
|
|
6409
|
+
}
|
|
6410
|
+
}
|
|
6201
6411
|
if (Array.isArray(statement.groupBy)) {
|
|
6202
6412
|
// groupBy 是列名字符串数组
|
|
6203
6413
|
for (const g of statement.groupBy) {
|
|
@@ -7208,24 +7418,42 @@ class SQLExecutor {
|
|
|
7208
7418
|
* 同时按「投影列名 → 下标」解析;命中就直接 `row[idx]`,
|
|
7209
7419
|
* 不再走 resolveOperand 的通用路径。
|
|
7210
7420
|
*/
|
|
7421
|
+
// 把 ORDER BY 引用的输出列(别名或位置序号)解析回该列的**底层取值**。
|
|
7422
|
+
// 非分组路径的行对象键是底层列名(如 'v'),不是别名('val'),
|
|
7423
|
+
// 直接 r['val'] 会取到 undefined,导致 ORDER BY 静默不排序。
|
|
7424
|
+
const resolveByColumn = (c) => {
|
|
7425
|
+
if (!c) return (r) => null;
|
|
7426
|
+
if (typeof c.expr === 'string' && c.expr !== '*') {
|
|
7427
|
+
const name = c.expr;
|
|
7428
|
+
const node = { type: 'column', name };
|
|
7429
|
+
return (r) => {
|
|
7430
|
+
const v = (r == null) ? undefined : r[name];
|
|
7431
|
+
return v !== undefined ? v : resolveOperand(node, r, this.ctx);
|
|
7432
|
+
};
|
|
7433
|
+
}
|
|
7434
|
+
if (c.scalar) return (r) => resolveOperand(c.scalar, r, this.ctx);
|
|
7435
|
+
if (c.aggregate) return (r) => this._aggValue([r], c.aggregate, c.column, c.distinct, c.separator, c.orderBy);
|
|
7436
|
+
const nm = c.alias;
|
|
7437
|
+
return (r) => (r == null ? null : r[nm]);
|
|
7438
|
+
};
|
|
7211
7439
|
const ordKeys = statement.orderBy.map(o => {
|
|
7212
7440
|
const dir = o.dir === 'asc' ? 1 : -1;
|
|
7213
|
-
// 位置序号:ORDER BY 1
|
|
7441
|
+
// 位置序号:ORDER BY 1(1-based,指第 N 个输出列)
|
|
7214
7442
|
const pos = (o.column && typeof o.column === 'object' && o.column.type === 'value' && typeof o.column.value === 'number')
|
|
7215
7443
|
? o.column.value
|
|
7216
7444
|
: (typeof o.column === 'number' ? o.column : null);
|
|
7217
7445
|
if (pos !== null && pos !== undefined) {
|
|
7218
|
-
|
|
7219
|
-
return { dir, at: (name === undefined || name === null) ? () => null : (r) => (r == null ? null : r[name]) };
|
|
7446
|
+
return { dir, at: resolveByColumn(statement.columns[pos - 1]) };
|
|
7220
7447
|
}
|
|
7221
7448
|
if (typeof o.column === 'string') {
|
|
7222
7449
|
const f = o.column;
|
|
7223
|
-
//
|
|
7450
|
+
// 命中某个输出列的别名/列名 → 用该列的底层取值(修复别名排序)
|
|
7451
|
+
const idx = ordNames.findIndex(n => n === f);
|
|
7452
|
+
if (idx >= 0) return { dir, at: resolveByColumn(statement.columns[idx]) };
|
|
7224
7453
|
const node = { type: 'column', name: f };
|
|
7225
7454
|
return {
|
|
7226
7455
|
dir,
|
|
7227
7456
|
at: (r) => {
|
|
7228
|
-
// 先按字段名直取;取不到再走 resolveOperand(点号路径、表达式等)
|
|
7229
7457
|
const v = (r == null) ? undefined : r[f];
|
|
7230
7458
|
return v !== undefined ? v : resolveOperand(node, r, this.ctx);
|
|
7231
7459
|
},
|
|
@@ -7709,6 +7937,30 @@ async function inferColumnTypes(engine, stmt, r) {
|
|
|
7709
7937
|
});
|
|
7710
7938
|
}
|
|
7711
7939
|
|
|
7940
|
+
// 结果里的大整数(> MAX_SAFE_INTEGER 的 BigInt)在 JSON.stringify / wasm 绑定下
|
|
7941
|
+
// 无法序列化("Do not know how to serialize a BigInt")。输出前递归转成字符串,
|
|
7942
|
+
// 保留精度且可序列化;内部运算仍走 BigInt,精度不丢。
|
|
7943
|
+
function finalizeBigInts(value, seen) {
|
|
7944
|
+
if (typeof value === 'bigint') return value.toString();
|
|
7945
|
+
if (Array.isArray(value)) {
|
|
7946
|
+
if (!seen) seen = new Set();
|
|
7947
|
+
if (seen.has(value)) return value;
|
|
7948
|
+
seen.add(value);
|
|
7949
|
+
for (let i = 0; i < value.length; i++) value[i] = finalizeBigInts(value[i], seen);
|
|
7950
|
+
return value;
|
|
7951
|
+
}
|
|
7952
|
+
if (value && typeof value === 'object') {
|
|
7953
|
+
// Buffer / Date / RegExp 等特殊对象不深入(Buffer 是 Uint8Array,逐字节递归会爆栈)
|
|
7954
|
+
if (value instanceof Buffer || value instanceof Date || value instanceof RegExp) return value;
|
|
7955
|
+
if (!seen) seen = new Set();
|
|
7956
|
+
if (seen.has(value)) return value;
|
|
7957
|
+
seen.add(value);
|
|
7958
|
+
for (const k of Object.keys(value)) value[k] = finalizeBigInts(value[k], seen);
|
|
7959
|
+
return value;
|
|
7960
|
+
}
|
|
7961
|
+
return value;
|
|
7962
|
+
}
|
|
7963
|
+
|
|
7712
7964
|
async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
|
|
7713
7965
|
// 参数守卫:engine 必须是引擎对象,sql 必须是字符串。误用时报清晰错误,
|
|
7714
7966
|
// 而不是在后续像 `reading 'length'` 那样抛出令人费解的 TypeError。
|
|
@@ -7767,6 +8019,7 @@ async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
|
|
|
7767
8019
|
let r = await executor.execute(stmt);
|
|
7768
8020
|
const durationMs = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
7769
8021
|
r = buildResultEnvelope(stmt, r, Math.round(durationMs * 1000) / 1000);
|
|
8022
|
+
r = finalizeBigInts(r);
|
|
7770
8023
|
if (r && r.columns && r.columnTypes === undefined) {
|
|
7771
8024
|
r.columnTypes = await inferColumnTypes(engine, stmt, r);
|
|
7772
8025
|
}
|
package/lib/table.js
CHANGED
|
@@ -1543,6 +1543,11 @@ class Table {
|
|
|
1543
1543
|
else if (typeof v === 'number' || typeof v === 'boolean' || v instanceof Date) result[field] = String(v);
|
|
1544
1544
|
else if (Buffer.isBuffer(v)) result[field] = v.toString('utf8');
|
|
1545
1545
|
else throw createError('ER_TRUNCATED_WRONG_VALUE', t, v);
|
|
1546
|
+
} else if ((t === 'object' || t === 'array' || t === 'json') && (def.jsonType || t === 'json')) {
|
|
1547
|
+
// JSON / JSONB 列:字符串形式必须是合法 JSON({bad json 这类会被 JSON.parse 拒绝)
|
|
1548
|
+
if (typeof v === 'string') {
|
|
1549
|
+
try { JSON.parse(v); } catch (e) { throw createError('ER_TRUNCATED_WRONG_VALUE', t, v); }
|
|
1550
|
+
}
|
|
1546
1551
|
}
|
|
1547
1552
|
}
|
|
1548
1553
|
return result;
|
package/lib/wasm_client.js
CHANGED
|
@@ -561,6 +561,18 @@ class JSQL {
|
|
|
561
561
|
// 难以定位的 "Cannot convert undefined or null to object",这里直接给明确 TypeError
|
|
562
562
|
const arr = Array.isArray(data) ? data : [data];
|
|
563
563
|
arr.forEach((row, i) => assertRowObject(row, i, arr.length > 1 || Array.isArray(data)));
|
|
564
|
+
// JSON / JSONB 列:字符串形式必须是合法 JSON({bad json 这类会被 JSON.parse 拒绝)
|
|
565
|
+
const schema = this._schemas[table];
|
|
566
|
+
if (schema) {
|
|
567
|
+
for (const row of arr) {
|
|
568
|
+
for (const [f, def] of Object.entries(schema)) {
|
|
569
|
+
if (def && def.jsonType && typeof row[f] === 'string') {
|
|
570
|
+
try { JSON.parse(row[f]); }
|
|
571
|
+
catch (e) { throw new Error(`Truncated incorrect object value: '${row[f]}'`); }
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
564
576
|
if (!(await this._runHooks('beforeInsert', [table, arr]))) return [];
|
|
565
577
|
if (arr.length > 1) {
|
|
566
578
|
await this._flush();
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Bug 回归测试:此前发现的 5 个缺陷,逐一固化,防止回退。
|
|
3
|
+
*
|
|
4
|
+
* 1. 布尔求值器整体失效:TRUE AND TRUE → 0、NULL AND TRUE → 0、NULL OR FALSE → 0。
|
|
5
|
+
* 正确三值逻辑:1 / NULL / NULL。根因是 resolveOperand 没有 and/or/xor/not 分支,
|
|
6
|
+
* 落到 default 返回 undefined → 数值化成 0。
|
|
7
|
+
* 2. CREATE TABLE d(a INT, a INT) 不拒绝重复列名(对象键覆盖静默丢列)。
|
|
8
|
+
* 3. JSONB 列能塞进 {bad json 这种非法 JSON(两引擎:Database _validateDataTypes + wasm insert)。
|
|
9
|
+
* 4. ORDER BY 不存在的列静默忽略(解析产物是裸字符串,_validateColumnRefs 的 walk 检不到)。
|
|
10
|
+
* 5. SELECT 超大整数抛 "Do not know how to serialize a BigInt"(输出前应转字符串)。
|
|
11
|
+
*/
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const ROOT = path.join(__dirname, '..');
|
|
15
|
+
const { JSQL } = require(path.join(ROOT, 'index.js'));
|
|
16
|
+
const { executeSQL } = require(path.join(ROOT, 'lib/sql.js'));
|
|
17
|
+
|
|
18
|
+
const OPTS = { safety: false };
|
|
19
|
+
let pass = 0, fail = 0;
|
|
20
|
+
|
|
21
|
+
function ok(name, cond, extra) {
|
|
22
|
+
if (cond) { pass++; console.log('[OK] ', name); }
|
|
23
|
+
else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 150) : ''); }
|
|
24
|
+
}
|
|
25
|
+
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
26
|
+
|
|
27
|
+
(async () => {
|
|
28
|
+
const db = new JSQL(':memory:');
|
|
29
|
+
await db.start();
|
|
30
|
+
const q = (sql) => executeSQL(db, sql, OPTS);
|
|
31
|
+
const rows = async (sql) => (await q(sql)).rows;
|
|
32
|
+
const one = async (sql) => (await q(sql)).rows[0][0];
|
|
33
|
+
const fails = async (sql) => { try { await q(sql); return null; } catch (e) { return e.message; } };
|
|
34
|
+
|
|
35
|
+
// ── 1. 三值布尔逻辑 ────────────────────────────────────────────
|
|
36
|
+
console.log('--- 1. 布尔三值逻辑 ---');
|
|
37
|
+
ok('TRUE AND TRUE = 1', (await one('SELECT TRUE AND TRUE')) === 1);
|
|
38
|
+
ok('TRUE AND FALSE = 0', (await one('SELECT TRUE AND FALSE')) === 0);
|
|
39
|
+
ok('NULL AND TRUE = NULL', (await one('SELECT NULL AND TRUE')) === null);
|
|
40
|
+
ok('NULL AND FALSE = 0', (await one('SELECT NULL AND FALSE')) === 0);
|
|
41
|
+
ok('NULL OR FALSE = NULL', (await one('SELECT NULL OR FALSE')) === null);
|
|
42
|
+
ok('NULL OR TRUE = 1', (await one('SELECT NULL OR TRUE')) === 1);
|
|
43
|
+
ok('FALSE OR FALSE = 0', (await one('SELECT FALSE OR FALSE')) === 0);
|
|
44
|
+
ok('NOT TRUE = 0', (await one('SELECT NOT TRUE')) === 0);
|
|
45
|
+
ok('NOT NULL = NULL', (await one('SELECT NOT NULL')) === null);
|
|
46
|
+
ok('1 AND 0 = 0', (await one('SELECT 1 AND 0')) === 0);
|
|
47
|
+
ok('1 XOR 0 = 1', (await one('SELECT 1 XOR 0')) === 1);
|
|
48
|
+
ok('TRUE OR NULL = 1', (await one('SELECT TRUE OR NULL')) === 1);
|
|
49
|
+
|
|
50
|
+
// ── 2. CREATE TABLE 重复列名 ───────────────────────────────────
|
|
51
|
+
console.log('--- 2. 重复列名 ---');
|
|
52
|
+
ok('d(a INT, a INT) 报错', /Duplicate column/i.test(await fails('CREATE TABLE r1(a INT, a INT)')));
|
|
53
|
+
ok('d(x,y,x) 报错', /Duplicate column/i.test(await fails('CREATE TABLE r2(x INT, y INT, x INT)')));
|
|
54
|
+
ok('正常列不报错', (await fails('CREATE TABLE r3(a INT, b TEXT)')) === null);
|
|
55
|
+
ok('id PRIMARY KEY + id 重复报错', /Duplicate column/i.test(await fails('CREATE TABLE r4(id INT PRIMARY KEY, id INT)')));
|
|
56
|
+
|
|
57
|
+
// ── 3. JSONB 非法 JSON ─────────────────────────────────────────
|
|
58
|
+
console.log('--- 3. JSONB 校验 ---');
|
|
59
|
+
await q('CREATE TABLE rj(c JSONB)');
|
|
60
|
+
ok('{bad json 拒绝', /Truncated incorrect object value/.test(await fails(`INSERT INTO rj VALUES ('{bad json')`)));
|
|
61
|
+
ok('[1,2 拒绝', /Truncated incorrect object value/.test(await fails(`INSERT INTO rj VALUES ('[1,2')`)));
|
|
62
|
+
ok('合法 {"a":1} 通过', (await fails(`INSERT INTO rj VALUES ('{"a":1}')`)) === null);
|
|
63
|
+
await q('CREATE TABLE rj2(c JSON)');
|
|
64
|
+
ok('JSON 列 nope 拒绝', /Truncated incorrect object value/.test(await fails(`INSERT INTO rj2 VALUES ('nope')`)));
|
|
65
|
+
|
|
66
|
+
// ── 4. ORDER BY 不存在列 ───────────────────────────────────────
|
|
67
|
+
console.log('--- 4. ORDER BY 校验 ---');
|
|
68
|
+
await q('CREATE TABLE ro (id INT PRIMARY KEY, v INT, s TEXT)');
|
|
69
|
+
await q('INSERT INTO ro VALUES (1,2,\'x\'),(2,1,\'y\')');
|
|
70
|
+
ok('ORDER BY nonexistent_col 报错', /no such column: nonexistent_col/.test(await fails('SELECT id FROM ro ORDER BY nonexistent_col')));
|
|
71
|
+
ok('WHERE 不存在列报错', /no such column: nosuch/.test(await fails('SELECT id FROM ro WHERE nosuch > 1')));
|
|
72
|
+
ok('ORDER BY 真列 v 正常', eq(await rows('SELECT id FROM ro ORDER BY v'), [[2], [1]]));
|
|
73
|
+
ok('ORDER BY 位置序号 1 正常', eq(await rows('SELECT id FROM ro ORDER BY 1'), [[1], [2]]));
|
|
74
|
+
ok('ORDER BY 输出别名 vv 正常', eq(await rows('SELECT v AS vv FROM ro ORDER BY vv'), [[1], [2]]));
|
|
75
|
+
|
|
76
|
+
// ── 5. 超大整数序列化 ──────────────────────────────────────────
|
|
77
|
+
console.log('--- 5. 超大整数 ---');
|
|
78
|
+
const big = await one('SELECT 1000000000000000000000');
|
|
79
|
+
ok('超大整数可序列化(字符串)', typeof big === 'string' && big === '1000000000000000000000');
|
|
80
|
+
ok('超界精确值 9007199254740993', (await one('SELECT 9007199254740993')) === '9007199254740993');
|
|
81
|
+
const bigRows = await rows('SELECT 1000000000000000000000');
|
|
82
|
+
ok('JSON.stringify 不炸', (() => { try { JSON.stringify(bigRows); return true; } catch (e) { return false; } })());
|
|
83
|
+
|
|
84
|
+
await db.stop();
|
|
85
|
+
console.log(`\nRESULT: ${pass} passed, ${fail} failed`);
|
|
86
|
+
process.exit(fail ? 1 : 0);
|
|
87
|
+
})().catch((e) => { console.error('FATAL', e); process.exit(1); });
|