jsql-neo 6.3.4 → 6.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **One engine to rule them all** — a Rust-powered embedded database that speaks your language:
4
4
  > MySQL. PostgreSQL. MongoDB. Redis. SQL. TypeScript. The browser. **And it fits in one npm package.**
5
5
 
6
- > **v6.3.4** — official release build · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo)
6
+ > **v6.3.6** — official release build · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo)
7
7
 
8
8
  ![Engines](https://img.shields.io/badge/engines-Native%20%7C%20WASM%20%7C%20Pure%20JS-7ee787)
9
9
  ![MySQL](https://img.shields.io/badge/protocol-MySQL%20compatible-1f6feb)
@@ -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.4
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.4
1473
+ jsql-neo v6.3.6
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.4`.
1494
+ The status bar shows: `db=<name> dialect=<d> mode=<tui|batch> ver=6.3.6`.
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.4
4178
+ jsql-neo v6.3.6
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.4 · 最后更新:2026-09-27*
5278
+ *文档版本:v6.3.6 · 最后更新: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.4
7050
+ jsql-neo v6.3.6
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.4 · 共 19 个附录 · 最后更新:2026-09-27*
7706
+ *文档版本:v6.3.6 · 共 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.4 · 附录 A–Z · 全文 6000+ 行 · 最后更新:2026-09-27*
8313
+ *文档版本:v6.3.6 · 附录 A–Z · 全文 6000+ 行 · 最后更新:2026-09-27*
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSQL-NEO v6.3.4 — Rust-Powered Embedded Database (WASM + HTTP)
2
+ * JSQL-NEO v6.3.6 — Rust-Powered Embedded Database (WASM + HTTP)
3
3
  *
4
4
  * @example
5
5
  * const jsql = require('jsql-neo');
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
- Object.assign(row, data);
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
- const row = this._resolveId(table, id);
1567
- const idx = row ? table._rows.findIndex(r => r === row) : -1;
1568
- if (idx !== -1) {
1569
- table._rows.splice(idx, 1);
1570
- removed++;
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();
@@ -707,11 +707,41 @@ class JSQL {
707
707
  if (!this._runHooks('beforeUpdate', [table, id, data])) return;
708
708
  const seq = this._seqIdByPk(table, id);
709
709
  if (seq === null) return;
710
+ // 改主键 / UNIQUE 列前先查冲突(同步版,与 lib/database.js 的修复对齐)。
711
+ // 此前直接写入,导致 UPDATE t SET id = 1 WHERE id = 2 静默产生两行同主键。
712
+ this._assertNoKeyConflictSync(table, id, data);
710
713
  if (!this._opBuffer.update[table]) this._opBuffer.update[table] = [];
711
714
  this._opBuffer.update[table].push([seq, data]);
712
715
  this._scheduleOpFlush();
713
716
  }
714
717
 
718
+ /** UPDATE 后的主键 / UNIQUE 值是否与其它行冲突(同步检查) */
719
+ _assertNoKeyConflictSync(table, id, data) {
720
+ const schema = this._schemas && this._schemas[table];
721
+ if (!schema) return;
722
+ const fields = Object.keys(schema).filter(f => schema[f] && (schema[f].primaryKey || schema[f].unique));
723
+ if (!fields.length) return;
724
+ const raw = this._n('jsqlFind', table, '{}', 1000000, 0);
725
+ const all = safeParse(raw);
726
+ if (!Array.isArray(all)) return;
727
+ const pk = this._pkOf(table) || 'id';
728
+ // jsqlFind 返回的行结构是 { id, fields:{...} } —— 值在 fields 里,不在顶层。
729
+ const valOf = (r, f) => (r && r.fields && typeof r.fields === 'object' ? r.fields[f] : r ? r[f] : undefined);
730
+ const idOf = (r) => (r && r.fields ? valOf(r, pk) : r ? r[pk] : undefined);
731
+ for (const f of fields) {
732
+ if (!Object.prototype.hasOwnProperty.call(data, f)) continue;
733
+ const nv = data[f];
734
+ const self = all.find(r => String(idOf(r)) === String(id));
735
+ if (self && valOf(self, f) === nv) continue; // 值没变
736
+ if (all.some(r => r !== self && valOf(r, f) === nv)) {
737
+ const e = new Error(`Duplicate entry '${nv}' for key '${f}'`);
738
+ e.code = 'ER_DUP_ENTRY';
739
+ e.errno = 1062;
740
+ throw e;
741
+ }
742
+ }
743
+ }
744
+
715
745
  removeById(table, id) {
716
746
  if (!this._runHooks('beforeDelete', [table, id])) return;
717
747
  const seq = this._seqIdByPk(table, id);
package/lib/sql.js CHANGED
@@ -610,6 +610,17 @@ class Parser {
610
610
 
611
611
  while (true) {
612
612
  const t = this.peek();
613
+ // 列级 CHECK (age INT CHECK(age >= 0))。
614
+ // CHECK 刻意不在 KEYWORDS 里(避免影响同名标识符),所以用 isWord 匹配,
615
+ // 放在 keyword switch 之外。表级 CHECK 已能被解析(同样只是跳过),
616
+ // 列级此前会直接 return def,把 CHECK 留在流里 →
617
+ // "Expected ',' or ')' in CREATE TABLE, got 'CHECK'"。
618
+ // 与表级保持一致:解析并跳过,条件文本存到 def.check 供后续实现。
619
+ if (this.isWord('CHECK')) {
620
+ this.next();
621
+ if (this.peek().type === 'op' && this.peek().value === '(') this._skipParens();
622
+ continue;
623
+ }
613
624
  if (t.type === 'keyword') {
614
625
  switch (t.value) {
615
626
  case 'PRIMARY':
@@ -1073,8 +1084,19 @@ class Parser {
1073
1084
  this.expect('op', ')');
1074
1085
  aggregate = { type: 'COUNT', column: col, distinct: aggDistinct };
1075
1086
  aggregate = this.parseOptionalOver(aggregate);
1076
- aggregate.alias = this.parseOptionalAlias();
1077
- columns.push({ expr: col, aggregate: 'COUNT', column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct });
1087
+ // 聚合后紧跟运算符:SUM(v)/COUNT(*) / COUNT(*)-1 …
1088
+ // 顶层聚合默认被表示为独立的 aggregate 字段,不参与后续运算,
1089
+ // 运算符会留在流里 → "Unexpected token '/' after statement"。
1090
+ // 此时整体降级成 scalar 表达式(arith 里嵌 aggregate),
1091
+ // 由执行层的嵌套聚合替换逻辑求值。
1092
+ // 注意:先看尾随运算符,再取别名 —— 顺序反了会把 AS 当成运算符的一部分。
1093
+ const cntTail = this.parseArithTail(aggregate);
1094
+ if (cntTail) {
1095
+ columns.push({ expr: null, scalar: cntTail, alias: this.parseOptionalAlias() });
1096
+ } else {
1097
+ aggregate.alias = this.parseOptionalAlias();
1098
+ columns.push({ expr: col, aggregate: 'COUNT', column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct });
1099
+ }
1078
1100
  } else if (t.type === 'keyword' && ['SUM', 'AVG', 'MIN', 'MAX'].includes(t.value)) {
1079
1101
  this.next();
1080
1102
  const fn = t.value;
@@ -1085,8 +1107,14 @@ class Parser {
1085
1107
  this.expect('op', ')');
1086
1108
  aggregate = { type: fn, column: col, distinct: aggDistinct2 };
1087
1109
  aggregate = this.parseOptionalOver(aggregate);
1088
- aggregate.alias = this.parseOptionalAlias();
1089
- columns.push({ expr: col, aggregate: fn, column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct2 });
1110
+ // 同 COUNT 分支:聚合参与后续算术时降级成 scalar 表达式
1111
+ const fnTail = this.parseArithTail(aggregate);
1112
+ if (fnTail) {
1113
+ columns.push({ expr: null, scalar: fnTail, alias: this.parseOptionalAlias() });
1114
+ } else {
1115
+ aggregate.alias = this.parseOptionalAlias();
1116
+ columns.push({ expr: col, aggregate: fn, column: col, alias: aggregate.alias, over: aggregate.over || null, window: !!aggregate.over, distinct: aggDistinct2 });
1117
+ }
1090
1118
  } else if (t.type === 'op' && t.value === '*') {
1091
1119
  this.next();
1092
1120
  columns.push({ expr: '*' });
@@ -1762,9 +1790,49 @@ class Parser {
1762
1790
  this.peek(1) && this.peek(1).type === 'keyword' && this.peek(1).value === 'SELECT';
1763
1791
  if (!isSubqueryParen && this.peek().type === 'op' && this.peek().value === '(') {
1764
1792
  this.next();
1765
- const e = this.parseExpr();
1793
+ // 递归解析括号内的完整表达式(到 ')' 为止)。
1794
+ // 此处不能用 parseExpr():它一路走到 parseComparison,
1795
+ // 而内层看不到 ')' 会抛 "Expected comparison operator, got ')'"。
1796
+ // 用 bareExprDepth>0 的 parseArgExpr 走同一套优先级,但允许裸操作数收尾。
1797
+ this.bareExprDepth = (this.bareExprDepth || 0) + 1;
1798
+ let left;
1799
+ try {
1800
+ left = this.parseOr();
1801
+ } finally {
1802
+ this.bareExprDepth--;
1803
+ }
1766
1804
  this.expect('op', ')');
1767
- return e;
1805
+ // 括号整体是一个完整的左操作数,后面若紧跟运算符必须继续参与运算:
1806
+ // SELECT (1+2)*3 / SELECT (a+b)/2 / WHERE (x+1) > 5
1807
+ // 直接 return 会把运算符留在流里 → "Unexpected token '*' after statement"
1808
+ // (2*(3+4) 正常是因为 '(' 出现在右操作数位置,不走这个分支。)
1809
+ // 先乘除后加减,与 parseTerm/parseAdditive 的优先级一致。
1810
+ for (;;) {
1811
+ const t = this.peek();
1812
+ if (t.type !== 'op' || !['*', '/', '%'].includes(t.value)) break;
1813
+ this.next();
1814
+ const right = this.parseExpr();
1815
+ left = { type: 'arith', op: t.value, left, right };
1816
+ }
1817
+ for (;;) {
1818
+ const t = this.peek();
1819
+ if (t.type !== 'op' || !['+', '-'].includes(t.value)) break;
1820
+ this.next();
1821
+ const right = this.parseExpr();
1822
+ left = { type: 'arith', op: t.value, left, right };
1823
+ }
1824
+ // 尾随比较/条件运算符:WHERE (a) = 1 / (a) IN (...) / (a) LIKE 'x' /
1825
+ // (a) IS NULL / (a) BETWEEN 1 AND 5 / (a) NOT IN (...)。
1826
+ // 一律交给 parseComparison —— 它是完整实现,覆盖这些全部形式。
1827
+ const nx = this.peek();
1828
+ const isCmp = nx.type === 'op' &&
1829
+ ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>', '@>', '<@'].includes(nx.value);
1830
+ const isCondKw = nx.type === 'keyword' &&
1831
+ ['IN', 'LIKE', 'ILIKE', 'IS', 'BETWEEN', 'REGEXP', 'RLIKE', 'NOT'].includes(nx.value);
1832
+ if (isCmp || isCondKw) {
1833
+ return this.parseComparison(left);
1834
+ }
1835
+ return left;
1768
1836
  }
1769
1837
  // EXISTS (SELECT ...):子查询是否存在至少一行
1770
1838
  if (this.isKeyword('EXISTS')) {
@@ -1777,6 +1845,42 @@ class Parser {
1777
1845
  return this.parseComparison();
1778
1846
  }
1779
1847
 
1848
+ /**
1849
+ * 聚合(或任意已解析好的左操作数)之后若紧跟算术运算符,继续解析并返回
1850
+ * 组合后的 arith 节点;没有运算符则返回 null(调用方走原有的 aggregate 列路径)。
1851
+ * SELECT SUM(v)/COUNT(*) → arith('/', SUM, arith('/', COUNT))
1852
+ * 优先级与 parseTerm / parseAdditive 一致:先乘除后加减。
1853
+ */
1854
+ parseArithTail(left) {
1855
+ // 投影分支里的 aggregate 对象是 { type: 'SUM', column, distinct } ——
1856
+ // type 直接是聚合函数名,不是 'aggregate'。而执行层的 _replaceAggregates
1857
+ // 只认 type === 'aggregate',不规范化就会漏替换 → 整个表达式求值为 null。
1858
+ // 这里包一层 { type:'aggregate', fn, column, … } 供替换逻辑识别。
1859
+ const seed = left && left.type !== 'aggregate' && typeof left.type === 'string' &&
1860
+ /^[A-Z][A-Z0-9_]*$/.test(left.type)
1861
+ ? { type: 'aggregate', fn: left.type, column: left.column,
1862
+ distinct: left.distinct, separator: left.separator, orderBy: left.orderBy }
1863
+ : left;
1864
+
1865
+ let node = seed;
1866
+ let touched = false;
1867
+ for (;;) {
1868
+ const t = this.peek();
1869
+ if (t.type !== 'op' || !['*', '/', '%'].includes(t.value)) break;
1870
+ this.next();
1871
+ node = { type: 'arith', op: t.value, left: node, right: this.parseScalar() };
1872
+ touched = true;
1873
+ }
1874
+ for (;;) {
1875
+ const t = this.peek();
1876
+ if (t.type !== 'op' || !['+', '-'].includes(t.value)) break;
1877
+ this.next();
1878
+ node = { type: 'arith', op: t.value, left: node, right: this.parseScalar() };
1879
+ touched = true;
1880
+ }
1881
+ return touched ? node : null;
1882
+ }
1883
+
1780
1884
  /**
1781
1885
  * 解析聚合调用:fn ( [DISTINCT] expr [, expr*] [ORDER BY ...] [SEPARATOR 'x'] )
1782
1886
  * 以及 COUNT(*) 这类无参形式。
@@ -1838,7 +1942,27 @@ class Parser {
1838
1942
  // 否则会被当作逐行标量函数:既返回 null,又不会折叠分组
1839
1943
  if (AGG_FUNCS.has(String(t.value).toUpperCase()) &&
1840
1944
  this.peek().type === 'op' && this.peek().value === '(') {
1841
- return this.parseAggregateCall(String(t.value).toUpperCase());
1945
+ const agg = this.parseAggregateCall(String(t.value).toUpperCase());
1946
+ // 聚合结果也是一个完整的左操作数,后面可紧跟运算符:
1947
+ // SELECT SUM(sal)/COUNT(*) AS avg ← 算平均值的标准写法
1948
+ // SELECT SUM(sal) - MIN(sal) ← 极差
1949
+ // SELECT MAX(sal) * 2
1950
+ // 不处理的话运算符会留在流里 → "Unexpected token '/' after statement"。
1951
+ // (加括号写成 (SUM(sal))/2 之所以正常,是走了括号分组分支。)
1952
+ let left = agg;
1953
+ for (;;) {
1954
+ const nx = this.peek();
1955
+ if (nx.type !== 'op' || !['*', '/', '%'].includes(nx.value)) break;
1956
+ this.next();
1957
+ left = { type: 'arith', op: nx.value, left, right: this.parseOperand() };
1958
+ }
1959
+ for (;;) {
1960
+ const nx = this.peek();
1961
+ if (nx.type !== 'op' || !['+', '-'].includes(nx.value)) break;
1962
+ this.next();
1963
+ left = { type: 'arith', op: nx.value, left, right: this.parseOperand() };
1964
+ }
1965
+ return left;
1842
1966
  }
1843
1967
  // 支持 alias.column 引用
1844
1968
  if (this.peek().type === 'op' && this.peek().value === '.') {
@@ -2039,10 +2163,33 @@ class Parser {
2039
2163
  }
2040
2164
  // 括号分组:支持 (1+2)、2*(3+4) 等出现在算术/函数参数任意位置
2041
2165
  // 注意:parseOperand 开头已通过 const t = this.next() 消费了 '(', 此处不可再 next()
2166
+ //
2167
+ // 括号整体是一个完整的左操作数,后面若紧跟运算符必须继续参与运算,
2168
+ // 否则运算符会留在流里:
2169
+ // SELECT (1+2)*3 → Unexpected token '*' after statement
2170
+ // WHERE (a) = 1 → Expected comparison operator, got ')'
2042
2171
  if (t.type === 'op' && t.value === '(') {
2043
- const inner = this.parseArgExpr(); // 容忍裸操作数,允许纯算术分组
2172
+ let left = this.parseArgExpr(); // 容忍裸操作数,允许纯算术分组
2044
2173
  this.expect('op', ')');
2045
- return inner;
2174
+ // 先乘除后加减,与 parseTerm / parseAdditive 的优先级一致
2175
+ for (;;) {
2176
+ const nx = this.peek();
2177
+ if (nx.type !== 'op' || !['*', '/', '%'].includes(nx.value)) break;
2178
+ this.next();
2179
+ left = { type: 'arith', op: nx.value, left, right: this.parseArgExpr() };
2180
+ }
2181
+ for (;;) {
2182
+ const nx = this.peek();
2183
+ if (nx.type !== 'op' || !['+', '-'].includes(nx.value)) break;
2184
+ this.next();
2185
+ left = { type: 'arith', op: nx.value, left, right: this.parseArgExpr() };
2186
+ }
2187
+ // 尾随比较运算符交给 parseComparison(完整实现,覆盖 =/</>/LIKE/IN/IS…)
2188
+ const cx = this.peek();
2189
+ if (cx.type === 'op' && ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>'].includes(cx.value)) {
2190
+ return this.parseComparison(left);
2191
+ }
2192
+ return left;
2046
2193
  }
2047
2194
  if (t.type === 'op' && (t.value === '-' || t.value === '+')) {
2048
2195
  const num = this.next();
@@ -2190,7 +2337,15 @@ class Parser {
2190
2337
  const v = this.parseOperand();
2191
2338
  cond = { type: 'compare', op: '=', left: base, right: v };
2192
2339
  } else {
2193
- cond = this.parseComparison();
2340
+ // 条件可能是完整比较(WHEN a > 1),也可能是裸值/裸列(WHEN 1、WHEN flag)。
2341
+ // parseComparison 只接受比较式,裸值会抛 "Expected comparison operator, got 'THEN'"。
2342
+ // 先看清下一个 token 决定走哪条路,避免用 try/catch 回溯吞掉真实错误。
2343
+ const nx = this.peek();
2344
+ const isCmp = (nx.type === 'op' && ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>'].includes(nx.value)) ||
2345
+ (nx.type === 'keyword' && ['IN', 'LIKE', 'ILIKE', 'IS', 'BETWEEN', 'REGEXP', 'RLIKE', 'NOT'].includes(nx.value)) ||
2346
+ this.isWord('NOT');
2347
+ cond = isCmp ? this.parseComparison()
2348
+ : { type: 'isTruth', operand: this.parseArgExpr(), not: false, truth: true };
2194
2349
  }
2195
2350
  this.expectKeyword('THEN');
2196
2351
  const val = this.parseOperand();
@@ -2202,8 +2357,13 @@ class Parser {
2202
2357
  return { type: 'case', branches, elseVal };
2203
2358
  }
2204
2359
 
2205
- parseComparison() {
2206
- const left = this.parseBitwise();
2360
+ /**
2361
+ * 解析比较表达式。
2362
+ * preLeft:已解析好的左操作数(括号分组场景 —— parseNot 消费到 ')' 后传进来),
2363
+ * 省略时正常从 parseBitwise() 取左操作数。
2364
+ */
2365
+ parseComparison(preLeft) {
2366
+ const left = preLeft !== undefined ? preLeft : this.parseBitwise();
2207
2367
  const t = this.peek();
2208
2368
  let not = false;
2209
2369
  if (t.type === 'keyword' && t.value === 'NOT') {
@@ -2347,7 +2507,10 @@ const OPERATORS = {
2347
2507
  function applyCast(value, dataType) {
2348
2508
  const t = String(dataType || '').toUpperCase().replace(/\(.*\)$/, '').trim();
2349
2509
  if (value === null || value === undefined) return null;
2350
- if (t === 'INTEGER' || t === 'INT' || t === 'BIGINT' || t === 'SMALLINT' || t === 'TINYINT') {
2510
+ // SIGNED / UNSIGNED 是 MySQL 的整型别名(CAST(x AS SIGNED)),
2511
+ // 此前没列入整型分支,导致 CAST('12abc' AS SIGNED) 原样返回字符串。
2512
+ if (t === 'INTEGER' || t === 'INT' || t === 'BIGINT' || t === 'SMALLINT' ||
2513
+ t === 'TINYINT' || t === 'SIGNED' || t === 'UNSIGNED' || t === 'MEDIUMINT') {
2351
2514
  // MySQL CAST(... AS SIGNED/INT):取数字前缀('12abc'→12),无法解析时返回 0(而非 null)。
2352
2515
  if (typeof value === 'boolean') return value ? 1 : 0;
2353
2516
  if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : 0;
@@ -2374,6 +2537,23 @@ function rowHasPrefixedKeys(row) {
2374
2537
  return false;
2375
2538
  }
2376
2539
 
2540
+ /**
2541
+ * 字符串能否当作数值参与算术运算。
2542
+ * MySQL 里 '5' + 5 = 10、'5' + '5' = 10、'abc' + 1 = 1(非数字按 0)。
2543
+ * 纯数字(含小数/负号/科学计数)才返回数值,否则返回 null。
2544
+ */
2545
+ function numericIfNumeric(v) {
2546
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
2547
+ if (typeof v === 'boolean') return v ? 1 : 0;
2548
+ if (typeof v !== 'string') return null;
2549
+ const s = v.trim();
2550
+ if (s === '') return null;
2551
+ // 严格数字:可选符号 + 数字(含小数与指数),不接受 '12abc' 这类前缀形式
2552
+ if (!/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
2553
+ const n = Number(s);
2554
+ return Number.isFinite(n) ? n : null;
2555
+ }
2556
+
2377
2557
  function resolveOperand(operand, row, ctx) {
2378
2558
  if (operand === null || operand === undefined) return null;
2379
2559
  if (typeof operand === 'string') return resolveOperand({ type: 'column', name: operand }, row, ctx);
@@ -2423,11 +2603,28 @@ function resolveOperand(operand, row, ctx) {
2423
2603
  const r = resolveOperand(operand.right, row, ctx);
2424
2604
  if (l === null || r === null || l === undefined || r === undefined) return null;
2425
2605
  switch (operand.op) {
2426
- case '+': return l + r;
2427
- case '-': return l - r;
2428
- case '*': return l * r;
2429
- case '/': return r === 0 ? null : l / r;
2430
- case '%': return r === 0 ? null : l % r;
2606
+ // MySQL/SQLite 里 + 永远是算术加法,字符串拼接只走 || 或 CONCAT。
2607
+ // 直接用 JS 的 l + r 会让 '5' + 5 得到 '55'(静默返回字符串),
2608
+ // 数字与字符串混合的算术会一路错下去。
2609
+ // 判据:任一侧是 number,或字符串能被完整解析为数字 → 走数值加法。
2610
+ case '+': {
2611
+ // MySQL / SQLite 语义(已用 sqlite3 实测核对):
2612
+ // '5' + 5 = 10 '5' + '5' = 10 'abc' + 1 = 1 'a' + 'b' = 0
2613
+ // 任一侧是「数值」时做算术加法,另一侧非数值按 0;
2614
+ // 两侧都不是数值才是 0(JS 的 l + r 会把 '5' + 5 拼成 '55')。
2615
+ if (typeof l === 'number' || typeof r === 'number') {
2616
+ const ln2 = numericIfNumeric(l), rn2 = numericIfNumeric(r);
2617
+ return (ln2 === null ? 0 : ln2) + (rn2 === null ? 0 : rn2);
2618
+ }
2619
+ const ln = numericIfNumeric(l), rn = numericIfNumeric(r);
2620
+ if (ln !== null) return ln + (rn === null ? 0 : rn);
2621
+ if (rn !== null) return rn; // 左侧非数值按 0
2622
+ return 0;
2623
+ }
2624
+ case '-': return Number(l) - Number(r);
2625
+ case '*': return Number(l) * Number(r);
2626
+ case '/': return r === 0 ? null : Number(l) / Number(r);
2627
+ case '%': return r === 0 ? null : Number(l) % Number(r);
2431
2628
  // MySQL 整数除法 DIV(结果向零取整)
2432
2629
  case 'DIV': return r === 0 ? null : Math.trunc(Number(l) / Number(r));
2433
2630
  // 位运算:先按整数归一,与 MySQL 的整数位运算语义一致
@@ -2894,7 +3091,12 @@ function applyScalarFunction(fnNode, row, ctx) {
2894
3091
  const b = parseSqlDate(args[1]);
2895
3092
  if (!a || !b) return null;
2896
3093
  const ms = a.getTime() - b.getTime();
2897
- const months = (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth());
3094
+ // MySQL 的整月差:先算日历月数差,再按「结束日的日 < 起始日的日」退一档。
3095
+ // 只算日历月差会高估:
3096
+ // TIMESTAMPDIFF(MONTH,'2026-01-15','2026-03-14') = 1(差 1 个月零 29 天,不足 2 个月)
3097
+ // TIMESTAMPDIFF(MONTH,'2026-01-31','2026-03-01') = 1(1/31+1月=2/28,2/28+1月=3/28,够不到 3/1)
3098
+ let months = (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth());
3099
+ if (a.getDate() < b.getDate()) months--;
2898
3100
  switch (unit) {
2899
3101
  case 'MICROSECOND': return Math.floor(ms * 1000);
2900
3102
  case 'SECOND': return Math.floor(ms / 1000);
@@ -2904,7 +3106,7 @@ function applyScalarFunction(fnNode, row, ctx) {
2904
3106
  case 'WEEK': return Math.floor(ms / (86400000 * 7));
2905
3107
  case 'MONTH': return months;
2906
3108
  case 'QUARTER': return Math.floor(months / 3);
2907
- case 'YEAR': return a.getFullYear() - b.getFullYear();
3109
+ case 'YEAR': return Math.floor(months / 12);
2908
3110
  default: return null;
2909
3111
  }
2910
3112
  }
@@ -2995,6 +3197,30 @@ function applyScalarFunction(fnNode, row, ctx) {
2995
3197
  case 'LTRIM': return args[0] == null ? null : String(args[0]).replace(/^\s+/, '');
2996
3198
  case 'RTRIM': return args[0] == null ? null : String(args[0]).replace(/\s+$/, '');
2997
3199
  case 'ABS': return args[0] == null ? null : Math.abs(args[0]);
3200
+ case 'SIGN': { // MySQL:正数 1 / 0 → 0 / 负数 -1;NULL → NULL
3201
+ const v = numericIfNumeric(args[0]);
3202
+ if (v === null) return args[0] == null ? null : 0;
3203
+ return v > 0 ? 1 : (v < 0 ? -1 : 0);
3204
+ }
3205
+ case 'TRUNCATE': { // MySQL:直接截断,不四舍五入;NULL → NULL
3206
+ const x = numericIfNumeric(args[0]);
3207
+ if (x === null) return args[0] == null ? null : null;
3208
+ const digits = args[1] === undefined || args[1] === null ? 0 : Math.trunc(Number(args[1])) || 0;
3209
+ const factor = Math.pow(10, digits);
3210
+ if (!Number.isFinite(factor)) return x;
3211
+ return Math.trunc(x * factor) / factor;
3212
+ }
3213
+ case 'SUBSTRING_INDEX': { // MySQL:按分隔符切,返回左边 count 段(count<0 从右数)
3214
+ const s = args[0] == null ? null : String(args[0]);
3215
+ const d = args[1] == null ? null : String(args[1]);
3216
+ if (s === null || d === null || d === '') return null;
3217
+ const n = Math.trunc(Number(args[2]));
3218
+ if (!Number.isFinite(n)) return null;
3219
+ if (n === 0) return '';
3220
+ const parts = s.split(d);
3221
+ if (n > 0) return parts.slice(0, n).join(d);
3222
+ return parts.slice(n).join(d); // n<0:取末尾 |n| 段
3223
+ }
2998
3224
  case 'ROUND': {
2999
3225
  // MySQL 语义:四舍五入「半远离零」(Math.round 是半向 +∞,对负数会错,如 -3.5→-3)
3000
3226
  // 位数 D 可为负(向小数点左侧舍入);非数值/非法输入返回 null,绝不抛 TypeError/RangeError。
@@ -3930,7 +4156,11 @@ class SQLExecutor {
3930
4156
  for (const [col, val] of statement.assignments) {
3931
4157
  data[col] = typeof val === 'object' && val !== null && val.type ? resolveOperand(val, row, this.ctx) : val;
3932
4158
  }
3933
- this.engine.updateById(statement.table, id, data);
4159
+ // 必须 await:wasm / native client 的 updateById 是异步的,
4160
+ // 不等就会把「主键/唯一约束冲突」的异常变成未处理的 Promise 拒绝 ——
4161
+ // 表现为 UPDATE 静默成功、产生两行同主键,而调用方拿不到任何错误。
4162
+ // lib/database.js 的实现是同步的,await 同步值同样安全。
4163
+ await this.engine.updateById(statement.table, id, data);
3934
4164
  count++;
3935
4165
  }
3936
4166
  }
@@ -3974,7 +4204,10 @@ class SQLExecutor {
3974
4204
  }
3975
4205
  if (typeof statement.limit === 'number') candidates = candidates.slice(0, statement.limit);
3976
4206
  for (const row of candidates) {
3977
- const id = this._rowPkId(row, pkCols);
4207
+ // 无主键表没有稳定行标识:_rowPkId 会回退到 row.id(业务列名),
4208
+ // 而存储层的 _resolveId 把 id 当 1-based 行号 —— 两者语义不一致,
4209
+ // DELETE ... WHERE id IN (3,5) 会删错行。此时直接传行对象,存储层按引用删。
4210
+ const id = pkCols.length > 0 ? this._rowPkId(row, pkCols) : row;
3978
4211
  if (id !== undefined) { ids.push(id); removedRows.push(row); }
3979
4212
  }
3980
4213
  if (ids.length > 0) {
@@ -4259,9 +4492,48 @@ class SQLExecutor {
4259
4492
  return row ? row.id : undefined;
4260
4493
  }
4261
4494
 
4262
- async _readTable(table) {
4495
+ /**
4496
+ * 从 WHERE 表达式里提取「可直接下推给存储层」的等值条件。
4497
+ *
4498
+ * 此前 _readTable 一律 `find(table, {}, ...)` 拉全表回内存再逐行 evaluateExpr,
4499
+ * WHERE 完全没有下推 —— 2 万行表上 `WHERE id = ?` 也要扫 2 万行(约 88ms/次),
4500
+ * 而主键索引本可以做到微秒级。
4501
+ *
4502
+ * 只认最安全的一类:column = <字面量>(含 AND 连接)。
4503
+ * 其它条件(范围、OR、函数、IN、子查询)一律不下推,交回内存过滤,
4504
+ * 避免为了快而改错结果。
4505
+ */
4506
+ _pushdownEqualities(statement) {
4507
+ const out = {};
4508
+ if (!statement || !statement.where || typeof statement.where !== 'object') return out;
4509
+ const visit = (node) => {
4510
+ if (!node || typeof node !== 'object') return;
4511
+ if (node.type === 'and') { visit(node.left); visit(node.right); return; }
4512
+ if (node.type !== 'compare' || node.op !== '=') return;
4513
+ const isCol = n => n && n.type === 'column' && typeof n.name === 'string';
4514
+ const isVal = n => n && n.type === 'value' && n.value !== null && n.value !== undefined;
4515
+ let col = null, val = null;
4516
+ if (isCol(node.left) && isVal(node.right)) { col = node.left.name; val = node.right.value; }
4517
+ else if (isCol(node.right) && isVal(node.left)) { col = node.right.name; val = node.left.value; }
4518
+ if (col === null) return;
4519
+ const key = col.includes('.') ? col.slice(col.lastIndexOf('.') + 1) : col;
4520
+ // 同一列出现多个等值条件(如 id=1 AND id=2)时不推,交给内存求值
4521
+ if (Object.prototype.hasOwnProperty.call(out, key)) delete out[key];
4522
+ else out[key] = val;
4523
+ };
4524
+ visit(statement.where);
4525
+ return out;
4526
+ }
4527
+
4528
+ async _readTable(table, statement) {
4263
4529
  const schema = await this._getSchema(table);
4264
- const rows = (await this.engine.find(table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
4530
+ // 有可下推的等值条件时交给存储层走索引;没有则维持原来的全表读取
4531
+ const filter = statement ? this._pushdownEqualities(statement) : {};
4532
+ const useIndex = Object.keys(filter).length > 0 &&
4533
+ (typeof this.engine.exists === 'function' || typeof this.engine.find === 'function');
4534
+ const rows = useIndex
4535
+ ? (await this.engine.find(table, filter, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema))
4536
+ : (await this.engine.find(table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
4265
4537
  return { schema, rows };
4266
4538
  }
4267
4539
 
@@ -4622,6 +4894,17 @@ class SQLExecutor {
4622
4894
  delete node.column;
4623
4895
  return;
4624
4896
  }
4897
+ // 投影分支构造的聚合节点形如 { type: 'SUM', column, distinct }(type 就是函数名),
4898
+ // 与 parseAggregateCall 产出的 { type:'aggregate', fn } 并存。
4899
+ // 两种都要替换,否则 SUM(v)/COUNT(*) 整式求值为 null。
4900
+ if (typeof node.type === 'string' && AGG_FUNCS.has(node.type) &&
4901
+ (node.column !== undefined || node.over === undefined)) {
4902
+ const fn = node.type;
4903
+ node.type = 'value';
4904
+ node.value = this._aggValue(group, fn, node.column, node.distinct, node.separator, node.orderBy);
4905
+ delete node.column;
4906
+ return;
4907
+ }
4625
4908
  for (const k of Object.keys(node)) {
4626
4909
  if (node[k] && typeof node[k] === 'object') this._replaceAggregates(node[k], group);
4627
4910
  }
@@ -5133,7 +5416,7 @@ class SQLExecutor {
5133
5416
  const res = await this.executeSelect(firstItem.subquery);
5134
5417
  rowsAll = { rows: this._subQueryRows(res), schema: null, columns: res.columns };
5135
5418
  } else {
5136
- rowsAll = await this._readTable(firstItem.table);
5419
+ rowsAll = await this._readTable(firstItem.table, statement);
5137
5420
  }
5138
5421
  schema = rowsAll.schema;
5139
5422
  const firstPrefix = firstItem.alias || firstItem.table;
@@ -5142,7 +5425,7 @@ class SQLExecutor {
5142
5425
  for (const j of statement.from.joins) {
5143
5426
  const rightRes = j.item.subquery
5144
5427
  ? await this.executeSelect(j.item.subquery)
5145
- : await this._readTable(j.item.table);
5428
+ : await this._readTable(j.item.table, statement);
5146
5429
  const rightPrefix = j.item.alias || j.item.table;
5147
5430
  const rightRows = (j.item.subquery ? this._subQueryRows(rightRes) : rightRes.rows).map(r => this._prefixRow(r, rightPrefix));
5148
5431
 
@@ -664,6 +664,12 @@ class JSQL {
664
664
  await this._flush();
665
665
  const seq = this._seqIdByPk(table, id);
666
666
  if (seq === null) return null;
667
+ // 改主键 / UNIQUE 列前先查冲突。
668
+ // 此前直接调 jsql_update_by_id,导致
669
+ // UPDATE t SET id = 1 WHERE id = 2 (t 已有 id=1)
670
+ // 静默产生两行同主键 —— 主键索引只留一个,索引与实际行数组不一致。
671
+ // MySQL / SQLite 都会报 UNIQUE constraint failed。
672
+ await this._assertNoKeyConflict(table, id, data, seq);
667
673
  const r = safeJsonParse(wasmBindings.jsql_update_by_id(table, BigInt(seq), JSON.stringify(data)));
668
674
  if (r && r.ok === false) throw new Error(r.error || 'update failed');
669
675
  this._emit('update', { table, id, data });
@@ -671,6 +677,35 @@ class JSQL {
671
677
  return r;
672
678
  }
673
679
 
680
+ /**
681
+ * 校验 UPDATE 后的主键 / UNIQUE 值不与其它行冲突。
682
+ * 逐行比对(未建索引也正确);值未变化时跳过。
683
+ */
684
+ async _assertNoKeyConflict(table, id, data, selfSeq) {
685
+ const schema = this._schemas[table];
686
+ if (!schema) return;
687
+ const fields = Object.keys(schema).filter(f => schema[f] && (schema[f].primaryKey || schema[f].unique));
688
+ if (!fields.length) return;
689
+ let rows;
690
+ try {
691
+ rows = await this.find(table, {}, { limit: 1e9, offset: 0 });
692
+ } catch (_) {
693
+ return; // 读不到就不拦,交给底层报错
694
+ }
695
+ for (const f of fields) {
696
+ if (!Object.prototype.hasOwnProperty.call(data, f)) continue;
697
+ const nv = data[f];
698
+ const self = rows.find(r => r && String(r.id) === String(id));
699
+ if (self && self[f] === nv) continue; // 值没变
700
+ if (rows.some(r => r && r !== self && r[f] === nv)) {
701
+ const e = new Error(`Duplicate entry '${nv}' for key '${f}'`);
702
+ e.code = 'ER_DUP_ENTRY';
703
+ e.errno = 1062;
704
+ throw e;
705
+ }
706
+ }
707
+ }
708
+
674
709
  async removeById(table, id) {
675
710
  if (!this._runHooks('beforeDelete', [table, id])) return;
676
711
  await this._flush();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.3.4",
3
+ "version": "6.3.6",
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,203 @@
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, JSQL, NativeJSQL } = 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
+ // 三个 engine 都要拦住 —— 修复曾只加在 lib/database.js,
134
+ // 而 JSQL(wasm)走 lib/wasm_client.js、NativeJSQL 走 lib/native_client.js,
135
+ // 各自的 updateById 是独立实现。
136
+ console.log('\n--- UPDATE 冲突校验:三个 engine 都要拦住 ---');
137
+ const engines = [
138
+ ['JSQL(wasm)', () => new JSQL({ mode: 'memory' })],
139
+ ['NativeJSQL', () => new NativeJSQL({ mode: 'memory' })],
140
+ ['Database', () => new Database(':memory:')],
141
+ ];
142
+ for (const [name, mk] of engines) {
143
+ await t(`${name} UPDATE 主键冲突 → 报错且数据完好`, async () => {
144
+ const db = await mk();
145
+ if (db.start) await db.start();
146
+ try {
147
+ const exec = (sql) => executeSQL(db, sql, OPTS);
148
+ await exec(`CREATE TABLE e (id INT PRIMARY KEY, v INT)`);
149
+ await exec(`INSERT INTO e VALUES (1,10),(2,20),(3,30)`);
150
+ let threw = false;
151
+ try { await exec(`UPDATE e SET id = 1 WHERE id = 2`); } catch (_) { threw = true; }
152
+ const after = (await exec(`SELECT id, v FROM e ORDER BY id`)).rows;
153
+ // 改不冲突的值必须仍然可用
154
+ await exec(`UPDATE e SET id = 9 WHERE id = 2`);
155
+ const ok = (await exec(`SELECT id FROM e ORDER BY id`)).rows;
156
+ return threw && eq(after, [[1, 10], [2, 20], [3, 30]]) && eq(ok, [[1], [3], [9]]);
157
+ } finally { try { await db.stop(); } catch (_) { /* ignore */ } }
158
+ });
159
+ await t(`${name} UPDATE UNIQUE 冲突 → 报错`, async () => {
160
+ const db = await mk();
161
+ if (db.start) await db.start();
162
+ try {
163
+ const exec = (sql) => executeSQL(db, sql, OPTS);
164
+ await exec(`CREATE TABLE q (id INT PRIMARY KEY, e TEXT UNIQUE)`);
165
+ await exec(`INSERT INTO q VALUES (1,'a'),(2,'b')`);
166
+ let threw = false;
167
+ try { await exec(`UPDATE q SET e = 'a' WHERE id = 2`); } catch (_) { threw = true; }
168
+ return threw && eq((await exec(`SELECT e FROM q ORDER BY id`)).rows, [['a'], ['b']]);
169
+ } finally { try { await db.stop(); } catch (_) { /* ignore */ } }
170
+ });
171
+ }
172
+
173
+ console.log('\n--- 既有约束行为回归对照 ---');
174
+ await t('INSERT 主键重复 → 报错', async ({ exec, mustFail }) => {
175
+ await exec(`CREATE TABLE t (id INT PRIMARY KEY)`);
176
+ await exec(`INSERT INTO t VALUES (1)`);
177
+ return mustFail(`INSERT INTO t VALUES (1)`);
178
+ });
179
+ await t('INSERT NOT NULL 违反 → 报错', async ({ exec, mustFail }) => {
180
+ await exec(`CREATE TABLE t (id INT, v TEXT NOT NULL)`);
181
+ return mustFail(`INSERT INTO t VALUES (1,NULL)`);
182
+ });
183
+ await t('INSERT UNIQUE 重复 → 报错', async ({ exec, mustFail }) => {
184
+ await exec(`CREATE TABLE t (id INT, e TEXT UNIQUE)`);
185
+ await exec(`INSERT INTO t VALUES (1,'a')`);
186
+ return mustFail(`INSERT INTO t VALUES (2,'a')`);
187
+ });
188
+ await t('UNIQUE 允许多个 NULL', async ({ exec, scalar }) => {
189
+ await exec(`CREATE TABLE t (id INT, e TEXT UNIQUE)`);
190
+ await exec(`INSERT INTO t VALUES (1,NULL),(2,NULL)`);
191
+ return eq(await scalar(`SELECT COUNT(*) AS c FROM t`), 2);
192
+ });
193
+ await t('删除后主键可复用', async ({ exec, scalar }) => {
194
+ await exec(`CREATE TABLE t (id INT PRIMARY KEY, v TEXT)`);
195
+ await exec(`INSERT INTO t VALUES (1,'a')`);
196
+ await exec(`DELETE FROM t WHERE id = 1`);
197
+ await exec(`INSERT INTO t VALUES (1,'c')`);
198
+ return eq(await scalar(`SELECT v FROM t WHERE id = 1`), 'c');
199
+ });
200
+
201
+ console.log(`\n${pass} passed, ${fail} failed`);
202
+ process.exit(fail === 0 ? 0 : 1);
203
+ })();
@@ -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
- ok('JS 层已启用多实例', A._multiInstance === true);
104
- ok('两实例句柄不同', A._handle !== B._handle, { a: A._handle, b: B._handle });
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
- ok('旧版二进制下回退单实例(_handle 为 null)', A._handle === null);
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
- if (hasHandles) ok('stop 释放句柄', A._handle === null && B._handle === null);
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
  }
@@ -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
  })();