jsql-neo 6.5.15 → 6.5.17

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, **and the browser**.
5
5
 
6
- > **v6.5.15** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
6
+ > **v6.5.17** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
7
7
 
8
8
  ![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)
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSQL-NEO v6.5.15 — Rust-Powered Embedded Database (WASM + HTTP)
2
+ * JSQL-NEO v6.5.17 — Rust-Powered Embedded Database (WASM + HTTP)
3
3
  *
4
4
  * @example
5
5
  * const jsql = require('jsql-neo');
@@ -146,6 +146,7 @@ class Connection extends EventEmitter {
146
146
  const r = await executeSQL(engine, finalSql, {
147
147
  safety: this.safety,
148
148
  allowComments: this.allowComments,
149
+ allowWrites: true,
149
150
  maxStatements: this.config.multipleStatements ? null : 1,
150
151
  });
151
152
  const results = Array.isArray(r) ? r : [r];
@@ -957,6 +957,8 @@ class MysqlConnection {
957
957
  const r = await executeSQL(engine, sqlToRun, {
958
958
  allowComments: this.server.allowComments,
959
959
  safety: this.server.safety,
960
+ // 协议服务器本身就是数据库端:客户端发来的 DROP/DELETE/UPDATE 必须执行。
961
+ allowWrites: true,
960
962
  maxStatements: 1,
961
963
  session: this.session,
962
964
  });
package/lib/sql.js CHANGED
@@ -18,6 +18,24 @@ const DANGEROUS_SQL = [
18
18
  // (CWE-770)。可在 executeSQL 通过 opts.maxSqlLength 放宽/关闭(传 Infinity)。
19
19
  const DEFAULT_MAX_SQL_LENGTH = 64 * 1024 * 1024;
20
20
 
21
+ // 破坏性语句:安全模式下默认拦截(可显式 allowWrites: true 放行)。
22
+ // 多语句注入(`SELECT …; DROP TABLE s;`)的修复分两层:
23
+ // 1) 安全模式默认只允许一条语句(mysql2 默认也是单语句);
24
+ // 2) 单条 DROP / TRUNCATE / DELETE / UPDATE 也需显式放行。
25
+ // 协议服务器(MySQL / PG)与迁移/管理台会显式传 allowWrites,保持原有能力。
26
+ const DESTRUCTIVE_STMT_TYPES = new Set([
27
+ 'delete', 'update', 'truncate',
28
+ 'dropTable', 'dropIndex', 'dropView', 'dropDatabase',
29
+ ]);
30
+
31
+ /** 递归看 WITH 包着的真正语句,返回破坏性语句类型或 null */
32
+ function findDestructiveStatement(stmt) {
33
+ if (!stmt || typeof stmt !== 'object') return null;
34
+ if (DESTRUCTIVE_STMT_TYPES.has(stmt.type)) return stmt.type;
35
+ if (stmt.type === 'with' && stmt.statement) return findDestructiveStatement(stmt.statement);
36
+ return null;
37
+ }
38
+
21
39
  function findDangerousSQL(tokens) {
22
40
  // 先把注释去掉,防止通过 /*comment*/DROP 绕过检测
23
41
  const commentStripped = [];
@@ -2867,8 +2885,21 @@ function applyCast(value, dataType) {
2867
2885
  if (value === null || value === undefined) return null;
2868
2886
  // SIGNED / UNSIGNED 是 MySQL 的整型别名(CAST(x AS SIGNED)),
2869
2887
  // 此前没列入整型分支,导致 CAST('12abc' AS SIGNED) 原样返回字符串。
2888
+ // CAST(x AS UNSIGNED):MySQL 先取整,再按 64 位无符号回绕(负数变成补码),
2889
+ // CAST(-5 AS UNSIGNED) = 18446744073709551611。用 BigInt 保精度,
2890
+ // 序列化层会把 BigInt 转成字符串(见 6.5.11 的超大整数处理)。
2891
+ if (t === 'UNSIGNED') {
2892
+ let n;
2893
+ if (typeof value === 'boolean') n = value ? 1n : 0n;
2894
+ else if (typeof value === 'number') n = Number.isFinite(value) ? BigInt(Math.trunc(value)) : 0n;
2895
+ else {
2896
+ const m = String(value).trim().match(/^[+-]?\d+(\.\d+)?/);
2897
+ n = m ? BigInt(Math.trunc(Number(m[0]))) : 0n;
2898
+ }
2899
+ return BigInt.asUintN(64, n);
2900
+ }
2870
2901
  if (t === 'INTEGER' || t === 'INT' || t === 'BIGINT' || t === 'SMALLINT' ||
2871
- t === 'TINYINT' || t === 'SIGNED' || t === 'UNSIGNED' || t === 'MEDIUMINT') {
2902
+ t === 'TINYINT' || t === 'SIGNED' || t === 'MEDIUMINT') {
2872
2903
  // MySQL CAST(... AS SIGNED/INT):取数字前缀('12abc'→12),无法解析时返回 0(而非 null)。
2873
2904
  if (typeof value === 'boolean') return value ? 1 : 0;
2874
2905
  if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : 0;
@@ -3480,7 +3511,13 @@ function applyScalarFunction(fnNode, row, ctx) {
3480
3511
  }
3481
3512
  case 'UPPER': case 'UCASE': return args[0] == null ? null : String(args[0]).toUpperCase();
3482
3513
  case 'LOWER': case 'LCASE': return args[0] == null ? null : String(args[0]).toLowerCase();
3483
- case 'LENGTH': case 'CHAR_LENGTH': case 'CHARACTER_LENGTH': return args[0] == null ? null : String(args[0]).length;
3514
+ // MySQL:LENGTH 是「字节数」(utf8mb4 下 'héllo' = 6),
3515
+ // CHAR_LENGTH / CHARACTER_LENGTH 才是「字符数」。此前三个全按字符数,
3516
+ // 非 ASCII 文本的字节长度全部偏小。
3517
+ case 'LENGTH': case 'OCTET_LENGTH':
3518
+ return args[0] == null ? null : Buffer.byteLength(String(args[0]), 'utf8');
3519
+ case 'CHAR_LENGTH': case 'CHARACTER_LENGTH':
3520
+ return args[0] == null ? null : String(args[0]).length;
3484
3521
  // TRIM(s) / TRIM([LEADING|TRAILING|BOTH] chars FROM s) / TRIM(s, chars)
3485
3522
  case 'TRIM': {
3486
3523
  if (args[0] == null) return null;
@@ -3767,7 +3804,10 @@ function applyScalarFunction(fnNode, row, ctx) {
3767
3804
  case 'FLOOR': return args[0] == null ? null : Math.floor(args[0]);
3768
3805
  case 'CEIL': case 'CEILING': return args[0] == null ? null : Math.ceil(args[0]);
3769
3806
  case 'MOD': return (args[0] == null || args[1] === 0) ? null : args[0] % args[1];
3770
- case 'POWER': case 'POW': return args[0] == null ? null : Math.pow(args[0], args[1]);
3807
+ case 'POWER': case 'POW':
3808
+ // MySQL:任一参数为 NULL 就返回 NULL。Math.pow(2, null) 会按 0 算出 1。
3809
+ if (args[0] == null || args[1] == null) return null;
3810
+ return Math.pow(args[0], args[1]);
3771
3811
  case 'SQRT': return args[0] == null ? null : Math.sqrt(args[0]);
3772
3812
  case 'IFNULL': case 'NVL': return args[0] != null ? args[0] : args[1];
3773
3813
  case 'COALESCE': {
@@ -3802,8 +3842,20 @@ function applyScalarFunction(fnNode, row, ctx) {
3802
3842
  }
3803
3843
  return s.substr(start - 1);
3804
3844
  }
3805
- case 'LEFT': return args[0] == null ? null : String(args[0]).slice(0, Number(args[1]));
3806
- case 'RIGHT': return args[0] == null ? null : String(args[0]).slice(-Number(args[1]));
3845
+ // MySQL:负长度返回空串(不是「从末尾切」或丢参数后的怪异子串)。
3846
+ case 'LEFT': {
3847
+ if (args[0] == null) return null;
3848
+ const n = Number(args[1]);
3849
+ if (!Number.isFinite(n) || n < 0) return '';
3850
+ return String(args[0]).slice(0, n);
3851
+ }
3852
+ case 'RIGHT': {
3853
+ if (args[0] == null) return null;
3854
+ const n = Number(args[1]);
3855
+ if (!Number.isFinite(n) || n < 0) return '';
3856
+ if (n === 0) return '';
3857
+ return String(args[0]).slice(-n);
3858
+ }
3807
3859
  case 'LOCATE': {
3808
3860
  if (args[0] == null || args[1] == null) return null;
3809
3861
  const idx = String(args[1]).indexOf(String(args[0]));
@@ -3866,23 +3918,31 @@ function applyScalarFunction(fnNode, row, ctx) {
3866
3918
  }
3867
3919
  return Array.isArray(v) ? v.length : null;
3868
3920
  }
3921
+ // MySQL:填充串按「需要多少补多少」截断,不能整段循环拼接——
3922
+ // LPAD('hi',5,'xy') = 'xyxhi'(此前每轮都加完整的 'xy',得到 'xyxyhi')。
3869
3923
  case 'LPAD': {
3870
- if (args[0] == null) return null;
3871
- let s = String(args[0]);
3872
- const n = Number(args[1]);
3873
- const pad = args[2] == null ? ' ' : String(args[2]);
3924
+ if (args[0] == null || args[1] == null) return null;
3925
+ if (args.length >= 3 && args[2] == null) return null;
3926
+ const s = String(args[0]);
3927
+ const n = Math.trunc(Number(args[1]));
3928
+ if (!Number.isFinite(n)) return null;
3874
3929
  if (n <= s.length) return s.slice(0, n);
3875
- while (s.length < n) s = pad + s;
3876
- return s;
3930
+ const pad = args[2] === undefined ? ' ' : String(args[2]);
3931
+ if (pad === '') return s;
3932
+ const need = n - s.length;
3933
+ return pad.repeat(Math.ceil(need / pad.length)).slice(0, need) + s;
3877
3934
  }
3878
3935
  case 'RPAD': {
3879
- if (args[0] == null) return null;
3880
- let s = String(args[0]);
3881
- const n = Number(args[1]);
3882
- const pad = args[2] == null ? ' ' : String(args[2]);
3936
+ if (args[0] == null || args[1] == null) return null;
3937
+ if (args.length >= 3 && args[2] == null) return null;
3938
+ const s = String(args[0]);
3939
+ const n = Math.trunc(Number(args[1]));
3940
+ if (!Number.isFinite(n)) return null;
3883
3941
  if (n <= s.length) return s.slice(0, n);
3884
- while (s.length < n) s = s + pad;
3885
- return s;
3942
+ const pad = args[2] === undefined ? ' ' : String(args[2]);
3943
+ if (pad === '') return s;
3944
+ const need = n - s.length;
3945
+ return s + pad.repeat(Math.ceil(need / pad.length)).slice(0, need);
3886
3946
  }
3887
3947
  case 'RAND': return args.length > 0 && args[0] != null ? seedRand(Number(args[0]))() : Math.random();
3888
3948
  case 'UNIX_TIMESTAMP': {
@@ -8032,26 +8092,43 @@ async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
8032
8092
  } else if (paramsOrOpts && typeof paramsOrOpts === 'object') {
8033
8093
  // 第三参为对象时:含已知 opts 键 → 视为 opts;否则视为命名参数({id: 1} → :id / $id)
8034
8094
  const OPTS_KEYS = ['safety', 'session', 'params', 'functions', 'aggregates',
8035
- 'allowComments', 'maxStatements', 'maxSqlLength', 'context', 'timeout', 'dialect'];
8095
+ 'allowComments', 'maxStatements', 'maxSqlLength', 'context', 'timeout', 'dialect',
8096
+ 'allowWrites', 'allowMultiStatement'];
8036
8097
  const looksLikeOpts = Object.keys(paramsOrOpts).some(k => OPTS_KEYS.includes(k));
8037
8098
  if (looksLikeOpts) opts = paramsOrOpts;
8038
8099
  else sql = applyParams(sql, paramsOrOpts);
8039
8100
  }
8040
- if (opts.safety !== false) {
8101
+ const safetyOn = opts.safety !== false;
8102
+ if (safetyOn) {
8041
8103
  if (!opts.allowComments && hasComments(sql)) {
8042
8104
  throw new Error('SQL comments are disabled for security (--, #, /* */)');
8043
8105
  }
8044
8106
  }
8045
8107
  let statements = splitStatements(sql, { maxLength: opts.maxSqlLength });
8046
- if (opts.maxStatements != null && statements.length > opts.maxStatements) {
8047
- throw new Error(`too many statements (${statements.length} > ${opts.maxStatements})`);
8048
- }
8049
- if (opts.safety !== false) {
8108
+ // 安全模式默认单语句:`SELECT 1; DROP TABLE s;` 不能再把第二条语句一起执行。
8109
+ // 需要多语句的内部调用方显式传 maxStatements 或 allowMultiStatement: true。
8110
+ const maxStatements = opts.maxStatements != null
8111
+ ? opts.maxStatements
8112
+ : (safetyOn && opts.allowMultiStatement !== true ? 1 : Infinity);
8113
+ if (statements.length > maxStatements) {
8114
+ throw new Error(`too many statements (${statements.length} > ${maxStatements})`);
8115
+ }
8116
+ if (safetyOn) {
8050
8117
  for (const stmtSql of statements) {
8051
8118
  const dangerous = findDangerousSQL(tokenize(stmtSql));
8052
8119
  if (dangerous) throw new Error(`SQL statement blocked by security policy: ${dangerous}`);
8053
8120
  }
8054
8121
  }
8122
+ // 先全部解析、再统一做破坏性检查 —— 若有第二条是 DROP,第一条 SELECT 也不能先执行。
8123
+ const parsedStatements = statements.map(s => parseSQL(s));
8124
+ if (safetyOn && opts.allowWrites !== true) {
8125
+ for (const stmt of parsedStatements) {
8126
+ const destructive = findDestructiveStatement(stmt);
8127
+ if (destructive) {
8128
+ throw new Error(`SQL statement blocked by security policy: ${destructive} (pass { allowWrites: true } or { safety: false } to allow writes)`);
8129
+ }
8130
+ }
8131
+ }
8055
8132
  const ctx = {};
8056
8133
  if (opts.session) ctx.session = opts.session;
8057
8134
  if (opts.params) ctx.params = opts.params; // 原生 ? 占位符(applyParams 之外的另一种用法)
@@ -8059,8 +8136,8 @@ async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
8059
8136
  if (opts.aggregates && typeof opts.aggregates === 'object') ctx.aggregates = opts.aggregates;
8060
8137
  const executor = new SQLExecutor(engine, Object.keys(ctx).length > 0 ? ctx : null);
8061
8138
  const results = [];
8062
- for (const stmtSql of statements) {
8063
- const stmt = parseSQL(stmtSql);
8139
+ for (let si = 0; si < parsedStatements.length; si++) {
8140
+ const stmt = parsedStatements[si];
8064
8141
  const t0 = process.hrtime.bigint();
8065
8142
  let r = await executor.execute(stmt);
8066
8143
  const durationMs = Number(process.hrtime.bigint() - t0) / 1e6;
@@ -118,6 +118,9 @@ async function executeStatement(sql, params) {
118
118
  },
119
119
  functions: userFunctions,
120
120
  aggregates: userAggregates,
121
+ // sqlite 兼容层是完整 SQL 接口:写操作与多语句都应保留。
122
+ allowWrites: true,
123
+ allowMultiStatement: true,
121
124
  });
122
125
  if (result && result.type === 'insert' && result.insertId !== null && result.insertId !== undefined) {
123
126
  _lastInsertId = Number(result.insertId);
package/lib/web_ui.js CHANGED
@@ -236,7 +236,8 @@ class WebUI {
236
236
  const db = await this.db(dbName);
237
237
  const t0 = Date.now();
238
238
  try {
239
- const res2 = await executeSQL(db, sql);
239
+ // 管理台是有鉴权的 SQL 控制台:需要执行 UPDATE/DELETE 等写语句。
240
+ const res2 = await executeSQL(db, sql, { allowWrites: true, allowMultiStatement: true });
240
241
  const ms = Date.now() - t0;
241
242
  const out = { ok: true, ms };
242
243
  if (res2 && res2.rows) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.5.15",
3
+ "version": "6.5.17",
4
4
  "description": "JSQL-NEO \u2014 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",
@@ -177,9 +177,6 @@ for (let t = 0; t < 25; t++) {
177
177
  ok('random mixed t' + t + ' vs reference', okAll && bt.size === ref.size, { size: bt.size, ref: ref.size, reason: reason });
178
178
  }
179
179
 
180
- console.log(failed === 0 ? `\nALL ${passed} BTREE TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
181
- process.exit(failed === 0 ? 0 : 1);
182
-
183
180
  /* ---- 回归:remove 后分隔键未更新导致内部键重复 ----
184
181
  *
185
182
  * 旧实现里 remove() 只改叶子,不更新祖先的分隔键副本。删掉某个正好是分隔键
@@ -205,3 +202,56 @@ process.exit(failed === 0 ? 0 : 1);
205
202
  if (errs.length) { okAll = false; reason = reason || ('validate: ' + errs[0]); }
206
203
  ok('回归:remove 后分隔键未更新', okAll && bt.size === ref.size, { size: bt.size, ref: ref.size, reason, keys: bt._root.keys });
207
204
  }
205
+
206
+ /* ---- 回归:混合增删触发再平衡后索引条目丢失(C1)----
207
+ *
208
+ * 报告数据:search(6) → [54,28](应为 [28,44,54])、size 少 1。
209
+ * 根因是删除路径「先再平衡、后确认键是否存在」:把分支合并/借用之后才去删,
210
+ * 目标键可能已被搬到别的节点,于是这个 rowIndex 静默消失,size 也随之错位。
211
+ * 下面用固定操作序列(含多行同键 + 反复删除触发 merge/borrow)锁定该场景,
212
+ * 逐值与参考实现比对。
213
+ */
214
+ {
215
+ const bt = new BTree(4);
216
+ const ref = new Map();
217
+ const ops = [];
218
+ // 先铺满两层,制造出可合并/可借用的兄弟节点
219
+ for (let k = 1; k <= 12; k++) ops.push([1, k, k]);
220
+ // key=6 挂三个行号(报告里的 28 / 44 / 54)
221
+ ops.push([1, 6, 28], [1, 6, 44], [1, 6, 54]);
222
+ // 删掉一批键,触发叶子合并 / 内部键下移
223
+ for (let k = 1; k <= 11; k += 2) ops.push([0, k, k]);
224
+ // 再插入新键,触发分裂
225
+ for (let k = 13; k <= 20; k++) ops.push([1, k, k]);
226
+ // 继续混合删除,踩 borrow/merge 路径
227
+ for (let k = 2; k <= 10; k += 2) ops.push([0, k, k]);
228
+ for (let k = 14; k <= 20; k += 2) ops.push([0, k, k]);
229
+ // 再插入,反复触发结构变化
230
+ ops.push([1, 21, 21], [1, 22, 22], [1, 23, 23], [0, 21, 21]);
231
+
232
+ let okAll = true, reason = '';
233
+ for (const [ins, k, r] of ops) {
234
+ if (ins) {
235
+ bt.insert(k, r);
236
+ if (!ref.has(k)) ref.set(k, new Set());
237
+ ref.get(k).add(r);
238
+ } else {
239
+ bt.remove(k, r);
240
+ if (ref.has(k)) { ref.get(k).delete(r); if (ref.get(k).size === 0) ref.delete(k); }
241
+ }
242
+ }
243
+ for (const [k, vals] of ref) {
244
+ const got = bt.search(k).slice().sort((a, b) => a - b);
245
+ const exp = [...vals].sort((a, b) => a - b);
246
+ if (JSON.stringify(got) !== JSON.stringify(exp)) { okAll = false; reason = reason || ('search(' + k + ')=' + JSON.stringify(got) + ' exp ' + JSON.stringify(exp)); }
247
+ }
248
+ const errs = validate(bt);
249
+ if (errs.length) { okAll = false; reason = reason || ('validate: ' + errs[0]); }
250
+ const six = bt.search(6).slice().sort((a, b) => a - b);
251
+ ok('C1 回归:key=6 三行号全部保留', JSON.stringify(six) === '[28,44,54]', six);
252
+ ok('C1 回归:混合增删后 size 与参考一致', bt.size === ref.size, { size: bt.size, ref: ref.size, reason });
253
+ ok('C1 回归:混合增删后逐键可搜到', okAll, reason);
254
+ }
255
+
256
+ console.log(failed === 0 ? `\nALL ${passed} BTREE TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
257
+ process.exit(failed === 0 ? 0 : 1);
package/test/coverage.js CHANGED
@@ -157,8 +157,8 @@ function tmp(name) {
157
157
  ok('sql aggregate', agg.rows[0][0] === 2);
158
158
  const fn = await executeSQL(db, 'SELECT VERSION(), CONCAT("x","y")');
159
159
  ok('sql func column names', fn.columns[0] === 'VERSION()');
160
- await executeSQL(db, 'UPDATE users SET age = 31 WHERE name = \'Bob\'');
161
- await executeSQL(db, 'DELETE FROM users WHERE id = 1');
160
+ await executeSQL(db, 'UPDATE users SET age = 31 WHERE name = \'Bob\'', { safety: false });
161
+ await executeSQL(db, 'DELETE FROM users WHERE id = 1', { safety: false });
162
162
  ok('sql count after delete', (await db.count('users')) === 1);
163
163
  await executeSQL(db, 'BEGIN');
164
164
  await executeSQL(db, "INSERT INTO users (name, age) VALUES ('Temp', 99)");
@@ -166,7 +166,7 @@ function tmp(name) {
166
166
  ok('sql rollback', (await db.count('users')) === 1);
167
167
  await executeSQL(db, 'START TRANSACTION');
168
168
  await executeSQL(db, "INSERT INTO users (name, age) VALUES ('X', 1)");
169
- await executeSQL(db, 'TRUNCATE TABLE users');
169
+ await executeSQL(db, 'TRUNCATE TABLE users', { safety: false });
170
170
  ok('sql truncate', (await db.count('users')) === 0);
171
171
  const sqlApi = require(path.join(ROOT, 'lib/sql'));
172
172
  const parsed = sqlApi.parseSQL('SELECT * FROM t WHERE id = 5');
@@ -159,15 +159,16 @@ test('超长字符串 100000 字符正常处理', async () => {
159
159
  assert.strictEqual(await one(q, `SELECT LENGTH('${long}')`), 100000);
160
160
  });
161
161
 
162
- test('Unicode é 正确处理(LENGTH=5, UPPER 含重音)', async () => {
162
+ test('Unicode é 正确处理(LENGTH=6 字节 / CHAR_LENGTH=5, UPPER 含重音)', async () => {
163
163
  const { q } = makeDb();
164
- assert.strictEqual(await one(q, "SELECT LENGTH('héllo')"), 5);
164
+ assert.strictEqual(await one(q, "SELECT LENGTH('héllo')"), 6);
165
+ assert.strictEqual(await one(q, "SELECT CHAR_LENGTH('héllo')"), 5);
165
166
  assert.strictEqual(await one(q, "SELECT UPPER('café')"), 'CAFÉ');
166
167
  });
167
168
 
168
- test('Emoji 按 UTF-16 单元计数(LENGTH=4)', async () => {
169
+ test('Emoji 按 UTF-8 字节计数(LENGTH=8)', async () => {
169
170
  const { q } = makeDb();
170
- assert.strictEqual(await one(q, "SELECT LENGTH('🚀🌟')"), 4);
171
+ assert.strictEqual(await one(q, "SELECT LENGTH('🚀🌟')"), 8);
171
172
  });
172
173
 
173
174
  test('Emoji UPPER 无变化', async () => {
@@ -92,6 +92,34 @@ function makeDb() { return new Database(':memory:'); }
92
92
  ok('options.user 不匹配拒绝', srvOpt._userPassword('other') === undefined);
93
93
  }
94
94
 
95
+ // ── N1. SQL 安全策略:多语句 / 破坏性语句 ────────────────────
96
+ console.log('\n--- N1. SQL 安全策略 ---');
97
+ {
98
+ const attempts = async (sql, opts) => {
99
+ try { await executeSQL(db, sql, opts); return null; } catch (e) { return e.message; }
100
+ };
101
+ await q('CREATE TABLE guard(id INT PRIMARY KEY, v INT)');
102
+ await q("INSERT INTO guard VALUES (1, 1)");
103
+ const multi = await attempts('SELECT 1; DROP TABLE guard');
104
+ ok('多语句被拒绝(默认单语句)', /too many statements/i.test(multi || ''), multi);
105
+ ok('被拒后目标表仍在', (await q('SELECT * FROM guard')).rows.length === 1);
106
+ const drop = await attempts('DROP TABLE guard');
107
+ ok('单条 DROP 被拦截', /blocked by security policy: dropTable/i.test(drop || ''), drop);
108
+ const del = await attempts('DELETE FROM guard');
109
+ ok('单条 DELETE 被拦截', /blocked by security policy: delete/i.test(del || ''), del);
110
+ const upd = await attempts('UPDATE guard SET v = 2');
111
+ ok('单条 UPDATE 被拦截', /blocked by security policy: update/i.test(upd || ''), upd);
112
+ const trunc = await attempts('TRUNCATE TABLE guard');
113
+ ok('单条 TRUNCATE 被拦截', /blocked by security policy: truncate/i.test(trunc || ''), trunc);
114
+ ok('WITH ... DELETE 同样被拦截',
115
+ /blocked by security policy: delete/i.test(await attempts('WITH x AS (SELECT 1) DELETE FROM guard') || ''));
116
+ const allowed = await attempts('DELETE FROM guard', { allowWrites: true });
117
+ ok('allowWrites: true 放行写操作', allowed === null, allowed);
118
+ const multiAllowed = await attempts('SELECT 1; SELECT 2', { allowMultiStatement: true, allowWrites: true });
119
+ ok('allowMultiStatement 可显式放开多语句(非破坏性)', multiAllowed === null, multiAllowed);
120
+ ok('safety:false 仍可执行破坏性语句', (await attempts('DROP TABLE guard', { safety: false })) === null);
121
+ }
122
+
95
123
  // ── H2. NeDB $regex ─────────────────────────────────────────
96
124
  console.log('\n--- H2. NeDB $regex ---');
97
125
  {
@@ -244,6 +272,32 @@ function makeDb() { return new Database(':memory:'); }
244
272
  ok('RLIKE BINARY 同样生效', eq((await q("SELECT id FROM r6 WHERE s RLIKE BINARY 'abc'")).rows, [[1]]));
245
273
  ok('非法正则安全返回不匹配', eq((await q("SELECT id FROM r6 WHERE s REGEXP BINARY '('")).rows, []));
246
274
 
275
+ // ── B1–B6. 标量函数语义(v6.5.17) ─────────────────────────
276
+ console.log('\n--- B1–B6. 标量函数 ---');
277
+ ok('B1 LENGTH 按字节(héllo=6)', (await one("SELECT LENGTH('héllo')")) === 6, await one("SELECT LENGTH('héllo')"));
278
+ ok('B1 CHAR_LENGTH 按字符(héllo=5)', (await one("SELECT CHAR_LENGTH('héllo')")) === 5);
279
+ ok('B1 emoji 字节数(🚀🌟=8)', (await one("SELECT LENGTH('🚀🌟')")) === 8);
280
+ ok('B2 OCTET_LENGTH 已实现(héllo=6)', (await one("SELECT OCTET_LENGTH('héllo')")) === 6);
281
+ ok('B2 OCTET_LENGTH NULL → NULL', (await one('SELECT OCTET_LENGTH(NULL)')) === null);
282
+ ok("B3 LEFT('hello',-3) → ''", (await one("SELECT LEFT('hello',-3)")) === '');
283
+ ok("B3 RIGHT('hello',-3) → ''", (await one("SELECT RIGHT('hello',-3)")) === '');
284
+ ok("B3 LEFT('hello',0) → ''", (await one("SELECT LEFT('hello',0)")) === '');
285
+ ok("B3 LEFT('hello',3) → 'hel'", (await one("SELECT LEFT('hello',3)")) === 'hel');
286
+ ok("B3 RIGHT('hello',3) → 'llo'", (await one("SELECT RIGHT('hello',3)")) === 'llo');
287
+ ok("B4 LPAD('hi',5,'xy') → 'xyxhi'", (await one("SELECT LPAD('hi',5,'xy')")) === 'xyxhi',
288
+ await one("SELECT LPAD('hi',5,'xy')"));
289
+ ok("B4 RPAD('hi',5,'xy') → 'hixyx'", (await one("SELECT RPAD('hi',5,'xy')")) === 'hixyx',
290
+ await one("SELECT RPAD('hi',5,'xy')"));
291
+ ok("B4 LPAD 目标更短时截断", (await one("SELECT LPAD('hello',3,'xy')")) === 'hel');
292
+ ok('B4 LPAD 填充串为 NULL → NULL', (await one("SELECT LPAD('hi',5,NULL)")) === null);
293
+ ok('B5 POWER(2,NULL) → NULL', (await one('SELECT POWER(2,NULL)')) === null);
294
+ ok('B5 POW(NULL,2) → NULL', (await one('SELECT POW(NULL,2)')) === null);
295
+ ok('B5 POWER 正常仍为 8', (await one('SELECT POWER(2,3)')) === 8);
296
+ ok('B6 CAST(-5 AS UNSIGNED) 64 位回绕', String(await one('SELECT CAST(-5 AS UNSIGNED)')) === '18446744073709551611',
297
+ String(await one('SELECT CAST(-5 AS UNSIGNED)')));
298
+ ok('B6 CAST(5 AS UNSIGNED) 保持原值', String(await one('SELECT CAST(5 AS UNSIGNED)')) === '5');
299
+ ok('B6 CAST(-5 AS SIGNED) 不回绕', (await one('SELECT CAST(-5 AS SIGNED)')) === -5);
300
+
247
301
  // ── M9. 插件钩子优先级 ─────────────────────────────────────
248
302
  console.log('\n--- M9. 钩子优先级 ---');
249
303
  {