jsql-neo 6.3.1 → 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/database.js +4 -0
- package/lib/date-types.js +6 -3
- package/lib/mysql_compat.js +0 -2
- package/lib/sql.js +5 -3
- package/lib/table.js +9 -11
- package/package.json +1 -1
- package/test/join.test.js +10 -0
- package/test/query-opt.test.js +55 -0
- package/test/sql-parser.test.js +11 -0
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
|
-
|
|
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.
|
|
181
|
-
default: return d
|
|
183
|
+
case 'time': return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
|
184
|
+
default: return toLocalDateTimeString(d);
|
|
182
185
|
}
|
|
183
186
|
}
|
|
184
187
|
|
package/lib/mysql_compat.js
CHANGED
|
@@ -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
|
@@ -3239,7 +3239,9 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
3239
3239
|
const v = resolveOperand(expr.operand, row, ctx);
|
|
3240
3240
|
const lo = resolveOperand(expr.low, row, ctx);
|
|
3241
3241
|
const hi = resolveOperand(expr.high, row, ctx);
|
|
3242
|
-
|
|
3242
|
+
// 三值逻辑:任一操作数为 NULL 时结果是 UNKNOWN(用 null 表示),
|
|
3243
|
+
// 与普通比较运算符一致;不能返回 false,否则 NOT BETWEEN 会被误判为真。
|
|
3244
|
+
if (v === null || v === undefined || lo === null || lo === undefined || hi === null || hi === undefined) return null;
|
|
3243
3245
|
const inRange = typeof v === 'number' && typeof lo === 'number' && typeof hi === 'number'
|
|
3244
3246
|
? v >= lo && v <= hi
|
|
3245
3247
|
: String(v) >= String(lo) && String(v) <= String(hi);
|
|
@@ -5010,7 +5012,7 @@ class SQLExecutor {
|
|
|
5010
5012
|
if (firstItem && firstItem.table && String(firstItem.table).toLowerCase().startsWith('information_schema.')) {
|
|
5011
5013
|
const view = String(firstItem.table).toLowerCase().split('.')[1];
|
|
5012
5014
|
const all = await this._infoSchemaRows(view);
|
|
5013
|
-
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;
|
|
5014
5016
|
const cols = statement.columns.map(c => scalarColumnName(c));
|
|
5015
5017
|
const isStar = cols.length === 1 && cols[0] === '*';
|
|
5016
5018
|
let outCols;
|
|
@@ -5055,7 +5057,7 @@ class SQLExecutor {
|
|
|
5055
5057
|
// 一对多:一个左行可能匹配多个右行,必须全部收进来(此前只取第一个)
|
|
5056
5058
|
const hits = [];
|
|
5057
5059
|
for (let ri = 0; ri < rightRows.length; ri++) {
|
|
5058
|
-
if (evaluateExpr(j.on, { ...l, ...rightRows[ri] })) hits.push(ri);
|
|
5060
|
+
if (evaluateExpr(j.on, { ...l, ...rightRows[ri] }, this.ctx)) hits.push(ri);
|
|
5059
5061
|
}
|
|
5060
5062
|
if (hits.length > 0) {
|
|
5061
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
321
|
+
return result;
|
|
324
322
|
}
|
|
325
323
|
|
|
326
324
|
upsert(data) {
|
package/package.json
CHANGED
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); });
|
package/test/query-opt.test.js
CHANGED
|
@@ -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); });
|
package/test/sql-parser.test.js
CHANGED
|
@@ -282,6 +282,17 @@ const rowsOf = (r) => (Array.isArray(r) ? r[0] : r).rows;
|
|
|
282
282
|
ok('ROUND(1.5, 101) 不再抛 RangeError', await roundOf('ROUND(1.5, 101)') === 1.5);
|
|
283
283
|
}
|
|
284
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
|
+
|
|
285
296
|
/* ================= 5.6.0: 相关子查询(曾静默返回 null) ================= */
|
|
286
297
|
console.log('\n--- 5.6.0: 相关子查询 ---');
|
|
287
298
|
{
|