jsql-neo 6.3.0 → 6.3.2

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/btree.js CHANGED
@@ -127,7 +127,8 @@ class BTree {
127
127
  */
128
128
  _route(node, key) {
129
129
  let i = this._lowerBound(node.keys, key);
130
- if (i < node.keys.length && node.keys[i] === key) {
130
+ // 叶子节点没有子节点可路由(children[i] 为 undefined),直接返回下界,避免崩溃。
131
+ if (i < node.keys.length && node.keys[i] === key && node.children[i]) {
131
132
  return this._getMax(node.children[i]).key === key ? i : i + 1;
132
133
  }
133
134
  return i;
package/lib/database.js CHANGED
@@ -1428,6 +1428,8 @@ class Database {
1428
1428
  async stop() {
1429
1429
  this._runHooks('onStop', []);
1430
1430
  this._emit('stop', {});
1431
+ // 内存监控定时器:stop() 此前从不清理,重复创建/销毁实例会堆积定时器
1432
+ if (this._monitorTimer) { clearInterval(this._monitorTimer); this._monitorTimer = null; }
1431
1433
  if (this._dirMode) {
1432
1434
  if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; }
1433
1435
  try {
@@ -1635,6 +1637,8 @@ class Database {
1635
1637
  clearTimeout(this._autoSaveTimer);
1636
1638
  this._autoSaveTimer = null;
1637
1639
  }
1640
+ // 同样清理内存监控定时器,否则 close() 后仍在后台触发 _checkMemory
1641
+ if (this._monitorTimer) { clearInterval(this._monitorTimer); this._monitorTimer = null; }
1638
1642
  if (this._dirMode) {
1639
1643
  try {
1640
1644
  this._flushDirty();
package/lib/date-types.js CHANGED
@@ -175,10 +175,13 @@ function validateTime(value, fieldName) {
175
175
  function now(type = 'datetime') {
176
176
  const d = new Date();
177
177
  switch (type) {
178
- case 'date': return d.toISOString().slice(0, 10);
178
+ // 必须与 validateDate/validateDateTime 一致使用本地时区:
179
+ // toISOString() 是 UTC,东八区 00:00 本地会被算成前一天 16:00Z,
180
+ // 导致 now() 写入的日期比实际早一天。
181
+ case 'date': return toLocalDateString(d);
179
182
  case 'timestamp': return Math.floor(d.getTime() / 1000);
180
- case 'time': return d.toISOString().slice(11, 19);
181
- default: return d.toISOString().replace('T', ' ').slice(0, 19);
183
+ case 'time': return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
184
+ default: return toLocalDateTimeString(d);
182
185
  }
183
186
  }
184
187
 
@@ -212,7 +212,6 @@ class Connection extends EventEmitter {
212
212
  this.state = 'closed';
213
213
  this.stream.destroy();
214
214
  this.emit('close');
215
- return undefined;
216
215
  })();
217
216
  if (cb) p.then(() => cb(null), err => cb(err));
218
217
  return p;
@@ -373,7 +372,6 @@ class Pool {
373
372
  const engines = Array.from(this._sharedEngines.values());
374
373
  this._sharedEngines.clear();
375
374
  await Promise.allSettled(engines.map(e => (typeof e.stop === 'function' ? e.stop() : null)));
376
- return undefined;
377
375
  })();
378
376
  if (cb) p.then(() => cb(null), err => cb(err));
379
377
  return p;
package/lib/sql.js CHANGED
@@ -2945,7 +2945,19 @@ function applyScalarFunction(fnNode, row, ctx) {
2945
2945
  case 'LTRIM': return args[0] == null ? null : String(args[0]).replace(/^\s+/, '');
2946
2946
  case 'RTRIM': return args[0] == null ? null : String(args[0]).replace(/\s+$/, '');
2947
2947
  case 'ABS': return args[0] == null ? null : Math.abs(args[0]);
2948
- case 'ROUND': return args[0] == null ? null : (args[1] !== undefined ? Number(args[0].toFixed(args[1])) : Math.round(args[0]));
2948
+ case 'ROUND': {
2949
+ // MySQL 语义:四舍五入「半远离零」(Math.round 是半向 +∞,对负数会错,如 -3.5→-3)
2950
+ // 位数 D 可为负(向小数点左侧舍入);非数值/非法输入返回 null,绝不抛 TypeError/RangeError。
2951
+ if (args[0] == null) return null;
2952
+ const x = Number(args[0]);
2953
+ if (!Number.isFinite(x)) return null;
2954
+ const digits = args[1] === undefined ? 0 : (Math.trunc(Number(args[1])) || 0);
2955
+ const factor = Math.pow(10, digits);
2956
+ if (!Number.isFinite(factor)) return x; // 位数过大:值不变
2957
+ if (factor === 0) return 0; // 位数过小:舍入到 0
2958
+ const sign = x < 0 ? -1 : 1;
2959
+ return sign * Math.round(Math.abs(x) * factor) / factor;
2960
+ }
2949
2961
  case 'FLOOR': return args[0] == null ? null : Math.floor(args[0]);
2950
2962
  case 'CEIL': case 'CEILING': return args[0] == null ? null : Math.ceil(args[0]);
2951
2963
  case 'MOD': return (args[0] == null || args[1] === 0) ? null : args[0] % args[1];
@@ -3227,7 +3239,9 @@ function evaluateExpr(expr, row, ctx) {
3227
3239
  const v = resolveOperand(expr.operand, row, ctx);
3228
3240
  const lo = resolveOperand(expr.low, row, ctx);
3229
3241
  const hi = resolveOperand(expr.high, row, ctx);
3230
- if (v === null || lo === null || hi === null) return false;
3242
+ // 三值逻辑:任一操作数为 NULL 时结果是 UNKNOWN(用 null 表示),
3243
+ // 与普通比较运算符一致;不能返回 false,否则 NOT BETWEEN 会被误判为真。
3244
+ if (v === null || v === undefined || lo === null || lo === undefined || hi === null || hi === undefined) return null;
3231
3245
  const inRange = typeof v === 'number' && typeof lo === 'number' && typeof hi === 'number'
3232
3246
  ? v >= lo && v <= hi
3233
3247
  : String(v) >= String(lo) && String(v) <= String(hi);
@@ -4998,7 +5012,7 @@ class SQLExecutor {
4998
5012
  if (firstItem && firstItem.table && String(firstItem.table).toLowerCase().startsWith('information_schema.')) {
4999
5013
  const view = String(firstItem.table).toLowerCase().split('.')[1];
5000
5014
  const all = await this._infoSchemaRows(view);
5001
- const filtered = statement.where ? all.filter(r => evaluateExpr(statement.where, r)) : all;
5015
+ const filtered = statement.where ? all.filter(r => evaluateExpr(statement.where, r, this.ctx)) : all;
5002
5016
  const cols = statement.columns.map(c => scalarColumnName(c));
5003
5017
  const isStar = cols.length === 1 && cols[0] === '*';
5004
5018
  let outCols;
@@ -5043,7 +5057,7 @@ class SQLExecutor {
5043
5057
  // 一对多:一个左行可能匹配多个右行,必须全部收进来(此前只取第一个)
5044
5058
  const hits = [];
5045
5059
  for (let ri = 0; ri < rightRows.length; ri++) {
5046
- if (evaluateExpr(j.on, { ...l, ...rightRows[ri] })) hits.push(ri);
5060
+ if (evaluateExpr(j.on, { ...l, ...rightRows[ri] }, this.ctx)) hits.push(ri);
5047
5061
  }
5048
5062
  if (hits.length > 0) {
5049
5063
  for (const ri of hits) {
package/lib/table.js CHANGED
@@ -234,8 +234,6 @@ class Table {
234
234
  if (row[field] !== undefined && row[field] !== null) uniqueTracker[field].add(row[field]);
235
235
  }
236
236
  }
237
- const dateFields = this._cachedDateFields;
238
- const hasDateFields = dateFields.length > 0;
239
237
  const autoIncField = this._autoIncrementField;
240
238
  const defaults = this._defaults;
241
239
  const computedFields = this._computedFields;
@@ -259,17 +257,16 @@ class Table {
259
257
  }
260
258
  }
261
259
  let autoIncSeq = 0;
260
+ const result = new Array(N);
262
261
  for (let i = 0; i < N; i++) {
263
- let data = items[i];
262
+ // 在副本上做默认值/计算列/类型归一,避免污染调用方传入的原始对象
263
+ let data = { ...items[i] };
264
264
  for (const [f, dv] of Object.entries(defaults)) {
265
265
  if (data[f] === undefined) data[f] = typeof dv === 'function' ? dv() : dv === 'CURRENT_TIMESTAMP' ? now() : dv;
266
266
  }
267
267
  for (const f of Object.keys(computedFields)) data[f] = computedFields[f](data);
268
- if (hasDateFields) {
269
- for (const f of dateFields) {
270
- if (data[f] !== undefined && data[f] !== null) data[f] = validateDateType(data[f], this._dateFields[f], f);
271
- }
272
- }
268
+ // 与单条 insert 保持一致:归一时间/数值/布尔/字符串类型(此前 insertMany 跳过了这一步)
269
+ data = this._validateDataTypes(data);
273
270
  for (const field of this._cachedSchemaFields) {
274
271
  const def = schema[field];
275
272
  if (def.required && (data[field] === undefined || data[field] === null)
@@ -312,15 +309,16 @@ class Table {
312
309
  const val = data[field];
313
310
  if (val !== undefined && val !== null) tree.insert(val, rowIdx);
314
311
  }
315
- items[i] = data;
312
+ // 不写回调用方传入的 items,改为返回归一后的新数组
313
+ result[i] = data;
316
314
  }
317
315
  this._dirty = true;
318
316
  this._db._markDirty(this._name);
319
317
  for (const hook of this._hooks.afterInsert) {
320
- for (let i = 0; i < N; i++) hook(items[i]);
318
+ for (let i = 0; i < N; i++) hook(result[i]);
321
319
  }
322
320
  this._db._emitChange('insertMany', this._name, { count: N });
323
- return items;
321
+ return result;
324
322
  }
325
323
 
326
324
  upsert(data) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.3.0",
3
+ "version": "6.3.2",
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",
@@ -58,6 +58,15 @@ function validate(bt) {
58
58
  ok('non-unique remove value keeps key until last', n.search('a').length === 0 && n.size === 1);
59
59
  }
60
60
 
61
+ /* ---- _route() 对叶子节点不再崩溃(单键根节点) ---- */
62
+ {
63
+ const bt = new BTree();
64
+ bt.insert(5, 0);
65
+ let threw = false;
66
+ try { bt._route(bt._root, 5); } catch (e) { threw = true; }
67
+ ok('_route() on leaf root no crash', !threw && bt._route(bt._root, 5) === 0);
68
+ }
69
+
61
70
  /* ---- 二分查找 / IN / 前缀 ---- */
62
71
  {
63
72
  const bt = new BTree(16);
package/test/join.test.js CHANGED
@@ -105,6 +105,16 @@ async function setup(db) {
105
105
  ok('J7 chained left joins', r.rows.length === 3 && r.rows[1][1] === 10 && r.rows[1][2] === 100 && r.rows[1][3] === 'C10' && r.rows[0][1] === null && r.rows[0][2] === null, r.rows);
106
106
  }
107
107
 
108
+ /* ============ J8 JOIN ON 透传 ctx ============ */
109
+ {
110
+ const db = new Database(':memory:', { autoSave: false });
111
+ await setup(db);
112
+ // ON 条件引用 @@会话变量:此前 evaluateExpr(j.on, row) 漏传 ctx,
113
+ // @@jv 解析为 null 导致匹配全丢,这里锁定必须透传 this.ctx。
114
+ const r = await executeSQL(db, 'SELECT a.id AS aid, b.id AS bid FROM a INNER JOIN b ON a.id = b.a_id AND b.id = @@jv', { session: { sysvars: { jv: 10 } } });
115
+ ok('J8 JOIN ON 透传 ctx(@@会话变量可解析)', r.rows.length === 1 && r.rows[0][0] === 2 && r.rows[0][1] === 10, r.rows);
116
+ }
117
+
108
118
  console.log(failed === 0 ? `\nALL ${passed} JOIN TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
109
119
  process.exit(failed === 0 ? 0 : 1);
110
120
  })().catch((e) => { console.error('FATAL', e); process.exit(1); });
@@ -4,6 +4,8 @@
4
4
  */
5
5
  const Database = require('../lib/database');
6
6
  const BTree = require('../lib/btree');
7
+ const { executeSQL } = require('../lib/sql');
8
+ const { now } = require('../lib/date-types');
7
9
 
8
10
  let passed = 0, failed = 0;
9
11
  function ok(name, cond, extra) {
@@ -104,6 +106,59 @@ function ok(name, cond, extra) {
104
106
  ok('batch afterInsert', afterInsertHits === 2, afterInsertHits);
105
107
  }
106
108
 
109
+ /* ============ BUG 修复: now() 时区 / 监控定时器 / insertMany 归一 & 不污染 ============ */
110
+ {
111
+ // BUG-1: now() 必须与 validateDate/DateTime 一致使用本地时区(此前用 toISOString=UTC)
112
+ const savedTZ = process.env.TZ;
113
+ process.env.TZ = 'Asia/Shanghai';
114
+ try {
115
+ const d = new Date();
116
+ const p2 = n => String(n).padStart(2, '0');
117
+ const localDate = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
118
+ const localTime = `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
119
+ ok('BUG-1 now("date") 本地时区', now('date') === localDate, { got: now('date'), want: localDate });
120
+ ok('BUG-1 now("time") 本地时区', now('time') === localTime, { got: now('time'), want: localTime });
121
+ ok('BUG-1 now() 本地时区', now() === `${localDate} ${localTime}`, { got: now(), want: `${localDate} ${localTime}` });
122
+ } finally {
123
+ if (savedTZ === undefined) delete process.env.TZ; else process.env.TZ = savedTZ;
124
+ }
125
+
126
+ // BUG-5: 内存监控定时器必须被 stop()/close() 清理
127
+ const mdb = new Database(':memory:', { autoSave: false });
128
+ await mdb.createTable('m', { id: 'integer' });
129
+ mdb._startMonitor();
130
+ ok('BUG-5 monitor 已启动', !!mdb._monitorTimer);
131
+ await mdb.stop();
132
+ ok('BUG-5 stop() 清理 monitorTimer', mdb._monitorTimer === null);
133
+ mdb._startMonitor();
134
+ mdb.close();
135
+ ok('BUG-5 close() 清理 monitorTimer', mdb._monitorTimer === null);
136
+
137
+ // BUG-6 / BUG-8: insertMany 在副本上归一,且不污染调用方对象
138
+ const tdb = new Database(':memory:', { autoSave: false });
139
+ await tdb.createTable('t', {
140
+ id: { type: 'integer', primaryKey: true, autoIncrement: true },
141
+ d: 'date', st: { type: 'string', default: 'x' }, age: 'integer'
142
+ });
143
+ const srcDate = new Date('2024-01-15T00:00:00Z');
144
+ const caller = [{ d: srcDate, age: '25' }];
145
+ const before = JSON.stringify(caller[0]);
146
+ const ret = tdb.t.insertMany(caller);
147
+ ok('BUG-6 insertMany 不污染调用方对象', JSON.stringify(caller[0]) === before, caller[0]);
148
+ ok('BUG-6 insertMany 返回归一后的新数组', Array.isArray(ret) && ret !== caller && ret[0].st === 'x' && ret[0].id === 1, ret);
149
+ const stored = tdb.t.find({})[0];
150
+ ok('BUG-8 insertMany 归一年龄为 number', typeof stored.age === 'number' && stored.age === 25, stored.age);
151
+ const p2 = n => String(n).padStart(2, '0');
152
+ const wantDate = `${srcDate.getFullYear()}-${p2(srcDate.getMonth() + 1)}-${p2(srcDate.getDate())}`;
153
+ ok('BUG-8 insertMany 归一日期为本地字符串', stored.d === wantDate, { got: stored.d, want: wantDate });
154
+
155
+ // BUG-4: information_schema 过滤条件必须透传 ctx(@@会话变量)
156
+ const idb = new Database(':memory:', { autoSave: false });
157
+ await idb.createTable('ifx', { id: 'integer' });
158
+ const ir = await executeSQL(idb, 'SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_NAME = @@tn', { session: { sysvars: { tn: 'ifx' } } });
159
+ ok('BUG-4 information_schema WHERE 透传 ctx', ir.rows.length === 1 && ir.rows[0][0] === 'ifx', ir.rows);
160
+ }
161
+
107
162
  console.log(failed === 0 ? `\nALL ${passed} QUERY-OPT TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
108
163
  process.exit(failed === 0 ? 0 : 1);
109
164
  })().catch(e => { console.error('FATAL', e); process.exit(1); });
@@ -268,6 +268,31 @@ const rowsOf = (r) => (Array.isArray(r) ? r[0] : r).rows;
268
268
  ok('未绑定参数不应在解析期报错', threw === null);
269
269
  }
270
270
 
271
+ /* ================= BUG 修复: ROUND 非数值/负数位数不再崩溃 ================= */
272
+ console.log('\n--- BUG 修复: ROUND MySQL 语义 ---');
273
+ {
274
+ const roundOf = async (expr) => rowsOf(await executeSQL(engine, `SELECT ${expr} AS v`, OPTS))[0][0];
275
+ ok("ROUND('abc', 0) 返回 null 而非 TypeError", await roundOf("ROUND('abc', 0)") === null);
276
+ ok("ROUND('abc') 返回 null", await roundOf("ROUND('abc')") === null);
277
+ ok('ROUND(3.14159, 2) = 3.14', await roundOf('ROUND(3.14159, 2)') === 3.14);
278
+ ok('ROUND(3.5) = 4', await roundOf('ROUND(3.5)') === 4);
279
+ ok('ROUND(-3.5) = -4(半远离零,与 MySQL 一致)', await roundOf('ROUND(-3.5)') === -4);
280
+ ok('ROUND(-2.5) = -3', await roundOf('ROUND(-2.5)') === -3);
281
+ ok('ROUND(123.456, -1) = 120(负数位数)', await roundOf('ROUND(123.456, -1)') === 120);
282
+ ok('ROUND(1.5, 101) 不再抛 RangeError', await roundOf('ROUND(1.5, 101)') === 1.5);
283
+ }
284
+
285
+ /* ================= BUG 修复: BETWEEN 含 NULL 的三值逻辑 ================= */
286
+ console.log('\n--- BUG 修复: BETWEEN NULL 三值逻辑 ---');
287
+ {
288
+ const nrows = async (sql) => rowsOf(await executeSQL(engine, sql, OPTS)).length;
289
+ // engine 行:sal 100/200/300/300
290
+ ok('sal BETWEEN NULL AND 400 不选行', await nrows('SELECT id FROM emp WHERE sal BETWEEN NULL AND 400') === 0);
291
+ ok('NOT sal BETWEEN NULL AND 400 不误选行', await nrows('SELECT id FROM emp WHERE NOT sal BETWEEN NULL AND 400') === 0);
292
+ ok('对照 NOT sal = NULL 也不选行', await nrows('SELECT id FROM emp WHERE NOT sal = NULL') === 0);
293
+ ok('正常 BETWEEN 区间仍工作', await nrows('SELECT id FROM emp WHERE sal BETWEEN 100 AND 200') === 2);
294
+ }
295
+
271
296
  /* ================= 5.6.0: 相关子查询(曾静默返回 null) ================= */
272
297
  console.log('\n--- 5.6.0: 相关子查询 ---');
273
298
  {
@@ -616,3 +616,76 @@ let wasm;
616
616
  export function __wbg_set_wasm(val) {
617
617
  wasm = val;
618
618
  }
619
+
620
+ // ============================================================
621
+ // WASM 初始化(原构建产物缺失这一段,导致 browser.mjs 无法加载:
622
+ // 它 `import init from './browser_bg.mjs'`,但本文件没有 default export)
623
+ // ============================================================
624
+
625
+ function __wbg_get_imports() {
626
+ const import0 = {
627
+ __proto__: null,
628
+ __wbg___wbindgen_string_get_b0ca35b86a603356: function(arg0, arg1) {
629
+ const obj = getObject(arg1);
630
+ const ret = typeof(obj) === 'string' ? obj : undefined;
631
+ var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
632
+ var len1 = WASM_VECTOR_LEN;
633
+ getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
634
+ getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
635
+ },
636
+ __wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) {
637
+ throw new Error(getStringFromWasm0(arg0, arg1));
638
+ },
639
+ __wbg_new_cc984128914cfc6f: function(arg0) {
640
+ const ret = new Date(getObject(arg0));
641
+ return addHeapObject(ret);
642
+ },
643
+ __wbg_now_86c0d4ba3fa605b8: function() {
644
+ const ret = Date.now();
645
+ return ret;
646
+ },
647
+ __wbg_toISOString_706fbe321055ee58: function(arg0) {
648
+ const ret = getObject(arg0).toISOString();
649
+ return addHeapObject(ret);
650
+ },
651
+ __wbindgen_cast_0000000000000001: function(arg0) {
652
+ // Cast intrinsic for `F64 -> Externref`.
653
+ const ret = arg0;
654
+ return addHeapObject(ret);
655
+ },
656
+ __wbindgen_object_drop_ref: function(arg0) {
657
+ takeObject(arg0);
658
+ },
659
+ };
660
+ return { './jsql_neo_wasm_bg.js': import0 };
661
+ }
662
+
663
+ /**
664
+ * 初始化 WASM 模块(default export,供 browser.mjs 的 `await init()` 调用)。
665
+ * @param {string|URL|Request|Response|ArrayBuffer|WebAssembly.Module} [input]
666
+ * 默认加载同目录的 jsql_neo_wasm_bg.wasm
667
+ */
668
+ async function __wbg_init(input) {
669
+ if (wasm !== undefined) return wasm;
670
+ const imports = __wbg_get_imports();
671
+ const path = input === undefined
672
+ ? new URL('./jsql_neo_wasm_bg.wasm', import.meta.url)
673
+ : input;
674
+
675
+ let instance;
676
+ if (path instanceof WebAssembly.Module) {
677
+ instance = (await WebAssembly.instantiate(path, imports)).instance;
678
+ } else if (typeof Response !== 'undefined' && path instanceof Response) {
679
+ instance = (await WebAssembly.instantiate(await path.arrayBuffer(), imports)).instance;
680
+ } else if (path instanceof ArrayBuffer || ArrayBuffer.isView(path)) {
681
+ instance = (await WebAssembly.instantiate(path, imports)).instance;
682
+ } else {
683
+ // 用 instantiate 而非 instantiateStreaming:某些 CDN 不返回 application/wasm MIME
684
+ const bytes = await (await fetch(path)).arrayBuffer();
685
+ instance = (await WebAssembly.instantiate(bytes, imports)).instance;
686
+ }
687
+ __wbg_set_wasm(instance.exports);
688
+ return instance.exports;
689
+ }
690
+
691
+ export default __wbg_init;