jsql-neo 6.5.20 → 6.5.22

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/lib/sql.js CHANGED
@@ -484,6 +484,7 @@ class Parser {
484
484
  this.paramCount = 0; // 顺序占位符 ? 的序号(从 0 开始)
485
485
  this.subquerySeq = 0; // FROM 子查询无别名时自动生成 __subN
486
486
  this.depth = 0; // 子查询递归深度
487
+ this.barePredicateDepth = 0; // >0 时允许裸布尔谓词(WHERE 1 / WHERE col / WHERE NULL)
487
488
  }
488
489
 
489
490
  peek(offset = 0) { return this.tokens[this.pos + offset]; }
@@ -717,6 +718,8 @@ class Parser {
717
718
  this.expect('op', '(');
718
719
 
719
720
  const schema = {};
721
+ // 表级 FOREIGN KEY 可能写在列定义之前,先收集、待所有列解析完再绑定
722
+ const pendingFKs = [];
720
723
  let hasPk = false;
721
724
  while (true) {
722
725
  const t = this.peek();
@@ -753,14 +756,14 @@ class Parser {
753
756
  } else if (t.type === 'keyword' && t.value === 'CONSTRAINT') {
754
757
  this.expectKeyword('CONSTRAINT');
755
758
  if (this.peek().type === 'ident') this.next(); // 约束名
756
- this._parseTableConstraint(schema);
759
+ this._parseTableConstraint(schema, pendingFKs);
757
760
  } else if (this.isWord('FOREIGN') || this.isWord('CHECK') ||
758
761
  t.type === 'keyword' && (t.value === 'KEY' || t.value === 'INDEX')) {
759
762
  // 表级约束:FOREIGN KEY ... REFERENCES ... / CHECK (...) / KEY name (cols)
760
763
  // KEY/INDEX 是保留字:裸写作列名(如 CREATE TABLE t (key TEXT))必须报错,
761
764
  // 由 _parseTableConstraint 消费 KEY 后遇到类型 token 触发
762
765
  // "Expected ',' or ')'",见 identifier-keywords 测试。
763
- this._parseTableConstraint(schema);
766
+ this._parseTableConstraint(schema, pendingFKs);
764
767
  } else if (t.type === 'eof' || (t.type === 'op' && t.value === ')')) {
765
768
  break;
766
769
  } else {
@@ -791,6 +794,13 @@ class Parser {
791
794
  if (!hasPk && schema.id === undefined) {
792
795
  schema.id = { type: 'integer', primaryKey: true, autoIncrement: true };
793
796
  }
797
+ // 表级外键落到对应列的 def.foreignKey 上,由 lib/table.js 统一注册/校验
798
+ for (const { column, fk } of pendingFKs) {
799
+ if (!schema[column]) {
800
+ throw new Error(`Unknown column '${column}' in foreign key definition of table '${name}'`);
801
+ }
802
+ schema[column].foreignKey = fk;
803
+ }
794
804
  return { type: 'createTable', name, schema, ifNotExists };
795
805
  }
796
806
 
@@ -853,6 +863,25 @@ class Parser {
853
863
  if (this.peek().type === 'op' && this.peek().value === '(') this._skipParens();
854
864
  continue;
855
865
  }
866
+ // 列级外键: pid INTEGER REFERENCES p(id) [ON DELETE ...] [ON UPDATE ...]
867
+ // 此前 REFERENCES 没被消费,直接留在流里 → "Expected ',' or ')'"。
868
+ if (this.isWord('REFERENCES')) {
869
+ this.next();
870
+ const refTable = this.parseTableName();
871
+ const refCols = this._parseParenNameList();
872
+ let onDelete = 'restrict';
873
+ let onUpdate = 'restrict';
874
+ while (this.isKeyword('ON')) {
875
+ this.next();
876
+ const which = String(this.next().value).toUpperCase();
877
+ const action = this._parseFKAction();
878
+ if (which === 'DELETE') onDelete = action;
879
+ else if (which === 'UPDATE') onUpdate = action;
880
+ else throw new Error(`Expected DELETE or UPDATE after ON, got '${which}'`);
881
+ }
882
+ def.foreignKey = { table: refTable, field: refCols[0] || 'id', onDelete, onUpdate };
883
+ continue;
884
+ }
856
885
  if (t.type === 'keyword') {
857
886
  switch (t.value) {
858
887
  case 'PRIMARY':
@@ -946,27 +975,75 @@ class Parser {
946
975
  }
947
976
  }
948
977
 
978
+ /** 解析括号内的名字列表: (a, b);无括号时返回空数组。 */
979
+ _parseParenNameList() {
980
+ const names = [];
981
+ if (!(this.peek().type === 'op' && this.peek().value === '(')) return names;
982
+ this.next();
983
+ while (true) {
984
+ names.push(this.parseTableName());
985
+ if (this.peek().type === 'op' && this.peek().value === ',') { this.next(); continue; }
986
+ break;
987
+ }
988
+ this.expect('op', ')');
989
+ return names;
990
+ }
991
+
992
+ /** 解析外键动作: CASCADE / RESTRICT / SET NULL / NO ACTION */
993
+ _parseFKAction() {
994
+ const first = String(this.next().value).toUpperCase();
995
+ if (first === 'CASCADE') return 'cascade';
996
+ if (first === 'RESTRICT') return 'restrict';
997
+ if (first === 'SET') {
998
+ const second = String(this.next().value).toUpperCase();
999
+ if (second === 'NULL') return 'set null';
1000
+ throw new Error(`Unsupported foreign key action: 'SET ${second}' (only SET NULL is supported)`);
1001
+ }
1002
+ if (first === 'NO') {
1003
+ if (this.isWord('ACTION')) this.next();
1004
+ return 'no action';
1005
+ }
1006
+ throw new Error(`Unsupported foreign key action: '${first}'`);
1007
+ }
1008
+
949
1009
  /**
950
1010
  * 解析表级约束(FOREIGN KEY / CHECK / KEY / INDEX / PRIMARY KEY / UNIQUE)。
951
- * 目前只做语法层面消费并在可能时补充列属性,不做外键实际校验。
1011
+ * FOREIGN KEY 会真正解析出 field/refTable/refField/onDelete/onUpdate 并登记,
1012
+ * 与列级 REFERENCES 行为一致(校验在 lib/table.js)。
952
1013
  */
953
- _parseTableConstraint(schema) {
1014
+ _parseTableConstraint(schema, pendingFKs) {
954
1015
  if (this.isWord('FOREIGN')) {
955
1016
  this.next();
956
1017
  if (!this.isKeyword('KEY')) throw new Error(`Expected KEY after FOREIGN, got '${this.peek().value}'`);
957
1018
  this.next();
958
- if (this.peek().type === 'op' && this.peek().value === '(') this._skipParens();
959
- if (this.isWord('REFERENCES')) {
960
- this.next();
961
- this.parseTableName();
962
- if (this.peek().type === 'op' && this.peek().value === '(') this._skipParens();
963
- }
964
- // ON DELETE / ON UPDATE <action>
1019
+ const cols = this._parseParenNameList();
1020
+ if (!cols.length) throw new Error('Expected column list in FOREIGN KEY constraint');
1021
+ if (!this.isWord('REFERENCES')) throw new Error(`Expected REFERENCES in FOREIGN KEY constraint, got '${this.peek().value}'`);
1022
+ this.next();
1023
+ const refTable = this.parseTableName();
1024
+ const refCols = this._parseParenNameList();
1025
+ let onDelete = 'restrict';
1026
+ let onUpdate = 'restrict';
965
1027
  while (this.isKeyword('ON')) {
966
1028
  this.next();
967
- this.next(); // DELETE | UPDATE
968
- const act = String(this.next().value).toUpperCase();
969
- if (act === 'SET' || act === 'NO') this.next(); // SET NULL / NO ACTION
1029
+ const which = String(this.next().value).toUpperCase();
1030
+ const action = this._parseFKAction();
1031
+ if (which === 'DELETE') onDelete = action;
1032
+ else if (which === 'UPDATE') onUpdate = action;
1033
+ else throw new Error(`Expected DELETE or UPDATE after ON, got '${which}'`);
1034
+ }
1035
+ // 复合外键无法用现有的单列校验表示;显式报错好过静默丢弃
1036
+ if (cols.length > 1) {
1037
+ throw new Error('Composite foreign key constraints are not supported (FOREIGN KEY with multiple columns)');
1038
+ }
1039
+ const fk = { table: refTable, field: refCols[0] || 'id', onDelete, onUpdate };
1040
+ if (pendingFKs) {
1041
+ // 列可能还没解析到,推迟到 parseCreateTable 末尾绑定
1042
+ pendingFKs.push({ column: cols[0], fk });
1043
+ } else if (schema[cols[0]]) {
1044
+ schema[cols[0]].foreignKey = fk;
1045
+ } else {
1046
+ throw new Error(`Unknown column '${cols[0]}' in foreign key definition`);
970
1047
  }
971
1048
  return;
972
1049
  }
@@ -1436,7 +1513,7 @@ class Parser {
1436
1513
  from = this.parseFrom();
1437
1514
  }
1438
1515
  let where = null;
1439
- if (this.isKeyword('WHERE')) { this.next(); where = this.parseExpr(); }
1516
+ if (this.isKeyword('WHERE')) { this.next(); where = this.parseBooleanExpr(); }
1440
1517
  let groupBy = null;
1441
1518
  let rollup = false;
1442
1519
  if (this.isKeyword('GROUP')) {
@@ -1455,7 +1532,7 @@ class Parser {
1455
1532
  }
1456
1533
  }
1457
1534
  let having = null;
1458
- if (this.isKeyword('HAVING')) { this.next(); having = this.parseExpr(); }
1535
+ if (this.isKeyword('HAVING')) { this.next(); having = this.parseBooleanExpr(); }
1459
1536
  let orderBy = null;
1460
1537
  if (this.isKeyword('ORDER')) {
1461
1538
  this.expectKeyword('ORDER'); this.expectKeyword('BY');
@@ -1590,7 +1667,7 @@ class Parser {
1590
1667
  }
1591
1668
  const item = this.parseFromItem();
1592
1669
  let on = null;
1593
- if (this.isKeyword('ON')) { this.next(); on = this.parseExpr(); }
1670
+ if (this.isKeyword('ON')) { this.next(); on = this.parseBooleanExpr(); }
1594
1671
  joins.push({ type, item, on });
1595
1672
  }
1596
1673
  return { tables, joins };
@@ -1661,7 +1738,7 @@ class Parser {
1661
1738
  break;
1662
1739
  }
1663
1740
  let where = null;
1664
- if (this.isKeyword('WHERE')) { this.next(); where = this.parseExpr(); }
1741
+ if (this.isKeyword('WHERE')) { this.next(); where = this.parseBooleanExpr(); }
1665
1742
  else console.warn(`⚠️ UPDATE without WHERE clause — this will modify ALL rows in '${table}'`);
1666
1743
  // UPDATE ... ORDER BY ... LIMIT n(与 DELETE 对称;按排序后的前 n 行更新)
1667
1744
  let orderBy = null, limit = null;
@@ -1694,7 +1771,7 @@ class Parser {
1694
1771
  this.expectKeyword('FROM');
1695
1772
  const table = this.parseTableName();
1696
1773
  let where = null;
1697
- if (this.isKeyword('WHERE')) { this.next(); where = this.parseExpr(); }
1774
+ if (this.isKeyword('WHERE')) { this.next(); where = this.parseBooleanExpr(); }
1698
1775
  let orderBy = null, limit = null;
1699
1776
  if (this.isKeyword('ORDER')) {
1700
1777
  this.next(); this.expectKeyword('BY');
@@ -2039,6 +2116,21 @@ class Parser {
2039
2116
  return this.parseOr();
2040
2117
  }
2041
2118
 
2119
+ /**
2120
+ * 布尔上下文表达式(WHERE / HAVING / ON 条件)。
2121
+ * 与 parseExpr 相同,但允许「裸布尔谓词」:
2122
+ * WHERE 1 / WHERE col / WHERE NULL / WHERE NULL AND b = 1
2123
+ * 此时 parseComparison 在找不到比较运算符时把左侧表达式直接作为布尔值返回。
2124
+ */
2125
+ parseBooleanExpr() {
2126
+ this.barePredicateDepth = (this.barePredicateDepth || 0) + 1;
2127
+ try {
2128
+ return this.parseExpr();
2129
+ } finally {
2130
+ this.barePredicateDepth--;
2131
+ }
2132
+ }
2133
+
2042
2134
  /**
2043
2135
  * 函数实参用的表达式:与 parseExpr 相同,但叶子允许是"裸操作数"。
2044
2136
  * 例如 IF(1 > 0, 'y', 'n') 的第一个参数是完整比较表达式,
@@ -2897,6 +2989,11 @@ class Parser {
2897
2989
  // 函数实参场景允许裸操作数(IF(age, ...) / SUM(IF(...)) 等)
2898
2990
  if (this.bareExprDepth > 0) return left;
2899
2991
 
2992
+ // 布尔上下文(WHERE / HAVING / ON)允许裸布尔谓词(#21):
2993
+ // WHERE 1 / WHERE col / WHERE NULL / WHERE 'abc' / WHERE NULL AND b = 1
2994
+ // 左侧表达式直接作为布尔值返回,由求值层按 MySQL 三值逻辑判真。
2995
+ if (this.barePredicateDepth > 0) return left;
2996
+
2900
2997
  throw new Error(`Expected comparison operator, got '${t2.value}'`);
2901
2998
  }
2902
2999
  }
@@ -3006,6 +3103,24 @@ function numericIfNumeric(v) {
3006
3103
  return Number.isFinite(n) ? n : null;
3007
3104
  }
3008
3105
 
3106
+ /**
3107
+ * MySQL 数字上下文里的字符串→数字强转:数字与字符串比较时,
3108
+ * 字符串按数值前缀解析,非数字/空串一律按 0(MySQL 语义)。
3109
+ * '' → 0、'a' → 0、'5' → 5、'5abc' → 5、'2024-01-01' → 2024
3110
+ * 与 strict 的 numericIfNumeric 不同:它拒绝非纯数字,这里宽进。
3111
+ */
3112
+ function mysqlNum(v) {
3113
+ if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
3114
+ if (typeof v === 'boolean') return v ? 1 : 0;
3115
+ if (typeof v === 'string') {
3116
+ const m = v.trim().match(/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/);
3117
+ if (!m) return 0;
3118
+ const n = Number(m[0]);
3119
+ return Number.isFinite(n) ? n : 0;
3120
+ }
3121
+ return 0;
3122
+ }
3123
+
3009
3124
  function resolveOperand(operand, row, ctx) {
3010
3125
  if (operand === null || operand === undefined) return null;
3011
3126
  if (typeof operand === 'string') return resolveOperand({ type: 'column', name: operand }, row, ctx);
@@ -4525,6 +4640,37 @@ function normalizeCmpValue(a, other) {
4525
4640
  return otherIsNumeric ? (a ? 1 : 0) : a;
4526
4641
  }
4527
4642
 
4643
+ // MySQL 数值强转(#26 / #39):数字原样返回,布尔按 1/0,字符串按 MySQL 规则
4644
+ // 转换(空串和非数字字符串都算 0)。数字与字符串混合比较时必须走这里,
4645
+ // 否则 '10' >= '5' 会退化成字典序比较而得到错误结果。
4646
+ function mysqlNumeric(v) {
4647
+ if (typeof v === 'number') return v;
4648
+ if (typeof v === 'boolean') return v ? 1 : 0;
4649
+ if (typeof v === 'string') {
4650
+ const t = v.trim();
4651
+ if (t === '') return 0;
4652
+ const n = Number(t);
4653
+ return Number.isFinite(n) ? n : 0;
4654
+ }
4655
+ return NaN;
4656
+ }
4657
+
4658
+ /** 严格数值解析:只有真正能解析成有限数值时才返回 number,否则 null。
4659
+ * 用来判断字符串边界是否“看起来像数字”('9' / '0.01' / ' 100 ' 是,'abc' 不是)。
4660
+ * 与 mysqlNumeric 的区别:非数字字符串返回 null 而不是 0 —— 判断“要不要按数值比较”
4661
+ * 时不能用 0 冒充,否则 'abc' 会被当成 0。 */
4662
+ function strictNumeric(v) {
4663
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
4664
+ if (typeof v === 'boolean') return v ? 1 : 0;
4665
+ if (typeof v === 'string') {
4666
+ const t = v.trim();
4667
+ if (t === '') return null;
4668
+ const n = Number(t);
4669
+ return Number.isFinite(n) ? n : null;
4670
+ }
4671
+ return null;
4672
+ }
4673
+
4528
4674
  // MySQL 默认 collation(utf8mb4_general_ci)下字符串比较不区分大小写,
4529
4675
  // 与 LIKE 保持一致。cs=true 表示显式 BINARY/COLLATE 强制大小写敏感。
4530
4676
  function ciEq(a, b, cs) {
@@ -4596,16 +4742,23 @@ function evaluateExpr(expr, row, ctx) {
4596
4742
  // 布尔与数值互通(MySQL: 1 = TRUE、0 = FALSE)
4597
4743
  const nl = normalizeCmpValue(l, r);
4598
4744
  const nr = normalizeCmpValue(r, l);
4745
+ // MySQL 类型强转:数字与字符串比较时,字符串按数字上下文转换。
4746
+ // 覆盖 0 = '' → 0 = 0 → 1、'a' = 0 → 0 = 0 → 1、'5' = 5 → 5 = 5 → 1(#26)。
4747
+ // 此前只对「数字 vs 纯数字字符串」生效,'' / 'a' 这类非纯数字字符串
4748
+ // 会退化成字符串比较而错判为不等。
4599
4749
  const isNum = (v) => typeof v === 'number';
4600
- const isNumStr = (v) => typeof v === 'string' && String(v).trim() !== '' && Number.isFinite(Number(v));
4601
- // 数值 vs 数值字符串(age < '10'、age = '09')必须按数值比,
4602
- // 否则退化成字典序:'9' < '10' 为 false、'9' = '09' 为 false
4603
- const mixedNum = (isNum(nl) && isNumStr(nr)) || (isNumStr(nl) && isNum(nr));
4604
- if (expr.op === '=') {
4605
- if (mixedNum) return Number(nl) === Number(nr);
4606
- return ciEq(nl, nr, cs);
4607
- }
4608
- if (mixedNum) { const fn = OPERATORS[expr.op]; return fn(Number(nl), Number(nr)); }
4750
+ // MySQL 类型强转(#26):数字 vs 字符串统一走全局 mysqlNum
4751
+ // (按数值前缀解析,非数字/空串视为 0,对齐 MySQL 宽松转换)。
4752
+ // 覆盖 '5' = 5、0 = ''、'a' = 0、'09' = 9 等,避免退化成字典序比较。
4753
+ const isStr = (v) => typeof v === 'string';
4754
+ const numVsStr = (isNum(nl) && isStr(nr)) || (isStr(nl) && isNum(nr));
4755
+ if (numVsStr) {
4756
+ const an = mysqlNum(nl), bn = mysqlNum(nr);
4757
+ if (expr.op === '=') return an === bn;
4758
+ const fn = OPERATORS[expr.op];
4759
+ return fn(an, bn);
4760
+ }
4761
+ if (expr.op === '=') return ciEq(nl, nr, cs);
4609
4762
  if (typeof nl === 'number' && typeof nr === 'number') { const fn = OPERATORS[expr.op]; return fn(nl, nr); }
4610
4763
  const c = ciCompare(nl, nr, cs);
4611
4764
  switch (expr.op) {
@@ -4689,21 +4842,17 @@ function evaluateExpr(expr, row, ctx) {
4689
4842
  // 10 BETWEEN '5' AND '9' 得 0(应为 1)。
4690
4843
  // 两边都是字符串时保持字符串比较(日期 '2024-01-02' 这类不能丢字典序)。
4691
4844
  let inRange;
4692
- const asNum = (x) => {
4693
- if (typeof x === 'number') return Number.isFinite(x) ? x : null;
4694
- if (typeof x === 'boolean') return x ? 1 : 0;
4695
- if (typeof x === 'string') {
4696
- const s = x.trim();
4697
- if (s === '') return null;
4698
- const num = Number(s);
4699
- return Number.isFinite(num) ? num : null;
4700
- }
4701
- return null;
4702
- };
4703
- const vn = typeof v === 'number' ? asNum(v) : null;
4704
- const ln = asNum(lo), hn = asNum(hi);
4705
- if (vn !== null && ln !== null && hn !== null) {
4706
- inRange = vn >= ln && vn <= hn;
4845
+ const vn = strictNumeric(v), ln = strictNumeric(lo), hn = strictNumeric(hi);
4846
+ const vIsNum = typeof v === 'number' || typeof v === 'boolean';
4847
+ const boundIsNum = typeof lo === 'number' || typeof lo === 'boolean' ||
4848
+ typeof hi === 'number' || typeof hi === 'boolean';
4849
+ // 数值比较的判定与 case 'compare' 的 mixedNum 保持一致:
4850
+ // 1) 列值本身就是数值(或布尔),且两个边界都能解析为数值;
4851
+ // 2) 列值是可解析为数值的字符串,而任一边界本身就是数值(混合比较)。
4852
+ // 其余情况(纯字符串列 + 纯字符串边界,如日期 '2024-01-02')保持字典序。
4853
+ const numericRange = ln !== null && hn !== null && (vIsNum || (boundIsNum && vn !== null));
4854
+ if (numericRange) {
4855
+ inRange = vn !== null && vn >= ln && vn <= hn;
4707
4856
  } else {
4708
4857
  inRange = String(v) >= String(lo) && String(v) <= String(hi);
4709
4858
  }
@@ -4753,7 +4902,14 @@ function evaluateExpr(expr, row, ctx) {
4753
4902
  default: return null;
4754
4903
  }
4755
4904
  }
4756
- default: return false;
4905
+ default: {
4906
+ // 裸布尔谓词(WHERE 1 / WHERE col / WHERE NULL / WHERE 'abc',#21):
4907
+ // 解析层把左侧表达式直接作为布尔条件返回,这里按 MySQL 真值规则
4908
+ // 归一化为 true / false / UNKNOWN(null):NULL → null;0/''/false → false;
4909
+ // 其余(含非空字符串)→ true。此前一律返回 false 导致谓词被静默算错。
4910
+ const v = resolveOperand(expr, row, ctx);
4911
+ return sqlTruthy(v);
4912
+ }
4757
4913
  }
4758
4914
  }
4759
4915
 
@@ -5047,9 +5203,8 @@ class SQLExecutor {
5047
5203
  }
5048
5204
  case 'addForeign':
5049
5205
  case 'dropForeign':
5050
- // 外键在这个引擎里没有实现(连 CREATE TABLE 的表级 FOREIGN KEY
5051
- // 都只是解析掉、不记录、不强制)。与其返回成功让人以为约束生效,
5052
- // 不如明确报错。要支持得先做引用完整性检查。
5206
+ // ALTER TABLE ... ADD/DROP FOREIGN KEY 仍未实现(CREATE TABLE 的表级/列级
5207
+ // FOREIGN KEY 已真正生效)。与其返回成功让人以为约束变更了,不如明确报错。
5053
5208
  throw new Error(
5054
5209
  `ALTER TABLE ... ${op.op === 'addForeign' ? 'ADD FOREIGN KEY' : 'DROP FOREIGN KEY'} is not supported: ` +
5055
5210
  `this engine does not implement foreign key constraints.`
package/lib/table.js CHANGED
@@ -450,7 +450,7 @@ class Table {
450
450
  // 更新
451
451
  // ============================================================
452
452
 
453
- update(query, updates) {
453
+ update(query, updates, opts = {}) {
454
454
  for (const hook of this._hooks.beforeUpdate) {
455
455
  updates = hook(query, { ...updates }) || updates;
456
456
  }
@@ -465,7 +465,7 @@ class Table {
465
465
 
466
466
  const oldData = { ...this._rows[index] };
467
467
  const merged = { ...this._rows[index], ...updates };
468
- this._validateConstraints(merged, index);
468
+ this._validateConstraints(merged, index, undefined, opts);
469
469
 
470
470
  this._removeFromIndexes(index, oldData);
471
471
  Object.assign(this._rows[index], updates);
@@ -1354,7 +1354,7 @@ class Table {
1354
1354
  // 内部: 约束校验
1355
1355
  // ============================================================
1356
1356
 
1357
- _validateConstraints(data, excludeIndex, uniqueTracker) {
1357
+ _validateConstraints(data, excludeIndex, uniqueTracker, opts = {}) {
1358
1358
  for (const [field, def] of Object.entries(this._schema)) {
1359
1359
  if (field === '_softDelete') continue;
1360
1360
 
@@ -1438,6 +1438,13 @@ class Table {
1438
1438
  }
1439
1439
 
1440
1440
  // FOREIGN KEY 校验
1441
+ // skipForeignKeys:父表主键 CASCADE 传播时,子表的新值尚未在父表生效,
1442
+ // 此时跳过本表 FK 复查(否则合法的 CASCADE 会被自己拦住)。
1443
+ if (!opts.skipForeignKeys) this._validateForeignKeys(data);
1444
+ }
1445
+
1446
+ /** 仅校验外键引用是否存在(供插入/更新路径复用) */
1447
+ _validateForeignKeys(data) {
1441
1448
  for (const fk of this._foreignKeys) {
1442
1449
  if (data[fk.field] !== undefined && data[fk.field] !== null) {
1443
1450
  const refTable = this._resolveTable(fk.table);
@@ -1504,9 +1511,10 @@ class Table {
1504
1511
  if (refs.length === 0) continue;
1505
1512
  const action = (fk.onUpdate || 'restrict').toLowerCase();
1506
1513
  if (action === 'cascade') {
1507
- table.update({ [fk.field]: oldVal }, { [fk.field]: newVal });
1514
+ // 父行此时还没写入新值,跳过子表 FK 复查
1515
+ table.update({ [fk.field]: oldVal }, { [fk.field]: newVal }, { skipForeignKeys: true });
1508
1516
  } else if (action === 'set null') {
1509
- table.update({ [fk.field]: oldVal }, { [fk.field]: null });
1517
+ table.update({ [fk.field]: oldVal }, { [fk.field]: null }, { skipForeignKeys: true });
1510
1518
  } else {
1511
1519
  throw createError('ER_ROW_IS_REFERENCED');
1512
1520
  }
package/lib/tui.js CHANGED
@@ -252,7 +252,10 @@ class TUIShell {
252
252
  else if (b >= 0xe0) n = 3;
253
253
  else if (b >= 0xc0) n = 2;
254
254
  ch = seq.toString('utf8', i, i + n);
255
- step = ch.length || n;
255
+ // #34:step 必须是 UTF-8 字节数 n,不能是 ch.length(UTF-16 码元数)。
256
+ // 3 字节 CJK 字符 ch.length=1 而 n=3,若按 1 字节推进 i,剩下的 2 个
257
+ // 续字节会被当成新字符重新解析,产生乱码并破坏后续输入。
258
+ step = n;
256
259
  }
257
260
  this.line = this.line.slice(0, this.cursor) + ch + this.line.slice(this.cursor);
258
261
  this.cursor += ch.length;
@@ -305,21 +308,40 @@ class TUIShell {
305
308
  return !inSQ && !inDQ && parens <= 0 && (trimmed.endsWith(';') || trimmed.endsWith('\\g'));
306
309
  }
307
310
 
311
+ // 返回 cursor 左侧最近的 UTF-16 字符边界(跳过代理对,不把一个码元拆开)
312
+ _prevChar(c) {
313
+ if (c <= 0) return 0;
314
+ const code = this.line.charCodeAt(c - 1);
315
+ // 低代理(0xDC00-0xDFFF)前面一定是高代理 → 一起回退一个码点
316
+ if (code >= 0xdc00 && code <= 0xdfff) return c - 2;
317
+ return c - 1;
318
+ }
319
+ // 返回 cursor 右侧最近的 UTF-16 字符边界
320
+ _nextChar(c) {
321
+ if (c >= this.line.length) return this.line.length;
322
+ const code = this.line.charCodeAt(c);
323
+ // 高代理(0xD800-0xDBFF)后面一定是低代理 → 一起前进一个码点
324
+ if (code >= 0xd800 && code <= 0xdbff) return c + 2;
325
+ return c + 1;
326
+ }
327
+
308
328
  _backspace() {
309
329
  if (this.cursor <= 0) return;
310
- this.line = this.line.slice(0, this.cursor - 1) + this.line.slice(this.cursor);
311
- this.cursor--;
330
+ const nc = this._prevChar(this.cursor);
331
+ this.line = this.line.slice(0, nc) + this.line.slice(this.cursor);
332
+ this.cursor = nc;
312
333
  this._redrawPrompt();
313
334
  }
314
335
 
315
336
  _deleteAt() {
316
337
  if (this.cursor >= this.line.length) return;
317
- this.line = this.line.slice(0, this.cursor) + this.line.slice(this.cursor + 1);
338
+ const nc = this._nextChar(this.cursor);
339
+ this.line = this.line.slice(0, this.cursor) + this.line.slice(nc);
318
340
  this._redrawPrompt();
319
341
  }
320
342
 
321
- _moveLeft() { if (this.cursor > 0) { this.cursor--; this._redrawPrompt(); } }
322
- _moveRight() { if (this.cursor < this.line.length) { this.cursor++; this._redrawPrompt(); } }
343
+ _moveLeft() { if (this.cursor > 0) { this.cursor = this._prevChar(this.cursor); this._redrawPrompt(); } }
344
+ _moveRight() { if (this.cursor < this.line.length) { this.cursor = this._nextChar(this.cursor); this._redrawPrompt(); } }
323
345
 
324
346
  _histPrev() {
325
347
  if (this.history.length === 0) return;
@@ -10,6 +10,18 @@ function safeJsonParse(str) {
10
10
  try { return JSON.parse(str); } catch { return str; }
11
11
  }
12
12
 
13
+ /**
14
+ * 把 limit / offset / id 规范成非负整数,供 WASM jsql_find 等使用。
15
+ * 负值/NaN/Infinity 在绑定层抛错,避免 WASM 侧把负值当 usize/i32 出问题(#35)。
16
+ */
17
+ function toNonNeg(name, v, def) {
18
+ if (v === undefined || v === null) return def;
19
+ const n = Number(v);
20
+ if (!Number.isFinite(n)) throw new Error(`jsql-neo: ${name} must be a finite number, got ${v}`);
21
+ if (n < 0) throw new Error(`jsql-neo: ${name} must be non-negative, got ${v}`);
22
+ return Math.trunc(n);
23
+ }
24
+
13
25
  /**
14
26
  * 深度相等比较两个行对象(用于无主键表行匹配)。
15
27
  * 剥离内部字段(_rid, _deleted)后逐字段比较。
@@ -445,16 +457,24 @@ class JSQL {
445
457
  _findRawById(table, id) {
446
458
  const pk = this._pkOf(table);
447
459
  if (pk) {
460
+ // 显式拒绝负 id:自增主键场景下会落到 jsql_find(filter) 或 u64 参数,统一语义(issue #35)。
461
+ const nid = Number(id);
462
+ if (Number.isFinite(nid) && nid < 0) throw new Error('id must be a non-negative number');
448
463
  const filter = {};
449
464
  filter[pk] = this._coercePkId(table, id);
450
465
  const r = safeJsonParse(wasmBindings.jsql_find(table, JSON.stringify(filter), 1, 0));
451
466
  if (r && r.error) return r;
452
467
  return Array.isArray(r) && r.length > 0 ? r[0] : null;
453
468
  }
454
- return safeJsonParse(wasmBindings.jsql_find_by_id(table, BigInt(id)));
469
+ // 无主键表:id 是 1-based 行号(BigInt 序列)。#35:负数/NaN 会被 WASM 的
470
+ // u64 参数静默回绕,绑定层显式拒绝(与 native 端校验对齐)。
471
+ return safeJsonParse(wasmBindings.jsql_find_by_id(table, BigInt(toNonNeg('id', id, 0))));
455
472
  }
456
473
 
457
474
  _seqIdByPk(table, id) {
475
+ // 负 id 会被后续 BigInt(seq) -> u64 静默回绕(updateById/removeById 等),显式拒绝(issue #35)。
476
+ const nid0 = Number(id);
477
+ if (Number.isFinite(nid0) && nid0 < 0) throw new Error('id must be a non-negative number');
458
478
  const pk = this._pkOf(table);
459
479
  if (pk) {
460
480
  const filter = {};
@@ -503,16 +523,44 @@ class JSQL {
503
523
  });
504
524
  }
505
525
  const rawJson = JSON.stringify(rows);
526
+ const pk = this._pkOf(table);
527
+ const isAutoPk = this._pkAutoIncrement(table);
528
+ // #36:旧 WASM 二进制对自增主键表按「保留区间 [next_id, next_id+n)」批量分配 id,
529
+ // 若某行带了落在该区间内的显式主键,会与另一行的自动 id 冲突、覆盖 pk_index
530
+ // 遮蔽原行,且返回的 id 列表恒为保留区间(错)。
531
+ // 解决:显式主键逐行插入(单行批不会与自身保留区间冲突),并把返回 id 映射回
532
+ // 显式主键值;无显式主键时保持原有批量路径。
533
+ const hasExplicitPk = isAutoPk && pk && rows.some(row => row[pk] !== undefined && row[pk] !== null);
534
+ if (hasExplicitPk) {
535
+ // 旧 WASM 二进制缺重复主键检查:显式插入已存在的主键会覆盖 pk_index
536
+ // 遮蔽原行。绑定层先查一遍,已存在则报重复主键错(与 add_rows 语义一致)。
537
+ const seen = new Set();
538
+ const ids = [];
539
+ for (const row of rows) {
540
+ const explicitPk = row[pk] !== undefined && row[pk] !== null ? row[pk] : null;
541
+ if (explicitPk !== null) {
542
+ const key = String(explicitPk);
543
+ if (seen.has(key)) throw new Error(`ER_DUP_ENTRY: Duplicate entry '${explicitPk}' for key '${pk}'`);
544
+ seen.add(key);
545
+ const existing = this._findRawById(table, explicitPk);
546
+ if (existing && !existing.error) {
547
+ throw new Error(`ER_DUP_ENTRY: Duplicate entry '${explicitPk}' for key '${pk}'`);
548
+ }
549
+ }
550
+ const r = safeJsonParse(wasmBindings.jsql_insert_json(table, JSON.stringify([row])));
551
+ if (r && r.error) throw new Error(r.error);
552
+ ids.push(explicitPk !== null ? explicitPk : (Array.isArray(r) ? r[0] : undefined));
553
+ }
554
+ return ids;
555
+ }
506
556
  const r = safeJsonParse(wasmBindings.jsql_insert_json(table, rawJson));
507
557
  if (r && r.error) throw new Error(r.error);
508
- if (r && Array.isArray(r) && !this._pkAutoIncrement(table)) {
509
- const pk = this._pkOf(table);
510
- if (pk) {
511
- return r.map((seq, i) => {
512
- const row = rows[i];
513
- return (row && row[pk] !== undefined && row[pk] !== null) ? row[pk] : seq;
514
- });
515
- }
558
+ // 把返回的序列 id 映射回显式主键(若行内带了主键值),保证返回值正确。
559
+ if (r && Array.isArray(r) && pk) {
560
+ return r.map((seq, i) => {
561
+ const row = rows[i];
562
+ return (row && row[pk] !== undefined && row[pk] !== null) ? row[pk] : seq;
563
+ });
516
564
  }
517
565
  return r;
518
566
  }
@@ -653,7 +701,11 @@ class JSQL {
653
701
  if (!(await this._runHooks('beforeFind', [table, { filter, opts }]))) return [];
654
702
  const filterStr = filter ? JSON.stringify(filter) : '';
655
703
  const { limit = 100, offset = 0 } = opts;
656
- const r = safeJsonParse(wasmBindings.jsql_find(table, filterStr, limit, offset));
704
+ // WASM 端 jsql_find 的 limit/offset 是 usize(无符号),负数会经 wasm-bindgen
705
+ // 静默回绕成巨大值,导致空结果或溢出。#35:绑定层先校验(同 native_client.js)。
706
+ const lim = toNonNeg('limit', limit, 100);
707
+ const off = toNonNeg('offset', offset, 0);
708
+ const r = safeJsonParse(wasmBindings.jsql_find(table, filterStr, lim, off));
657
709
  if (r && r.error) throw new Error(r.error);
658
710
  await this._runHooks('afterFind', [table, { filter, opts }, r]);
659
711
  const schema = this._schemas[table];