jsql-neo 6.3.4 → 6.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/index.js +1 -1
- package/lib/database.js +30 -7
- package/lib/sql.js +304 -25
- package/package.json +4 -3
- package/test/dml-constraints.test.js +163 -0
- package/test/native-multi-instance.test.js +11 -6
- package/test/sql-parser.test.js +189 -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. The browser. **And it fits in one npm package.**
|
|
5
5
|
|
|
6
|
-
> **v6.3.
|
|
6
|
+
> **v6.3.5** — official release build · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo)
|
|
7
7
|
|
|
8
8
|

|
|
9
9
|

|
|
@@ -233,7 +233,7 @@ npm install && npm run build # option 3: from source
|
|
|
233
233
|
Verify:
|
|
234
234
|
|
|
235
235
|
```bash
|
|
236
|
-
node -e "console.log(require('jsql-neo/package.json').version)" # 6.3.
|
|
236
|
+
node -e "console.log(require('jsql-neo/package.json').version)" # 6.3.5
|
|
237
237
|
```
|
|
238
238
|
|
|
239
239
|
### 30-second demo
|
|
@@ -1470,7 +1470,7 @@ jsql mod --engine wasm # switch engine (restart required)
|
|
|
1470
1470
|
|
|
1471
1471
|
```bash
|
|
1472
1472
|
$ jsql version
|
|
1473
|
-
jsql-neo v6.3.
|
|
1473
|
+
jsql-neo v6.3.5
|
|
1474
1474
|
engine: native (napi) | wasm | js
|
|
1475
1475
|
node: v22.0.0 platform: linux x64
|
|
1476
1476
|
```
|
|
@@ -1491,7 +1491,7 @@ jsql tui --memory -q # memory mode, quiet
|
|
|
1491
1491
|
jsql tui --prompt 'db> ' --no-color
|
|
1492
1492
|
```
|
|
1493
1493
|
|
|
1494
|
-
The status bar shows: `db=<name> dialect=<d> mode=<tui|batch> ver=6.3.
|
|
1494
|
+
The status bar shows: `db=<name> dialect=<d> mode=<tui|batch> ver=6.3.5`.
|
|
1495
1495
|
|
|
1496
1496
|
### Keyboard shortcuts
|
|
1497
1497
|
|
|
@@ -4175,7 +4175,7 @@ Data dir: /root/.jsql-neo/data
|
|
|
4175
4175
|
|
|
4176
4176
|
```bash
|
|
4177
4177
|
$ jsql version
|
|
4178
|
-
jsql-neo v6.3.
|
|
4178
|
+
jsql-neo v6.3.5
|
|
4179
4179
|
engine: native (napi) | wasm | js
|
|
4180
4180
|
node: v22.0.0
|
|
4181
4181
|
platform: linux x64
|
|
@@ -5275,7 +5275,7 @@ Apache License
|
|
|
5275
5275
|
|
|
5276
5276
|
*JSQL-NEO — One engine to rule them all. MySQL. PostgreSQL. MongoDB. Redis. SQL. TypeScript. The browser.*
|
|
5277
5277
|
|
|
5278
|
-
*文档版本:v6.3.
|
|
5278
|
+
*文档版本:v6.3.5 · 最后更新:2026-09-27*
|
|
5279
5279
|
|
|
5280
5280
|
---
|
|
5281
5281
|
|
|
@@ -7047,7 +7047,7 @@ Usage: jsql version
|
|
|
7047
7047
|
|
|
7048
7048
|
输出版本与环境信息:
|
|
7049
7049
|
|
|
7050
|
-
jsql-neo v6.3.
|
|
7050
|
+
jsql-neo v6.3.5
|
|
7051
7051
|
engine: native (napi) | wasm | js
|
|
7052
7052
|
node: v22.0.0
|
|
7053
7053
|
platform: linux x64
|
|
@@ -7703,7 +7703,7 @@ CI(GitHub Actions)矩阵:`node 20/22` × `linux/macos/windows` × `native/
|
|
|
7703
7703
|
|
|
7704
7704
|
*JSQL-NEO — One engine to rule them all. MySQL. PostgreSQL. MongoDB. Redis. SQL. TypeScript. The browser.*
|
|
7705
7705
|
|
|
7706
|
-
*文档版本:v6.3.
|
|
7706
|
+
*文档版本:v6.3.5 · 共 19 个附录 · 最后更新:2026-09-27*
|
|
7707
7707
|
|
|
7708
7708
|
---
|
|
7709
7709
|
|
|
@@ -8310,4 +8310,4 @@ npm test
|
|
|
8310
8310
|
|
|
8311
8311
|
*JSQL-NEO — One engine to rule them all. MySQL. PostgreSQL. MongoDB. Redis. SQL. TypeScript. The browser.*
|
|
8312
8312
|
|
|
8313
|
-
*文档版本:v6.3.
|
|
8313
|
+
*文档版本:v6.3.5 · 附录 A–Z · 全文 6000+ 行 · 最后更新:2026-09-27*
|
package/index.js
CHANGED
package/lib/database.js
CHANGED
|
@@ -1499,8 +1499,23 @@ class Database {
|
|
|
1499
1499
|
if (!this._runHooks('beforeUpdate', [tableName, id, data])) return;
|
|
1500
1500
|
const row = this._resolveId(table, id);
|
|
1501
1501
|
if (!row) return { ok: false, error: 'not found' };
|
|
1502
|
-
|
|
1502
|
+
// 改主键/唯一列时要先确认新值不与其它行冲突。
|
|
1503
|
+
// 此前直接 Object.assign,导致
|
|
1504
|
+
// UPDATE t SET id = 1 WHERE id = 2 (t 已有 id=1)
|
|
1505
|
+
// 静默产生两行同主键 —— 主键索引只留一个,SELECT 结果与实际存储不一致。
|
|
1506
|
+
// MySQL / SQLite 都会报 UNIQUE constraint failed。
|
|
1507
|
+
// 注意:UNIQUE 列记录在 table._cachedUniqueFields(不是 _indexes),
|
|
1508
|
+
// 逐行比对即可,未建索引时也能正确拦截。
|
|
1503
1509
|
const pk = this._pkOf(table);
|
|
1510
|
+
const uniq = (table._cachedUniqueFields || []).filter(f => f !== pk);
|
|
1511
|
+
for (const field of [pk, ...uniq]) {
|
|
1512
|
+
if (!field || !Object.prototype.hasOwnProperty.call(data, field)) continue;
|
|
1513
|
+
const nv = data[field];
|
|
1514
|
+
if (nv === row[field]) continue; // 值没变,不算冲突
|
|
1515
|
+
const clash = table._rows.find(r => r !== row && r[field] === nv);
|
|
1516
|
+
if (clash) throw createError('ER_DUP_ENTRY', `Duplicate entry '${nv}' for key '${field}'`);
|
|
1517
|
+
}
|
|
1518
|
+
Object.assign(row, data);
|
|
1504
1519
|
if (pk) this._walWrite({ op: 'update', table: tableName, pk, id, row: { ...data } });
|
|
1505
1520
|
this._emit('update', { table: tableName, id, data });
|
|
1506
1521
|
this._runHooks('afterUpdate', [tableName, id, data, { ok: true }]);
|
|
@@ -1562,13 +1577,21 @@ class Database {
|
|
|
1562
1577
|
if (table._primaryKey) {
|
|
1563
1578
|
for (const id of ids) removed += table.removeById(id);
|
|
1564
1579
|
} else {
|
|
1580
|
+
// 无主键表没有稳定的行标识,_resolveId 只能把 id 当 1-based 行号,
|
|
1581
|
+
// 而 _rowPkId 在无主键时回退到 row.id(业务列名)——
|
|
1582
|
+
// 两者语义不一致,导致 DELETE ... WHERE id IN (3,5) 删错行。
|
|
1583
|
+
// 这里先按「行对象 === id」尝试(SQL 层可以直接传行引用),
|
|
1584
|
+
// 退化到行号解析;并一次性收集目标后统一移除,避免边删边移位。
|
|
1585
|
+
const targets = [];
|
|
1586
|
+
const seen = new Set();
|
|
1587
|
+
const push = (row) => { if (row && !seen.has(row)) { seen.add(row); targets.push(row); } };
|
|
1565
1588
|
for (const id of ids) {
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1589
|
+
if (id && typeof id === 'object') { push(id); continue; }
|
|
1590
|
+
push(this._resolveId(table, id));
|
|
1591
|
+
}
|
|
1592
|
+
if (targets.length) {
|
|
1593
|
+
table._rows = table._rows.filter(r => !seen.has(r));
|
|
1594
|
+
removed = targets.length;
|
|
1572
1595
|
}
|
|
1573
1596
|
table._rebuildPKIndex();
|
|
1574
1597
|
table._rebuildAllBTrees();
|
package/lib/sql.js
CHANGED
|
@@ -610,6 +610,17 @@ class Parser {
|
|
|
610
610
|
|
|
611
611
|
while (true) {
|
|
612
612
|
const t = this.peek();
|
|
613
|
+
// 列级 CHECK (age INT CHECK(age >= 0))。
|
|
614
|
+
// CHECK 刻意不在 KEYWORDS 里(避免影响同名标识符),所以用 isWord 匹配,
|
|
615
|
+
// 放在 keyword switch 之外。表级 CHECK 已能被解析(同样只是跳过),
|
|
616
|
+
// 列级此前会直接 return def,把 CHECK 留在流里 →
|
|
617
|
+
// "Expected ',' or ')' in CREATE TABLE, got 'CHECK'"。
|
|
618
|
+
// 与表级保持一致:解析并跳过,条件文本存到 def.check 供后续实现。
|
|
619
|
+
if (this.isWord('CHECK')) {
|
|
620
|
+
this.next();
|
|
621
|
+
if (this.peek().type === 'op' && this.peek().value === '(') this._skipParens();
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
613
624
|
if (t.type === 'keyword') {
|
|
614
625
|
switch (t.value) {
|
|
615
626
|
case 'PRIMARY':
|
|
@@ -1073,8 +1084,19 @@ class Parser {
|
|
|
1073
1084
|
this.expect('op', ')');
|
|
1074
1085
|
aggregate = { type: 'COUNT', column: col, distinct: aggDistinct };
|
|
1075
1086
|
aggregate = this.parseOptionalOver(aggregate);
|
|
1076
|
-
|
|
1077
|
-
|
|
1087
|
+
// 聚合后紧跟运算符:SUM(v)/COUNT(*) / COUNT(*)-1 …
|
|
1088
|
+
// 顶层聚合默认被表示为独立的 aggregate 字段,不参与后续运算,
|
|
1089
|
+
// 运算符会留在流里 → "Unexpected token '/' after statement"。
|
|
1090
|
+
// 此时整体降级成 scalar 表达式(arith 里嵌 aggregate),
|
|
1091
|
+
// 由执行层的嵌套聚合替换逻辑求值。
|
|
1092
|
+
// 注意:先看尾随运算符,再取别名 —— 顺序反了会把 AS 当成运算符的一部分。
|
|
1093
|
+
const cntTail = this.parseArithTail(aggregate);
|
|
1094
|
+
if (cntTail) {
|
|
1095
|
+
columns.push({ expr: null, scalar: cntTail, alias: this.parseOptionalAlias() });
|
|
1096
|
+
} else {
|
|
1097
|
+
aggregate.alias = this.parseOptionalAlias();
|
|
1098
|
+
columns.push({ expr: col, aggregate: 'COUNT', column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct });
|
|
1099
|
+
}
|
|
1078
1100
|
} else if (t.type === 'keyword' && ['SUM', 'AVG', 'MIN', 'MAX'].includes(t.value)) {
|
|
1079
1101
|
this.next();
|
|
1080
1102
|
const fn = t.value;
|
|
@@ -1085,8 +1107,14 @@ class Parser {
|
|
|
1085
1107
|
this.expect('op', ')');
|
|
1086
1108
|
aggregate = { type: fn, column: col, distinct: aggDistinct2 };
|
|
1087
1109
|
aggregate = this.parseOptionalOver(aggregate);
|
|
1088
|
-
|
|
1089
|
-
|
|
1110
|
+
// 同 COUNT 分支:聚合参与后续算术时降级成 scalar 表达式
|
|
1111
|
+
const fnTail = this.parseArithTail(aggregate);
|
|
1112
|
+
if (fnTail) {
|
|
1113
|
+
columns.push({ expr: null, scalar: fnTail, alias: this.parseOptionalAlias() });
|
|
1114
|
+
} else {
|
|
1115
|
+
aggregate.alias = this.parseOptionalAlias();
|
|
1116
|
+
columns.push({ expr: col, aggregate: fn, column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct2 });
|
|
1117
|
+
}
|
|
1090
1118
|
} else if (t.type === 'op' && t.value === '*') {
|
|
1091
1119
|
this.next();
|
|
1092
1120
|
columns.push({ expr: '*' });
|
|
@@ -1762,9 +1790,49 @@ class Parser {
|
|
|
1762
1790
|
this.peek(1) && this.peek(1).type === 'keyword' && this.peek(1).value === 'SELECT';
|
|
1763
1791
|
if (!isSubqueryParen && this.peek().type === 'op' && this.peek().value === '(') {
|
|
1764
1792
|
this.next();
|
|
1765
|
-
|
|
1793
|
+
// 递归解析括号内的完整表达式(到 ')' 为止)。
|
|
1794
|
+
// 此处不能用 parseExpr():它一路走到 parseComparison,
|
|
1795
|
+
// 而内层看不到 ')' 会抛 "Expected comparison operator, got ')'"。
|
|
1796
|
+
// 用 bareExprDepth>0 的 parseArgExpr 走同一套优先级,但允许裸操作数收尾。
|
|
1797
|
+
this.bareExprDepth = (this.bareExprDepth || 0) + 1;
|
|
1798
|
+
let left;
|
|
1799
|
+
try {
|
|
1800
|
+
left = this.parseOr();
|
|
1801
|
+
} finally {
|
|
1802
|
+
this.bareExprDepth--;
|
|
1803
|
+
}
|
|
1766
1804
|
this.expect('op', ')');
|
|
1767
|
-
|
|
1805
|
+
// 括号整体是一个完整的左操作数,后面若紧跟运算符必须继续参与运算:
|
|
1806
|
+
// SELECT (1+2)*3 / SELECT (a+b)/2 / WHERE (x+1) > 5
|
|
1807
|
+
// 直接 return 会把运算符留在流里 → "Unexpected token '*' after statement"
|
|
1808
|
+
// (2*(3+4) 正常是因为 '(' 出现在右操作数位置,不走这个分支。)
|
|
1809
|
+
// 先乘除后加减,与 parseTerm/parseAdditive 的优先级一致。
|
|
1810
|
+
for (;;) {
|
|
1811
|
+
const t = this.peek();
|
|
1812
|
+
if (t.type !== 'op' || !['*', '/', '%'].includes(t.value)) break;
|
|
1813
|
+
this.next();
|
|
1814
|
+
const right = this.parseExpr();
|
|
1815
|
+
left = { type: 'arith', op: t.value, left, right };
|
|
1816
|
+
}
|
|
1817
|
+
for (;;) {
|
|
1818
|
+
const t = this.peek();
|
|
1819
|
+
if (t.type !== 'op' || !['+', '-'].includes(t.value)) break;
|
|
1820
|
+
this.next();
|
|
1821
|
+
const right = this.parseExpr();
|
|
1822
|
+
left = { type: 'arith', op: t.value, left, right };
|
|
1823
|
+
}
|
|
1824
|
+
// 尾随比较/条件运算符:WHERE (a) = 1 / (a) IN (...) / (a) LIKE 'x' /
|
|
1825
|
+
// (a) IS NULL / (a) BETWEEN 1 AND 5 / (a) NOT IN (...)。
|
|
1826
|
+
// 一律交给 parseComparison —— 它是完整实现,覆盖这些全部形式。
|
|
1827
|
+
const nx = this.peek();
|
|
1828
|
+
const isCmp = nx.type === 'op' &&
|
|
1829
|
+
['=', '!=', '<>', '<', '<=', '>', '>=', '<=>', '@>', '<@'].includes(nx.value);
|
|
1830
|
+
const isCondKw = nx.type === 'keyword' &&
|
|
1831
|
+
['IN', 'LIKE', 'ILIKE', 'IS', 'BETWEEN', 'REGEXP', 'RLIKE', 'NOT'].includes(nx.value);
|
|
1832
|
+
if (isCmp || isCondKw) {
|
|
1833
|
+
return this.parseComparison(left);
|
|
1834
|
+
}
|
|
1835
|
+
return left;
|
|
1768
1836
|
}
|
|
1769
1837
|
// EXISTS (SELECT ...):子查询是否存在至少一行
|
|
1770
1838
|
if (this.isKeyword('EXISTS')) {
|
|
@@ -1777,6 +1845,42 @@ class Parser {
|
|
|
1777
1845
|
return this.parseComparison();
|
|
1778
1846
|
}
|
|
1779
1847
|
|
|
1848
|
+
/**
|
|
1849
|
+
* 聚合(或任意已解析好的左操作数)之后若紧跟算术运算符,继续解析并返回
|
|
1850
|
+
* 组合后的 arith 节点;没有运算符则返回 null(调用方走原有的 aggregate 列路径)。
|
|
1851
|
+
* SELECT SUM(v)/COUNT(*) → arith('/', SUM, arith('/', COUNT))
|
|
1852
|
+
* 优先级与 parseTerm / parseAdditive 一致:先乘除后加减。
|
|
1853
|
+
*/
|
|
1854
|
+
parseArithTail(left) {
|
|
1855
|
+
// 投影分支里的 aggregate 对象是 { type: 'SUM', column, distinct } ——
|
|
1856
|
+
// type 直接是聚合函数名,不是 'aggregate'。而执行层的 _replaceAggregates
|
|
1857
|
+
// 只认 type === 'aggregate',不规范化就会漏替换 → 整个表达式求值为 null。
|
|
1858
|
+
// 这里包一层 { type:'aggregate', fn, column, … } 供替换逻辑识别。
|
|
1859
|
+
const seed = left && left.type !== 'aggregate' && typeof left.type === 'string' &&
|
|
1860
|
+
/^[A-Z][A-Z0-9_]*$/.test(left.type)
|
|
1861
|
+
? { type: 'aggregate', fn: left.type, column: left.column,
|
|
1862
|
+
distinct: left.distinct, separator: left.separator, orderBy: left.orderBy }
|
|
1863
|
+
: left;
|
|
1864
|
+
|
|
1865
|
+
let node = seed;
|
|
1866
|
+
let touched = false;
|
|
1867
|
+
for (;;) {
|
|
1868
|
+
const t = this.peek();
|
|
1869
|
+
if (t.type !== 'op' || !['*', '/', '%'].includes(t.value)) break;
|
|
1870
|
+
this.next();
|
|
1871
|
+
node = { type: 'arith', op: t.value, left: node, right: this.parseScalar() };
|
|
1872
|
+
touched = true;
|
|
1873
|
+
}
|
|
1874
|
+
for (;;) {
|
|
1875
|
+
const t = this.peek();
|
|
1876
|
+
if (t.type !== 'op' || !['+', '-'].includes(t.value)) break;
|
|
1877
|
+
this.next();
|
|
1878
|
+
node = { type: 'arith', op: t.value, left: node, right: this.parseScalar() };
|
|
1879
|
+
touched = true;
|
|
1880
|
+
}
|
|
1881
|
+
return touched ? node : null;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1780
1884
|
/**
|
|
1781
1885
|
* 解析聚合调用:fn ( [DISTINCT] expr [, expr*] [ORDER BY ...] [SEPARATOR 'x'] )
|
|
1782
1886
|
* 以及 COUNT(*) 这类无参形式。
|
|
@@ -1838,7 +1942,27 @@ class Parser {
|
|
|
1838
1942
|
// 否则会被当作逐行标量函数:既返回 null,又不会折叠分组
|
|
1839
1943
|
if (AGG_FUNCS.has(String(t.value).toUpperCase()) &&
|
|
1840
1944
|
this.peek().type === 'op' && this.peek().value === '(') {
|
|
1841
|
-
|
|
1945
|
+
const agg = this.parseAggregateCall(String(t.value).toUpperCase());
|
|
1946
|
+
// 聚合结果也是一个完整的左操作数,后面可紧跟运算符:
|
|
1947
|
+
// SELECT SUM(sal)/COUNT(*) AS avg ← 算平均值的标准写法
|
|
1948
|
+
// SELECT SUM(sal) - MIN(sal) ← 极差
|
|
1949
|
+
// SELECT MAX(sal) * 2
|
|
1950
|
+
// 不处理的话运算符会留在流里 → "Unexpected token '/' after statement"。
|
|
1951
|
+
// (加括号写成 (SUM(sal))/2 之所以正常,是走了括号分组分支。)
|
|
1952
|
+
let left = agg;
|
|
1953
|
+
for (;;) {
|
|
1954
|
+
const nx = this.peek();
|
|
1955
|
+
if (nx.type !== 'op' || !['*', '/', '%'].includes(nx.value)) break;
|
|
1956
|
+
this.next();
|
|
1957
|
+
left = { type: 'arith', op: nx.value, left, right: this.parseOperand() };
|
|
1958
|
+
}
|
|
1959
|
+
for (;;) {
|
|
1960
|
+
const nx = this.peek();
|
|
1961
|
+
if (nx.type !== 'op' || !['+', '-'].includes(nx.value)) break;
|
|
1962
|
+
this.next();
|
|
1963
|
+
left = { type: 'arith', op: nx.value, left, right: this.parseOperand() };
|
|
1964
|
+
}
|
|
1965
|
+
return left;
|
|
1842
1966
|
}
|
|
1843
1967
|
// 支持 alias.column 引用
|
|
1844
1968
|
if (this.peek().type === 'op' && this.peek().value === '.') {
|
|
@@ -2039,10 +2163,33 @@ class Parser {
|
|
|
2039
2163
|
}
|
|
2040
2164
|
// 括号分组:支持 (1+2)、2*(3+4) 等出现在算术/函数参数任意位置
|
|
2041
2165
|
// 注意:parseOperand 开头已通过 const t = this.next() 消费了 '(', 此处不可再 next()
|
|
2166
|
+
//
|
|
2167
|
+
// 括号整体是一个完整的左操作数,后面若紧跟运算符必须继续参与运算,
|
|
2168
|
+
// 否则运算符会留在流里:
|
|
2169
|
+
// SELECT (1+2)*3 → Unexpected token '*' after statement
|
|
2170
|
+
// WHERE (a) = 1 → Expected comparison operator, got ')'
|
|
2042
2171
|
if (t.type === 'op' && t.value === '(') {
|
|
2043
|
-
|
|
2172
|
+
let left = this.parseArgExpr(); // 容忍裸操作数,允许纯算术分组
|
|
2044
2173
|
this.expect('op', ')');
|
|
2045
|
-
|
|
2174
|
+
// 先乘除后加减,与 parseTerm / parseAdditive 的优先级一致
|
|
2175
|
+
for (;;) {
|
|
2176
|
+
const nx = this.peek();
|
|
2177
|
+
if (nx.type !== 'op' || !['*', '/', '%'].includes(nx.value)) break;
|
|
2178
|
+
this.next();
|
|
2179
|
+
left = { type: 'arith', op: nx.value, left, right: this.parseArgExpr() };
|
|
2180
|
+
}
|
|
2181
|
+
for (;;) {
|
|
2182
|
+
const nx = this.peek();
|
|
2183
|
+
if (nx.type !== 'op' || !['+', '-'].includes(nx.value)) break;
|
|
2184
|
+
this.next();
|
|
2185
|
+
left = { type: 'arith', op: nx.value, left, right: this.parseArgExpr() };
|
|
2186
|
+
}
|
|
2187
|
+
// 尾随比较运算符交给 parseComparison(完整实现,覆盖 =/</>/LIKE/IN/IS…)
|
|
2188
|
+
const cx = this.peek();
|
|
2189
|
+
if (cx.type === 'op' && ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>'].includes(cx.value)) {
|
|
2190
|
+
return this.parseComparison(left);
|
|
2191
|
+
}
|
|
2192
|
+
return left;
|
|
2046
2193
|
}
|
|
2047
2194
|
if (t.type === 'op' && (t.value === '-' || t.value === '+')) {
|
|
2048
2195
|
const num = this.next();
|
|
@@ -2190,7 +2337,15 @@ class Parser {
|
|
|
2190
2337
|
const v = this.parseOperand();
|
|
2191
2338
|
cond = { type: 'compare', op: '=', left: base, right: v };
|
|
2192
2339
|
} else {
|
|
2193
|
-
|
|
2340
|
+
// 条件可能是完整比较(WHEN a > 1),也可能是裸值/裸列(WHEN 1、WHEN flag)。
|
|
2341
|
+
// parseComparison 只接受比较式,裸值会抛 "Expected comparison operator, got 'THEN'"。
|
|
2342
|
+
// 先看清下一个 token 决定走哪条路,避免用 try/catch 回溯吞掉真实错误。
|
|
2343
|
+
const nx = this.peek();
|
|
2344
|
+
const isCmp = (nx.type === 'op' && ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>'].includes(nx.value)) ||
|
|
2345
|
+
(nx.type === 'keyword' && ['IN', 'LIKE', 'ILIKE', 'IS', 'BETWEEN', 'REGEXP', 'RLIKE', 'NOT'].includes(nx.value)) ||
|
|
2346
|
+
this.isWord('NOT');
|
|
2347
|
+
cond = isCmp ? this.parseComparison()
|
|
2348
|
+
: { type: 'isTruth', operand: this.parseArgExpr(), not: false, truth: true };
|
|
2194
2349
|
}
|
|
2195
2350
|
this.expectKeyword('THEN');
|
|
2196
2351
|
const val = this.parseOperand();
|
|
@@ -2202,8 +2357,13 @@ class Parser {
|
|
|
2202
2357
|
return { type: 'case', branches, elseVal };
|
|
2203
2358
|
}
|
|
2204
2359
|
|
|
2205
|
-
|
|
2206
|
-
|
|
2360
|
+
/**
|
|
2361
|
+
* 解析比较表达式。
|
|
2362
|
+
* preLeft:已解析好的左操作数(括号分组场景 —— parseNot 消费到 ')' 后传进来),
|
|
2363
|
+
* 省略时正常从 parseBitwise() 取左操作数。
|
|
2364
|
+
*/
|
|
2365
|
+
parseComparison(preLeft) {
|
|
2366
|
+
const left = preLeft !== undefined ? preLeft : this.parseBitwise();
|
|
2207
2367
|
const t = this.peek();
|
|
2208
2368
|
let not = false;
|
|
2209
2369
|
if (t.type === 'keyword' && t.value === 'NOT') {
|
|
@@ -2347,7 +2507,10 @@ const OPERATORS = {
|
|
|
2347
2507
|
function applyCast(value, dataType) {
|
|
2348
2508
|
const t = String(dataType || '').toUpperCase().replace(/\(.*\)$/, '').trim();
|
|
2349
2509
|
if (value === null || value === undefined) return null;
|
|
2350
|
-
|
|
2510
|
+
// SIGNED / UNSIGNED 是 MySQL 的整型别名(CAST(x AS SIGNED)),
|
|
2511
|
+
// 此前没列入整型分支,导致 CAST('12abc' AS SIGNED) 原样返回字符串。
|
|
2512
|
+
if (t === 'INTEGER' || t === 'INT' || t === 'BIGINT' || t === 'SMALLINT' ||
|
|
2513
|
+
t === 'TINYINT' || t === 'SIGNED' || t === 'UNSIGNED' || t === 'MEDIUMINT') {
|
|
2351
2514
|
// MySQL CAST(... AS SIGNED/INT):取数字前缀('12abc'→12),无法解析时返回 0(而非 null)。
|
|
2352
2515
|
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
2353
2516
|
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : 0;
|
|
@@ -2374,6 +2537,23 @@ function rowHasPrefixedKeys(row) {
|
|
|
2374
2537
|
return false;
|
|
2375
2538
|
}
|
|
2376
2539
|
|
|
2540
|
+
/**
|
|
2541
|
+
* 字符串能否当作数值参与算术运算。
|
|
2542
|
+
* MySQL 里 '5' + 5 = 10、'5' + '5' = 10、'abc' + 1 = 1(非数字按 0)。
|
|
2543
|
+
* 纯数字(含小数/负号/科学计数)才返回数值,否则返回 null。
|
|
2544
|
+
*/
|
|
2545
|
+
function numericIfNumeric(v) {
|
|
2546
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
2547
|
+
if (typeof v === 'boolean') return v ? 1 : 0;
|
|
2548
|
+
if (typeof v !== 'string') return null;
|
|
2549
|
+
const s = v.trim();
|
|
2550
|
+
if (s === '') return null;
|
|
2551
|
+
// 严格数字:可选符号 + 数字(含小数与指数),不接受 '12abc' 这类前缀形式
|
|
2552
|
+
if (!/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
|
|
2553
|
+
const n = Number(s);
|
|
2554
|
+
return Number.isFinite(n) ? n : null;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2377
2557
|
function resolveOperand(operand, row, ctx) {
|
|
2378
2558
|
if (operand === null || operand === undefined) return null;
|
|
2379
2559
|
if (typeof operand === 'string') return resolveOperand({ type: 'column', name: operand }, row, ctx);
|
|
@@ -2423,11 +2603,28 @@ function resolveOperand(operand, row, ctx) {
|
|
|
2423
2603
|
const r = resolveOperand(operand.right, row, ctx);
|
|
2424
2604
|
if (l === null || r === null || l === undefined || r === undefined) return null;
|
|
2425
2605
|
switch (operand.op) {
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
case '
|
|
2606
|
+
// MySQL/SQLite 里 + 永远是算术加法,字符串拼接只走 || 或 CONCAT。
|
|
2607
|
+
// 直接用 JS 的 l + r 会让 '5' + 5 得到 '55'(静默返回字符串),
|
|
2608
|
+
// 数字与字符串混合的算术会一路错下去。
|
|
2609
|
+
// 判据:任一侧是 number,或字符串能被完整解析为数字 → 走数值加法。
|
|
2610
|
+
case '+': {
|
|
2611
|
+
// MySQL / SQLite 语义(已用 sqlite3 实测核对):
|
|
2612
|
+
// '5' + 5 = 10 '5' + '5' = 10 'abc' + 1 = 1 'a' + 'b' = 0
|
|
2613
|
+
// 任一侧是「数值」时做算术加法,另一侧非数值按 0;
|
|
2614
|
+
// 两侧都不是数值才是 0(JS 的 l + r 会把 '5' + 5 拼成 '55')。
|
|
2615
|
+
if (typeof l === 'number' || typeof r === 'number') {
|
|
2616
|
+
const ln2 = numericIfNumeric(l), rn2 = numericIfNumeric(r);
|
|
2617
|
+
return (ln2 === null ? 0 : ln2) + (rn2 === null ? 0 : rn2);
|
|
2618
|
+
}
|
|
2619
|
+
const ln = numericIfNumeric(l), rn = numericIfNumeric(r);
|
|
2620
|
+
if (ln !== null) return ln + (rn === null ? 0 : rn);
|
|
2621
|
+
if (rn !== null) return rn; // 左侧非数值按 0
|
|
2622
|
+
return 0;
|
|
2623
|
+
}
|
|
2624
|
+
case '-': return Number(l) - Number(r);
|
|
2625
|
+
case '*': return Number(l) * Number(r);
|
|
2626
|
+
case '/': return r === 0 ? null : Number(l) / Number(r);
|
|
2627
|
+
case '%': return r === 0 ? null : Number(l) % Number(r);
|
|
2431
2628
|
// MySQL 整数除法 DIV(结果向零取整)
|
|
2432
2629
|
case 'DIV': return r === 0 ? null : Math.trunc(Number(l) / Number(r));
|
|
2433
2630
|
// 位运算:先按整数归一,与 MySQL 的整数位运算语义一致
|
|
@@ -2894,7 +3091,12 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
2894
3091
|
const b = parseSqlDate(args[1]);
|
|
2895
3092
|
if (!a || !b) return null;
|
|
2896
3093
|
const ms = a.getTime() - b.getTime();
|
|
2897
|
-
|
|
3094
|
+
// MySQL 的整月差:先算日历月数差,再按「结束日的日 < 起始日的日」退一档。
|
|
3095
|
+
// 只算日历月差会高估:
|
|
3096
|
+
// TIMESTAMPDIFF(MONTH,'2026-01-15','2026-03-14') = 1(差 1 个月零 29 天,不足 2 个月)
|
|
3097
|
+
// TIMESTAMPDIFF(MONTH,'2026-01-31','2026-03-01') = 1(1/31+1月=2/28,2/28+1月=3/28,够不到 3/1)
|
|
3098
|
+
let months = (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth());
|
|
3099
|
+
if (a.getDate() < b.getDate()) months--;
|
|
2898
3100
|
switch (unit) {
|
|
2899
3101
|
case 'MICROSECOND': return Math.floor(ms * 1000);
|
|
2900
3102
|
case 'SECOND': return Math.floor(ms / 1000);
|
|
@@ -2904,7 +3106,7 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
2904
3106
|
case 'WEEK': return Math.floor(ms / (86400000 * 7));
|
|
2905
3107
|
case 'MONTH': return months;
|
|
2906
3108
|
case 'QUARTER': return Math.floor(months / 3);
|
|
2907
|
-
case 'YEAR': return
|
|
3109
|
+
case 'YEAR': return Math.floor(months / 12);
|
|
2908
3110
|
default: return null;
|
|
2909
3111
|
}
|
|
2910
3112
|
}
|
|
@@ -2995,6 +3197,30 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
2995
3197
|
case 'LTRIM': return args[0] == null ? null : String(args[0]).replace(/^\s+/, '');
|
|
2996
3198
|
case 'RTRIM': return args[0] == null ? null : String(args[0]).replace(/\s+$/, '');
|
|
2997
3199
|
case 'ABS': return args[0] == null ? null : Math.abs(args[0]);
|
|
3200
|
+
case 'SIGN': { // MySQL:正数 1 / 0 → 0 / 负数 -1;NULL → NULL
|
|
3201
|
+
const v = numericIfNumeric(args[0]);
|
|
3202
|
+
if (v === null) return args[0] == null ? null : 0;
|
|
3203
|
+
return v > 0 ? 1 : (v < 0 ? -1 : 0);
|
|
3204
|
+
}
|
|
3205
|
+
case 'TRUNCATE': { // MySQL:直接截断,不四舍五入;NULL → NULL
|
|
3206
|
+
const x = numericIfNumeric(args[0]);
|
|
3207
|
+
if (x === null) return args[0] == null ? null : null;
|
|
3208
|
+
const digits = args[1] === undefined || args[1] === null ? 0 : Math.trunc(Number(args[1])) || 0;
|
|
3209
|
+
const factor = Math.pow(10, digits);
|
|
3210
|
+
if (!Number.isFinite(factor)) return x;
|
|
3211
|
+
return Math.trunc(x * factor) / factor;
|
|
3212
|
+
}
|
|
3213
|
+
case 'SUBSTRING_INDEX': { // MySQL:按分隔符切,返回左边 count 段(count<0 从右数)
|
|
3214
|
+
const s = args[0] == null ? null : String(args[0]);
|
|
3215
|
+
const d = args[1] == null ? null : String(args[1]);
|
|
3216
|
+
if (s === null || d === null || d === '') return null;
|
|
3217
|
+
const n = Math.trunc(Number(args[2]));
|
|
3218
|
+
if (!Number.isFinite(n)) return null;
|
|
3219
|
+
if (n === 0) return '';
|
|
3220
|
+
const parts = s.split(d);
|
|
3221
|
+
if (n > 0) return parts.slice(0, n).join(d);
|
|
3222
|
+
return parts.slice(n).join(d); // n<0:取末尾 |n| 段
|
|
3223
|
+
}
|
|
2998
3224
|
case 'ROUND': {
|
|
2999
3225
|
// MySQL 语义:四舍五入「半远离零」(Math.round 是半向 +∞,对负数会错,如 -3.5→-3)
|
|
3000
3226
|
// 位数 D 可为负(向小数点左侧舍入);非数值/非法输入返回 null,绝不抛 TypeError/RangeError。
|
|
@@ -3974,7 +4200,10 @@ class SQLExecutor {
|
|
|
3974
4200
|
}
|
|
3975
4201
|
if (typeof statement.limit === 'number') candidates = candidates.slice(0, statement.limit);
|
|
3976
4202
|
for (const row of candidates) {
|
|
3977
|
-
|
|
4203
|
+
// 无主键表没有稳定行标识:_rowPkId 会回退到 row.id(业务列名),
|
|
4204
|
+
// 而存储层的 _resolveId 把 id 当 1-based 行号 —— 两者语义不一致,
|
|
4205
|
+
// DELETE ... WHERE id IN (3,5) 会删错行。此时直接传行对象,存储层按引用删。
|
|
4206
|
+
const id = pkCols.length > 0 ? this._rowPkId(row, pkCols) : row;
|
|
3978
4207
|
if (id !== undefined) { ids.push(id); removedRows.push(row); }
|
|
3979
4208
|
}
|
|
3980
4209
|
if (ids.length > 0) {
|
|
@@ -4259,9 +4488,48 @@ class SQLExecutor {
|
|
|
4259
4488
|
return row ? row.id : undefined;
|
|
4260
4489
|
}
|
|
4261
4490
|
|
|
4262
|
-
|
|
4491
|
+
/**
|
|
4492
|
+
* 从 WHERE 表达式里提取「可直接下推给存储层」的等值条件。
|
|
4493
|
+
*
|
|
4494
|
+
* 此前 _readTable 一律 `find(table, {}, ...)` 拉全表回内存再逐行 evaluateExpr,
|
|
4495
|
+
* WHERE 完全没有下推 —— 2 万行表上 `WHERE id = ?` 也要扫 2 万行(约 88ms/次),
|
|
4496
|
+
* 而主键索引本可以做到微秒级。
|
|
4497
|
+
*
|
|
4498
|
+
* 只认最安全的一类:column = <字面量>(含 AND 连接)。
|
|
4499
|
+
* 其它条件(范围、OR、函数、IN、子查询)一律不下推,交回内存过滤,
|
|
4500
|
+
* 避免为了快而改错结果。
|
|
4501
|
+
*/
|
|
4502
|
+
_pushdownEqualities(statement) {
|
|
4503
|
+
const out = {};
|
|
4504
|
+
if (!statement || !statement.where || typeof statement.where !== 'object') return out;
|
|
4505
|
+
const visit = (node) => {
|
|
4506
|
+
if (!node || typeof node !== 'object') return;
|
|
4507
|
+
if (node.type === 'and') { visit(node.left); visit(node.right); return; }
|
|
4508
|
+
if (node.type !== 'compare' || node.op !== '=') return;
|
|
4509
|
+
const isCol = n => n && n.type === 'column' && typeof n.name === 'string';
|
|
4510
|
+
const isVal = n => n && n.type === 'value' && n.value !== null && n.value !== undefined;
|
|
4511
|
+
let col = null, val = null;
|
|
4512
|
+
if (isCol(node.left) && isVal(node.right)) { col = node.left.name; val = node.right.value; }
|
|
4513
|
+
else if (isCol(node.right) && isVal(node.left)) { col = node.right.name; val = node.left.value; }
|
|
4514
|
+
if (col === null) return;
|
|
4515
|
+
const key = col.includes('.') ? col.slice(col.lastIndexOf('.') + 1) : col;
|
|
4516
|
+
// 同一列出现多个等值条件(如 id=1 AND id=2)时不推,交给内存求值
|
|
4517
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) delete out[key];
|
|
4518
|
+
else out[key] = val;
|
|
4519
|
+
};
|
|
4520
|
+
visit(statement.where);
|
|
4521
|
+
return out;
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
async _readTable(table, statement) {
|
|
4263
4525
|
const schema = await this._getSchema(table);
|
|
4264
|
-
|
|
4526
|
+
// 有可下推的等值条件时交给存储层走索引;没有则维持原来的全表读取
|
|
4527
|
+
const filter = statement ? this._pushdownEqualities(statement) : {};
|
|
4528
|
+
const useIndex = Object.keys(filter).length > 0 &&
|
|
4529
|
+
(typeof this.engine.exists === 'function' || typeof this.engine.find === 'function');
|
|
4530
|
+
const rows = useIndex
|
|
4531
|
+
? (await this.engine.find(table, filter, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema))
|
|
4532
|
+
: (await this.engine.find(table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
|
|
4265
4533
|
return { schema, rows };
|
|
4266
4534
|
}
|
|
4267
4535
|
|
|
@@ -4622,6 +4890,17 @@ class SQLExecutor {
|
|
|
4622
4890
|
delete node.column;
|
|
4623
4891
|
return;
|
|
4624
4892
|
}
|
|
4893
|
+
// 投影分支构造的聚合节点形如 { type: 'SUM', column, distinct }(type 就是函数名),
|
|
4894
|
+
// 与 parseAggregateCall 产出的 { type:'aggregate', fn } 并存。
|
|
4895
|
+
// 两种都要替换,否则 SUM(v)/COUNT(*) 整式求值为 null。
|
|
4896
|
+
if (typeof node.type === 'string' && AGG_FUNCS.has(node.type) &&
|
|
4897
|
+
(node.column !== undefined || node.over === undefined)) {
|
|
4898
|
+
const fn = node.type;
|
|
4899
|
+
node.type = 'value';
|
|
4900
|
+
node.value = this._aggValue(group, fn, node.column, node.distinct, node.separator, node.orderBy);
|
|
4901
|
+
delete node.column;
|
|
4902
|
+
return;
|
|
4903
|
+
}
|
|
4625
4904
|
for (const k of Object.keys(node)) {
|
|
4626
4905
|
if (node[k] && typeof node[k] === 'object') this._replaceAggregates(node[k], group);
|
|
4627
4906
|
}
|
|
@@ -5133,7 +5412,7 @@ class SQLExecutor {
|
|
|
5133
5412
|
const res = await this.executeSelect(firstItem.subquery);
|
|
5134
5413
|
rowsAll = { rows: this._subQueryRows(res), schema: null, columns: res.columns };
|
|
5135
5414
|
} else {
|
|
5136
|
-
rowsAll = await this._readTable(firstItem.table);
|
|
5415
|
+
rowsAll = await this._readTable(firstItem.table, statement);
|
|
5137
5416
|
}
|
|
5138
5417
|
schema = rowsAll.schema;
|
|
5139
5418
|
const firstPrefix = firstItem.alias || firstItem.table;
|
|
@@ -5142,7 +5421,7 @@ class SQLExecutor {
|
|
|
5142
5421
|
for (const j of statement.from.joins) {
|
|
5143
5422
|
const rightRes = j.item.subquery
|
|
5144
5423
|
? await this.executeSelect(j.item.subquery)
|
|
5145
|
-
: await this._readTable(j.item.table);
|
|
5424
|
+
: await this._readTable(j.item.table, statement);
|
|
5146
5425
|
const rightPrefix = j.item.alias || j.item.table;
|
|
5147
5426
|
const rightRows = (j.item.subquery ? this._subQueryRows(rightRes) : rightRes.rows).map(r => this._prefixRow(r, rightPrefix));
|
|
5148
5427
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsql-neo",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.5",
|
|
4
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",
|
|
@@ -51,9 +51,10 @@
|
|
|
51
51
|
"test:btree": "node test/btree.test.js",
|
|
52
52
|
"test:native": "node test/native.test.js",
|
|
53
53
|
"test:regress": "node test/regress-5.1.0.js",
|
|
54
|
-
"test:all": "node test/smoke.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",
|
|
54
|
+
"test:all": "node test/smoke.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",
|
|
55
55
|
"test:coverage": "c8 --all --include='lib/**' --exclude='lib/web_ui.js' --reporter=text --reporter=lcov node test/coverage.js",
|
|
56
|
-
"test:orms": "node examples/orms/run-all.js"
|
|
56
|
+
"test:orms": "node examples/orms/run-all.js",
|
|
57
|
+
"test:dml": "node test/dml-constraints.test.js"
|
|
57
58
|
},
|
|
58
59
|
"keywords": [
|
|
59
60
|
"database",
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* DML 与表约束回归测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖两类曾静默出错的问题:
|
|
5
|
+
* 1. 无主键表批量删除删错行 —— 存储层把 id 当 1-based 行号,
|
|
6
|
+
* 边解析边删会让后续行号整体左移(DELETE ... WHERE id <= 2
|
|
7
|
+
* 在 5 行表上只删掉 1 行,且留下的是第 2 行)。
|
|
8
|
+
* 2. UPDATE 改主键/唯一列不校验冲突 —— 直接 Object.assign,
|
|
9
|
+
* 静默产生两行同主键(MySQL / SQLite 都会报 UNIQUE constraint failed)。
|
|
10
|
+
*
|
|
11
|
+
* 每个用例独立建库,避免状态污染。
|
|
12
|
+
*/
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const ROOT = path.join(__dirname, '..');
|
|
16
|
+
const { Database } = require(path.join(ROOT, 'index.js'));
|
|
17
|
+
const { executeSQL } = require(path.join(ROOT, 'lib/sql.js'));
|
|
18
|
+
|
|
19
|
+
const OPTS = { safety: false };
|
|
20
|
+
let pass = 0, fail = 0;
|
|
21
|
+
|
|
22
|
+
function ok(name, cond, extra) {
|
|
23
|
+
if (cond) { pass++; console.log('[OK] ', name); }
|
|
24
|
+
else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra) : ''); }
|
|
25
|
+
}
|
|
26
|
+
const rowsOf = r => (Array.isArray(r) ? r[0] : r).rows;
|
|
27
|
+
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
28
|
+
|
|
29
|
+
/** 跑一个用例:每次给全新的 :memory: 库 */
|
|
30
|
+
async function t(name, fn) {
|
|
31
|
+
const db = new Database(':memory:');
|
|
32
|
+
try {
|
|
33
|
+
const r = await fn({
|
|
34
|
+
exec: (sql) => executeSQL(db, sql, OPTS),
|
|
35
|
+
rows: (sql) => executeSQL(db, sql, OPTS).then(rowsOf),
|
|
36
|
+
scalar: (sql) => executeSQL(db, sql, OPTS).then(r => rowsOf(r)[0][0]),
|
|
37
|
+
mustFail: async (sql) => {
|
|
38
|
+
try { await executeSQL(db, sql, OPTS); return '本该报错却成功了'; }
|
|
39
|
+
catch (e) { return true; }
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
if (r === true) ok(name, true);
|
|
43
|
+
else ok(name, false, r);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
ok(name, false, String(e.message || e).slice(0, 120));
|
|
46
|
+
} finally {
|
|
47
|
+
try { await db.stop(); } catch (_) { /* ignore */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
(async () => {
|
|
52
|
+
console.log('\n--- 无主键表:批量删除曾删错行 ---');
|
|
53
|
+
await t('无主键 DELETE 多个连续行', async ({ exec, rows }) => {
|
|
54
|
+
await exec(`CREATE TABLE t (id INT)`);
|
|
55
|
+
await exec(`INSERT INTO t VALUES (1),(2),(3),(4),(5)`);
|
|
56
|
+
await exec(`DELETE FROM t WHERE id <= 2`);
|
|
57
|
+
return eq(await rows(`SELECT id FROM t ORDER BY id`), [[3], [4], [5]]);
|
|
58
|
+
});
|
|
59
|
+
await t('无主键 DELETE IN 列表', async ({ exec, rows }) => {
|
|
60
|
+
await exec(`CREATE TABLE t (id INT)`);
|
|
61
|
+
await exec(`INSERT INTO t VALUES (1),(2),(3),(4),(5)`);
|
|
62
|
+
await exec(`DELETE FROM t WHERE id IN (1,4)`);
|
|
63
|
+
return eq(await rows(`SELECT id FROM t ORDER BY id`), [[2], [3], [5]]);
|
|
64
|
+
});
|
|
65
|
+
await t('无主键 DELETE 单行(回归对照)', async ({ exec, rows }) => {
|
|
66
|
+
await exec(`CREATE TABLE t (id INT)`);
|
|
67
|
+
await exec(`INSERT INTO t VALUES (1),(2),(3)`);
|
|
68
|
+
await exec(`DELETE FROM t WHERE id = 2`);
|
|
69
|
+
return eq(await rows(`SELECT id FROM t ORDER BY id`), [[1], [3]]);
|
|
70
|
+
});
|
|
71
|
+
await t('无主键 DELETE 全部', async ({ exec, rows }) => {
|
|
72
|
+
await exec(`CREATE TABLE t (id INT)`);
|
|
73
|
+
await exec(`INSERT INTO t VALUES (1),(2),(3)`);
|
|
74
|
+
await exec(`DELETE FROM t`);
|
|
75
|
+
return eq(await rows(`SELECT id FROM t`), []);
|
|
76
|
+
});
|
|
77
|
+
await t('有主键 DELETE 多个(回归对照)', async ({ exec, rows }) => {
|
|
78
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY)`);
|
|
79
|
+
await exec(`INSERT INTO t VALUES (1),(2),(3),(4),(5)`);
|
|
80
|
+
await exec(`DELETE FROM t WHERE id <= 2`);
|
|
81
|
+
return eq(await rows(`SELECT id FROM t ORDER BY id`), [[3], [4], [5]]);
|
|
82
|
+
});
|
|
83
|
+
await t('无主键 DELETE 后行数正确', async ({ exec, scalar }) => {
|
|
84
|
+
await exec(`CREATE TABLE t (id INT)`);
|
|
85
|
+
await exec(`INSERT INTO t VALUES (1),(2),(3)`);
|
|
86
|
+
const r = await exec(`DELETE FROM t WHERE id > 1`);
|
|
87
|
+
if (r.affectedRows !== 2) return { affectedRows: r.affectedRows, want: 2 };
|
|
88
|
+
return eq(await scalar(`SELECT COUNT(*) AS c FROM t`), 1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
console.log('\n--- UPDATE 改主键 / 唯一列曾不校验冲突 ---');
|
|
92
|
+
await t('UPDATE 主键成已存在值 → 报错', async ({ exec, mustFail }) => {
|
|
93
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY, v INT)`);
|
|
94
|
+
await exec(`INSERT INTO t VALUES (1,10),(2,20)`);
|
|
95
|
+
return mustFail(`UPDATE t SET id = 1 WHERE id = 2`);
|
|
96
|
+
});
|
|
97
|
+
await t('UPDATE 主键冲突后数据未损坏', async ({ exec, rows }) => {
|
|
98
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY, v INT)`);
|
|
99
|
+
await exec(`INSERT INTO t VALUES (1,10),(2,20),(3,30)`);
|
|
100
|
+
try { await exec(`UPDATE t SET id = 1 WHERE id = 2`); } catch (_) { /* expected */ }
|
|
101
|
+
return eq(await rows(`SELECT id, v FROM t ORDER BY id`), [[1, 10], [2, 20], [3, 30]]);
|
|
102
|
+
});
|
|
103
|
+
await t('UPDATE 主键成不冲突值 → 成功', async ({ exec, rows }) => {
|
|
104
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY, v INT)`);
|
|
105
|
+
await exec(`INSERT INTO t VALUES (1,10),(2,20),(3,30)`);
|
|
106
|
+
await exec(`UPDATE t SET id = 9 WHERE id = 2`);
|
|
107
|
+
return eq(await rows(`SELECT id, v FROM t ORDER BY id`), [[1, 10], [3, 30], [9, 20]]);
|
|
108
|
+
});
|
|
109
|
+
await t('UPDATE 主键为自身值 → 允许', async ({ exec, scalar }) => {
|
|
110
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY, v INT)`);
|
|
111
|
+
await exec(`INSERT INTO t VALUES (1,10),(9,20)`);
|
|
112
|
+
await exec(`UPDATE t SET id = 9 WHERE id = 9`);
|
|
113
|
+
return eq(await scalar(`SELECT COUNT(*) AS c FROM t`), 2);
|
|
114
|
+
});
|
|
115
|
+
await t('UPDATE UNIQUE 列成已存在值 → 报错', async ({ exec, mustFail }) => {
|
|
116
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY, e TEXT UNIQUE)`);
|
|
117
|
+
await exec(`INSERT INTO t VALUES (1,'a'),(2,'b')`);
|
|
118
|
+
return mustFail(`UPDATE t SET e = 'a' WHERE id = 2`);
|
|
119
|
+
});
|
|
120
|
+
await t('UPDATE UNIQUE 列成不冲突值 → 成功', async ({ exec, rows }) => {
|
|
121
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY, e TEXT UNIQUE)`);
|
|
122
|
+
await exec(`INSERT INTO t VALUES (1,'a'),(2,'b')`);
|
|
123
|
+
await exec(`UPDATE t SET e = 'c' WHERE id = 2`);
|
|
124
|
+
return eq(await rows(`SELECT id, e FROM t ORDER BY id`), [[1, 'a'], [2, 'c']]);
|
|
125
|
+
});
|
|
126
|
+
await t('UPDATE 非约束列不受影响', async ({ exec, rows }) => {
|
|
127
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY, v INT)`);
|
|
128
|
+
await exec(`INSERT INTO t VALUES (1,10),(2,20)`);
|
|
129
|
+
await exec(`UPDATE t SET v = 99 WHERE id = 1`);
|
|
130
|
+
return eq(await rows(`SELECT id, v FROM t ORDER BY id`), [[1, 99], [2, 20]]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
console.log('\n--- 既有约束行为回归对照 ---');
|
|
134
|
+
await t('INSERT 主键重复 → 报错', async ({ exec, mustFail }) => {
|
|
135
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY)`);
|
|
136
|
+
await exec(`INSERT INTO t VALUES (1)`);
|
|
137
|
+
return mustFail(`INSERT INTO t VALUES (1)`);
|
|
138
|
+
});
|
|
139
|
+
await t('INSERT NOT NULL 违反 → 报错', async ({ exec, mustFail }) => {
|
|
140
|
+
await exec(`CREATE TABLE t (id INT, v TEXT NOT NULL)`);
|
|
141
|
+
return mustFail(`INSERT INTO t VALUES (1,NULL)`);
|
|
142
|
+
});
|
|
143
|
+
await t('INSERT UNIQUE 重复 → 报错', async ({ exec, mustFail }) => {
|
|
144
|
+
await exec(`CREATE TABLE t (id INT, e TEXT UNIQUE)`);
|
|
145
|
+
await exec(`INSERT INTO t VALUES (1,'a')`);
|
|
146
|
+
return mustFail(`INSERT INTO t VALUES (2,'a')`);
|
|
147
|
+
});
|
|
148
|
+
await t('UNIQUE 允许多个 NULL', async ({ exec, scalar }) => {
|
|
149
|
+
await exec(`CREATE TABLE t (id INT, e TEXT UNIQUE)`);
|
|
150
|
+
await exec(`INSERT INTO t VALUES (1,NULL),(2,NULL)`);
|
|
151
|
+
return eq(await scalar(`SELECT COUNT(*) AS c FROM t`), 2);
|
|
152
|
+
});
|
|
153
|
+
await t('删除后主键可复用', async ({ exec, scalar }) => {
|
|
154
|
+
await exec(`CREATE TABLE t (id INT PRIMARY KEY, v TEXT)`);
|
|
155
|
+
await exec(`INSERT INTO t VALUES (1,'a')`);
|
|
156
|
+
await exec(`DELETE FROM t WHERE id = 1`);
|
|
157
|
+
await exec(`INSERT INTO t VALUES (1,'c')`);
|
|
158
|
+
return eq(await scalar(`SELECT v FROM t WHERE id = 1`), 'c');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
162
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
163
|
+
})();
|
|
@@ -99,12 +99,16 @@ const hasHandles = typeof native.jsqlInstanceNew === 'function';
|
|
|
99
99
|
const B = new JSQL({ path: dB, mode: 'hybrid' });
|
|
100
100
|
await A.start();
|
|
101
101
|
await B.start();
|
|
102
|
-
if (hasHandles) {
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
if (!hasHandles) {
|
|
103
|
+
// 旧版二进制没有句柄 API,两个 JS 实例会落到同一个默认实例上,
|
|
104
|
+
// 此时建同名表必然冲突 —— 只能验证「回退到单实例」这个事实本身。
|
|
105
|
+
ok('旧版二进制下回退单实例(_handle 为 null)', A._handle === null && B._handle === null);
|
|
106
|
+
skipTest('JS 层多实例并存测试', '旧版预编译二进制无 jsqlInstanceNew,两实例共享默认实例');
|
|
107
|
+
await A.stop();
|
|
108
|
+
await B.stop();
|
|
105
109
|
} else {
|
|
106
|
-
|
|
107
|
-
}
|
|
110
|
+
ok('JS 层已启用多实例', A._multiInstance === true);
|
|
111
|
+
ok('两实例句柄不同', A._handle !== B._handle, { a: A._handle, b: B._handle });
|
|
108
112
|
await A.createTable('t', SCHEMA);
|
|
109
113
|
await A.insert('t', { tag: 'A1' });
|
|
110
114
|
await A.insert('t', { tag: 'A2' });
|
|
@@ -124,7 +128,8 @@ const hasHandles = typeof native.jsqlInstanceNew === 'function';
|
|
|
124
128
|
|
|
125
129
|
await A.stop();
|
|
126
130
|
await B.stop();
|
|
127
|
-
|
|
131
|
+
ok('stop 释放句柄', A._handle === null && B._handle === null);
|
|
132
|
+
} // else: hasHandles
|
|
128
133
|
fs.rmSync(dA, { recursive: true, force: true });
|
|
129
134
|
fs.rmSync(dB, { recursive: true, force: true });
|
|
130
135
|
}
|
package/test/sql-parser.test.js
CHANGED
|
@@ -584,6 +584,195 @@ const rowsOf = (r) => (Array.isArray(r) ? r[0] : r).rows;
|
|
|
584
584
|
ok('分组 + ORDER BY + LIMIT 只返回 1 行', lim.rows.length === 1 && lim.rows[0][0] === 'ops', lim.rows);
|
|
585
585
|
}
|
|
586
586
|
|
|
587
|
+
/* ---- 括号分组后接运算符:曾报 Unexpected token '*' ---- */
|
|
588
|
+
console.log('\n--- BUG 修复: 括号分组参与后续运算 ---');
|
|
589
|
+
{
|
|
590
|
+
const run = async (sql) => executeSQL(engine, sql, OPTS);
|
|
591
|
+
// parseNot 的括号分组分支:解析到 ')' 就闭合返回,运算符留在流里
|
|
592
|
+
const cases = [
|
|
593
|
+
['SELECT (1+2)*3 AS v', 9],
|
|
594
|
+
['SELECT (3)*4 AS v', 12],
|
|
595
|
+
['SELECT (1+2)*(3+4) AS v', 21],
|
|
596
|
+
['SELECT (1+2)/2 AS v', 1.5],
|
|
597
|
+
['SELECT 10-(2+3) AS v', 5],
|
|
598
|
+
['SELECT (2+3)%2 AS v', 1],
|
|
599
|
+
['SELECT (1+2)+3 AS v', 6],
|
|
600
|
+
['SELECT ((1+2))*3 AS v', 9], // 嵌套括号
|
|
601
|
+
['SELECT (1+2) AS v', 3], // 无运算符(回归对照)
|
|
602
|
+
['SELECT 2*(3+4) AS v', 14], // ( 在右操作数位置(回归对照)
|
|
603
|
+
];
|
|
604
|
+
for (const [sql, expect] of cases) {
|
|
605
|
+
let got = null, err = null;
|
|
606
|
+
try { got = (await run(sql)).rows[0][0]; } catch (e) { err = e; }
|
|
607
|
+
ok(`${sql} = ${expect}`, err === null && got === expect,
|
|
608
|
+
err ? String(err.message || err) : got);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// WHERE 里的括号同样要能参与比较
|
|
612
|
+
const w = await run(`SELECT name FROM emp WHERE (sal + 100) > 350 ORDER BY sal DESC`);
|
|
613
|
+
ok('WHERE (sal+100) > 350 括号参与比较',
|
|
614
|
+
w.rows.length === 2 && w.rows[0][0] === 'c' && w.rows[1][0] === 'd', w.rows);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* ---- 聚合参与算术:SUM(x)/COUNT(*) 这类写法曾直接报错 ---- */
|
|
618
|
+
console.log('\n--- BUG 修复: 聚合函数参与算术运算 ---');
|
|
619
|
+
{
|
|
620
|
+
const run = async (sql) => executeSQL(engine, sql, OPTS);
|
|
621
|
+
// mock emp: eng(sal 100,200) / ops(sal 300,300)
|
|
622
|
+
// 全表 SUM=900 COUNT=4 AVG=225 MIN=100 MAX=300
|
|
623
|
+
const cases = [
|
|
624
|
+
['SELECT SUM(sal)/COUNT(*) AS avg FROM emp', 225], // 算平均值的标准写法
|
|
625
|
+
['SELECT SUM(sal)-MIN(sal) AS range FROM emp', 800], // 极差
|
|
626
|
+
['SELECT MAX(sal)*2 AS m FROM emp', 600],
|
|
627
|
+
['SELECT SUM(sal)+1 AS s FROM emp', 901],
|
|
628
|
+
['SELECT COUNT(*)*100 AS c FROM emp', 400],
|
|
629
|
+
['SELECT SUM(sal)/COUNT(*) FROM emp', 225], // 无别名
|
|
630
|
+
];
|
|
631
|
+
for (const [sql, expect] of cases) {
|
|
632
|
+
let got = null, err = null;
|
|
633
|
+
try { got = (await run(sql)).rows[0][0]; } catch (e) { err = e; }
|
|
634
|
+
ok(`${sql} = ${expect}`, err === null && got === expect,
|
|
635
|
+
err ? String(err.message || err) : got);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 分组场景:分组路径也要能算 SUM/COUNT
|
|
639
|
+
const g = await run(`SELECT dept, SUM(sal)/COUNT(*) AS avg FROM emp GROUP BY dept ORDER BY avg DESC`);
|
|
640
|
+
ok('分组 + SUM/COUNT 每组各自的平均',
|
|
641
|
+
g.rows.length === 2 && g.rows[0][0] === 'ops' && g.rows[0][1] === 300
|
|
642
|
+
&& g.rows[1][1] === 150, g.rows);
|
|
643
|
+
|
|
644
|
+
// 与其它聚合混用不能互相破坏
|
|
645
|
+
const m = await run(`SELECT SUM(sal)/COUNT(*) AS avg, MAX(sal) AS mx, MIN(sal) AS mn FROM emp`);
|
|
646
|
+
ok('SUM/COUNT 与 MAX/MIN 并存', m.rows[0][0] === 225 && m.rows[0][1] === 300 && m.rows[0][2] === 100, m.rows);
|
|
647
|
+
|
|
648
|
+
// 原有路径回归对照(不带运算符)
|
|
649
|
+
const s = await run(`SELECT SUM(sal) AS s, COUNT(*) AS c, AVG(sal) AS a FROM emp`);
|
|
650
|
+
ok('SUM/COUNT/AVG 单独使用不受影响', s.rows[0][0] === 900 && s.rows[0][1] === 4 && s.rows[0][2] === 225, s.rows);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* ---- TIMESTAMPDIFF 整月差:曾按日历月差高估 ---- */
|
|
654
|
+
console.log('\n--- BUG 修复: TIMESTAMPDIFF 整月差语义 ---');
|
|
655
|
+
{
|
|
656
|
+
const run = async (sql) => executeSQL(engine, sql, OPTS);
|
|
657
|
+
// MySQL:先算日历月差,再按「结束日的日 < 起始日的日」退一档。
|
|
658
|
+
// 只算日历月差会高估,且 YEAR 同样受影响。
|
|
659
|
+
const cases = [
|
|
660
|
+
// [SQL 片段, 期望, 描述]
|
|
661
|
+
[`TIMESTAMPDIFF(MONTH,'2026-01-31','2026-03-01')`, 1, '月末出发不足整月'],
|
|
662
|
+
[`TIMESTAMPDIFF(MONTH,'2026-01-15','2026-03-14')`, 1, '差一月零29天不满两月'],
|
|
663
|
+
[`TIMESTAMPDIFF(MONTH,'2026-01-01','2026-03-01')`, 2, '月初对齐'],
|
|
664
|
+
[`TIMESTAMPDIFF(MONTH,'2026-01-15','2026-03-15')`, 2, '同日对齐'],
|
|
665
|
+
[`TIMESTAMPDIFF(YEAR,'2020-06-15','2026-06-14')`, 5, 'YEAR 差一天不满整年'],
|
|
666
|
+
[`TIMESTAMPDIFF(YEAR,'2020-06-15','2026-06-15')`, 6, 'YEAR 整年'],
|
|
667
|
+
[`TIMESTAMPDIFF(QUARTER,'2026-01-01','2026-07-01')`, 2, 'QUARTER 整季'],
|
|
668
|
+
[`TIMESTAMPDIFF(DAY,'2026-01-01','2026-03-01')`, 59, 'DAY 不受影响'],
|
|
669
|
+
];
|
|
670
|
+
for (const [expr, expect, desc] of cases) {
|
|
671
|
+
let got = null, err = null;
|
|
672
|
+
try { got = (await run(`SELECT ${expr} AS v`)).rows[0][0]; } catch (e) { err = e; }
|
|
673
|
+
ok(`${desc}: ${expr} = ${expect}`, err === null && got === expect,
|
|
674
|
+
err ? String(err.message || err) : got);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/* ---- 隐式类型转换:+ 必须是算术而非字符串拼接 ---- */
|
|
679
|
+
console.log('\n--- BUG 修复: 算术运算中的隐式类型转换 ---');
|
|
680
|
+
{
|
|
681
|
+
const run = async (sql) => executeSQL(engine, sql, OPTS);
|
|
682
|
+
// 语义已用 Python sqlite3 实测核对:'5'+5=10、'abc'+1=1、'a'+'b'=0
|
|
683
|
+
const cases = [
|
|
684
|
+
[`SELECT '5' + 5 AS v`, 10, '数字字符串 + 数字'],
|
|
685
|
+
[`SELECT 5 + '5' AS v`, 10, '数字 + 数字字符串'],
|
|
686
|
+
[`SELECT '5' + '5' AS v`, 10, '两个数字字符串'],
|
|
687
|
+
[`SELECT '2.5' * 2 AS v`, 5, '数字字符串乘法'],
|
|
688
|
+
[`SELECT '10' - 4 AS v`, 6, '数字字符串减法'],
|
|
689
|
+
[`SELECT 'abc' + 1 AS v`, 1, '非数字按 0(MySQL/SQLite 语义)'],
|
|
690
|
+
[`SELECT 'a' + 'b' AS v`, 0, '两侧都非数字返 0'],
|
|
691
|
+
[`SELECT 1 + 2 AS v`, 3, '纯数字回归对照'],
|
|
692
|
+
];
|
|
693
|
+
for (const [sql, expect, desc] of cases) {
|
|
694
|
+
let got = null, err = null;
|
|
695
|
+
try { got = (await run(sql)).rows[0][0]; } catch (e) { err = e; }
|
|
696
|
+
ok(`${desc}: ${sql} = ${expect}`, err === null && got === expect,
|
|
697
|
+
err ? String(err.message || err) : got);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// CAST(... AS SIGNED) 是 MySQL 整型别名,此前未列入整型分支,
|
|
701
|
+
// 导致 CAST('12abc' AS SIGNED) 原样返回字符串
|
|
702
|
+
const cast = [
|
|
703
|
+
[`SELECT CAST('12abc' AS SIGNED) AS v`, 12, 'SIGNED 取数字前缀'],
|
|
704
|
+
[`SELECT CAST('abc' AS SIGNED) AS v`, 0, 'SIGNED 非数字返 0'],
|
|
705
|
+
[`SELECT CAST(12.9 AS SIGNED) AS v`, 12, 'SIGNED 截断小数'],
|
|
706
|
+
[`SELECT CAST(12.9 AS INTEGER) AS v`, 12, 'INTEGER 回归对照'],
|
|
707
|
+
];
|
|
708
|
+
for (const [sql, expect, desc] of cast) {
|
|
709
|
+
let got = null, err = null;
|
|
710
|
+
try { got = (await run(sql)).rows[0][0]; } catch (e) { err = e; }
|
|
711
|
+
ok(`${desc}: ${sql} = ${expect}`, err === null && got === expect,
|
|
712
|
+
err ? String(err.message || err) : got);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* ---- CASE WHEN 裸值条件 + 补齐缺失的 MySQL 函数 ---- */
|
|
717
|
+
console.log('\n--- BUG 修复: CASE 裸值条件 / SIGN / TRUNCATE / SUBSTRING_INDEX ---');
|
|
718
|
+
{
|
|
719
|
+
const run = async (sql) => executeSQL(engine, sql, OPTS);
|
|
720
|
+
const one = async (sql) => (await run(sql)).rows[0][0];
|
|
721
|
+
|
|
722
|
+
// 搜索形式 CASE 的条件可能是裸值/裸列,此前 parseComparison 只收比较式
|
|
723
|
+
const caseCases = [
|
|
724
|
+
[`SELECT CASE WHEN 1 THEN 'a' ELSE 'b' END AS v`, 'a', 'CASE WHEN 裸数字'],
|
|
725
|
+
[`SELECT CASE WHEN 0 THEN 'a' ELSE 'b' END AS v`, 'b', 'CASE WHEN 假'],
|
|
726
|
+
[`SELECT CASE WHEN NULL THEN 'a' ELSE 'b' END AS v`, 'b', 'CASE WHEN NULL 走 ELSE'],
|
|
727
|
+
[`SELECT CASE WHEN 1=1 THEN 'a' ELSE 'b' END AS v`, 'a', 'CASE WHEN 比较式'],
|
|
728
|
+
[`SELECT CASE WHEN 1 IN (1,2) THEN 'y' ELSE 'n' END AS v`, 'y', 'CASE WHEN IN'],
|
|
729
|
+
[`SELECT CASE 2 WHEN 2 THEN 'y' ELSE 'n' END AS v`, 'y', 'CASE 简单形式'],
|
|
730
|
+
];
|
|
731
|
+
for (const [sql, expect, desc] of caseCases) {
|
|
732
|
+
let got = null, err = null;
|
|
733
|
+
try { got = await one(sql); } catch (e) { err = e; }
|
|
734
|
+
ok(`${desc}`, err === null && got === expect, err ? String(err.message || err) : got);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 缺失的 MySQL 函数
|
|
738
|
+
const fnCases = [
|
|
739
|
+
['SIGN(-9)', -1, 'SIGN 负'], ['SIGN(9)', 1, 'SIGN 正'], ['SIGN(0)', 0, 'SIGN 零'],
|
|
740
|
+
['TRUNCATE(3.987,2)', 3.98, 'TRUNCATE 截断而非四舍五入'],
|
|
741
|
+
['TRUNCATE(-3.987,2)', -3.98, 'TRUNCATE 负数'],
|
|
742
|
+
[`SUBSTRING_INDEX('a.b.c','.',2)`, 'a.b', 'SUBSTRING_INDEX 取左'],
|
|
743
|
+
[`SUBSTRING_INDEX('a.b.c','.',-1)`, 'c', 'SUBSTRING_INDEX 取右'],
|
|
744
|
+
[`SUBSTRING_INDEX('a.b.c','.',0)`, '', 'SUBSTRING_INDEX 取 0 段'],
|
|
745
|
+
];
|
|
746
|
+
for (const [expr, expect, desc] of fnCases) {
|
|
747
|
+
let got = null, err = null;
|
|
748
|
+
try { got = await one(`SELECT ${expr} AS v`); } catch (e) { err = e; }
|
|
749
|
+
ok(`${desc}: ${expr} = ${JSON.stringify(expect)}`, err === null && got === expect,
|
|
750
|
+
err ? String(err.message || err) : got);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/* ---- 列级 CHECK 约束:此前直接报 CREATE TABLE 语法错误 ---- */
|
|
755
|
+
console.log('\n--- BUG 修复: 列级 CHECK 约束解析 ---');
|
|
756
|
+
{
|
|
757
|
+
// 表级 CHECK 早已能解析(同样只是跳过),列级却会报
|
|
758
|
+
// Expected ',' or ')' in CREATE TABLE, got 'CHECK'
|
|
759
|
+
// 因为 parseColumnDef 的 default 分支直接 return def,把 CHECK 留在流里。
|
|
760
|
+
// CHECK 刻意不在 KEYWORDS 里(避免影响同名标识符),要用 isWord 匹配。
|
|
761
|
+
const ddls = [
|
|
762
|
+
`CREATE TABLE ck1 (a INT CHECK(a > 0))`,
|
|
763
|
+
`CREATE TABLE ck2 (a INT CHECK(a > 0), b TEXT)`,
|
|
764
|
+
`CREATE TABLE ck3 (a INT NOT NULL CHECK(a > 0))`,
|
|
765
|
+
`CREATE TABLE ck4 (a TEXT, b INT CHECK(b > 0))`,
|
|
766
|
+
`CREATE TABLE ck5 (a INT, CHECK(a > 0))`, // 表级(回归对照)
|
|
767
|
+
`CREATE TABLE ck6 (a INT UNIQUE CHECK(a > 0))`,
|
|
768
|
+
];
|
|
769
|
+
for (const ddl of ddls) {
|
|
770
|
+
let err = null;
|
|
771
|
+
try { parseSQL(ddl); } catch (e) { err = e; }
|
|
772
|
+
ok(`可解析:${ddl}`, err === null, err ? String(err.message || err) : '');
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
587
776
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
588
777
|
process.exit(fail === 0 ? 0 : 1);
|
|
589
778
|
})();
|