jsql-neo 6.3.2 → 6.3.4
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 +65 -25
- package/index.js +73 -6
- package/lib/btree.js +5 -41
- package/lib/database.js +247 -29
- package/lib/native_client.js +57 -21
- package/lib/sql.js +192 -30
- package/native/jsql-neo-native.node +0 -0
- package/nativesrc/jsql-neo-core/src/engine/hybrid.rs +8 -1
- package/nativesrc/jsql-neo-native/src/lib.rs +274 -178
- package/package.json +4 -4
- package/test/mysql-compat-hijack.test.js +87 -0
- package/test/native-multi-instance.test.js +134 -0
- package/test/readme-audit.test.js +25 -0
- package/test/regress-5.1.0.js +32 -7
- package/test/sql-parser.test.js +114 -0
- package/test/wal-recovery.test.js +161 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* require.cache 全局劫持回归测试(P0-1)
|
|
3
|
+
*
|
|
4
|
+
* 背景:旧版 enableMySQLCompat() 会遍历 node_modules,把 mysql2 的
|
|
5
|
+
* require.cache 条目替换成内存兼容层。它靠 process.cwd() 猜路径,
|
|
6
|
+
* mysql2 通常尚未加载,于是被静默替换 —— 真实 MySQL 连接悄悄改查内存库
|
|
7
|
+
* 且不报错,表现为「本地跑通、上线全错」。
|
|
8
|
+
*
|
|
9
|
+
* 本测试锁定修复后的契约:
|
|
10
|
+
* 1. 默认调用 enableMySQLCompat() 绝不改动 require.cache
|
|
11
|
+
* 2. { global: true } 缺少环境变量确认时必须抛错
|
|
12
|
+
* 3. injectMySQLCompat() 只改写传入对象的引用,不污染全局
|
|
13
|
+
* 4. 兼容层仍可显式使用
|
|
14
|
+
*/
|
|
15
|
+
const assert = require('assert');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
let pass = 0, fail = 0;
|
|
21
|
+
function ok(name, cond, extra) {
|
|
22
|
+
if (cond) { pass++; console.log('[OK] ' + name); }
|
|
23
|
+
else { fail++; console.log('[FAIL] ' + name + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 造一个隔离的假 mysql2,用于验证劫持行为
|
|
27
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-hijack-'));
|
|
28
|
+
const nm = path.join(tmp, 'node_modules', 'mysql2');
|
|
29
|
+
fs.mkdirSync(nm, { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(nm, 'index.js'),
|
|
31
|
+
'exports.createConnection = function(){ return { __real: true }; };\n' +
|
|
32
|
+
'exports.createPool = function(){ return { __real: true }; };\n');
|
|
33
|
+
fs.writeFileSync(path.join(nm, 'package.json'),
|
|
34
|
+
JSON.stringify({ name: 'mysql2', version: '3.0.0', main: 'index.js' }));
|
|
35
|
+
|
|
36
|
+
const jsql = require('../index.js');
|
|
37
|
+
const mysql2Path = path.join(nm, 'index.js');
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
console.log('--- 1. 默认调用不得劫持 require.cache ---');
|
|
41
|
+
// 直接检查 index.js 源码不含无条件注入路径
|
|
42
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'index.js'), 'utf8');
|
|
43
|
+
const fnBody = src.slice(src.indexOf('function enableMySQLCompat'));
|
|
44
|
+
ok('enableMySQLCompat 内部对 global 做了显式判断',
|
|
45
|
+
/opts\.global\s*!==\s*true/.test(fnBody));
|
|
46
|
+
ok('危险路径需环境变量确认',
|
|
47
|
+
/JSQL_NEO_ALLOW_GLOBAL_HIJACK/.test(fnBody));
|
|
48
|
+
|
|
49
|
+
console.log('--- 2. 调用后 require.cache 不含被伪造的 mysql2 ---');
|
|
50
|
+
jsql.enableMySQLCompat();
|
|
51
|
+
const cached = require.cache[mysql2Path];
|
|
52
|
+
ok('require.cache 中没有 mysql2 条目', cached === undefined,
|
|
53
|
+
cached ? '仍被注入' : undefined);
|
|
54
|
+
|
|
55
|
+
console.log('--- 3. { global: true } 无环境变量时抛错 ---');
|
|
56
|
+
let threw = null;
|
|
57
|
+
try { jsql.enableMySQLCompat({ global: true }); } catch (e) { threw = e; }
|
|
58
|
+
ok('抛错并说明原因', threw && /require\.cache/.test(threw.message), threw && threw.message);
|
|
59
|
+
ok('抛错后仍未注入', require.cache[mysql2Path] === undefined);
|
|
60
|
+
|
|
61
|
+
console.log('--- 4. 显式注入只影响目标对象 ---');
|
|
62
|
+
const target = {
|
|
63
|
+
createConnection: () => ({ __fake: true }),
|
|
64
|
+
Driver: { createPool: () => ({ __fake: true }) },
|
|
65
|
+
};
|
|
66
|
+
const before = target.createConnection;
|
|
67
|
+
const touched = jsql.injectMySQLCompat(target);
|
|
68
|
+
ok('injectMySQLCompat 返回 true', touched === true);
|
|
69
|
+
ok('顶层 createConnection 已被替换', target.createConnection !== before);
|
|
70
|
+
ok('嵌套 Driver.createPool 已被替换',
|
|
71
|
+
typeof target.Driver.createPool === 'function' &&
|
|
72
|
+
target.Driver.createPool !== before);
|
|
73
|
+
ok('未触碰 require.cache', require.cache[mysql2Path] === undefined);
|
|
74
|
+
|
|
75
|
+
console.log('--- 5. 兼容层仍可显式使用 ---');
|
|
76
|
+
ok('导出 mysql2.createConnection', typeof jsql.mysql2.createConnection === 'function');
|
|
77
|
+
ok('导出 mysql2.createPool', typeof jsql.mysql2.createPool === 'function');
|
|
78
|
+
ok('导出 createConnection 快捷方式', typeof jsql.createConnection === 'function');
|
|
79
|
+
ok('导出 injectMySQLCompat', typeof jsql.injectMySQLCompat === 'function');
|
|
80
|
+
ok('enableMySQLCompat 默认调用返回兼容层',
|
|
81
|
+
jsql.enableMySQLCompat() === jsql.mysql2);
|
|
82
|
+
} finally {
|
|
83
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
87
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* native 多实例回归测试(P0-2)
|
|
3
|
+
*
|
|
4
|
+
* 背景:6.3.3 及以前 native 层是 `thread_local! static ENGINE`,
|
|
5
|
+
* 整个进程只有一个 HybridEngine。于是:
|
|
6
|
+
* new Database(dirA) → new Database(dirB)
|
|
7
|
+
* 会把 dirA 的所有表 clear() 掉(HybridEngine::open 无条件 clear),
|
|
8
|
+
* 同进程无法持有两个独立数据库 —— 对自称「嵌入式数据库」是硬伤。
|
|
9
|
+
*
|
|
10
|
+
* 6.3.4 改为句柄注册表:jsqlInstanceNew() 分配句柄,
|
|
11
|
+
* 所有操作走 jsqlXxxH(handle, ...);旧的 jsqlXxx() 走保留的默认实例 0。
|
|
12
|
+
*
|
|
13
|
+
* 若加载的是旧版预编译二进制(无 jsqlInstanceNew),整套多实例断言跳过,
|
|
14
|
+
* 但向后兼容路径仍需验证。
|
|
15
|
+
*/
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const native = require('../native/jsql-neo-native.node');
|
|
21
|
+
const { JSQL } = require('../lib/native_client.js');
|
|
22
|
+
|
|
23
|
+
let pass = 0, fail = 0, skip = 0;
|
|
24
|
+
function ok(name, cond, extra) {
|
|
25
|
+
if (cond) { pass++; console.log('[OK] ' + name); }
|
|
26
|
+
else { fail++; console.log('[FAIL] ' + name + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
|
|
27
|
+
}
|
|
28
|
+
function skipTest(name, why) { skip++; console.log('[SKIP] ' + name + ' — ' + why); }
|
|
29
|
+
|
|
30
|
+
const tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-native-' + tag + '-'));
|
|
31
|
+
const SCHEMA = { id: { type: 'integer', primaryKey: true }, tag: { type: 'string' } };
|
|
32
|
+
const hasHandles = typeof native.jsqlInstanceNew === 'function';
|
|
33
|
+
|
|
34
|
+
(async () => {
|
|
35
|
+
console.log('--- 1. native 二进制能力探测 ---');
|
|
36
|
+
console.log(' 句柄版 API: ' + (hasHandles ? '可用' : '不可用(旧版二进制)'));
|
|
37
|
+
ok('二进制可加载', !!native);
|
|
38
|
+
ok('旧版无句柄 API 仍存在(向后兼容)', typeof native.jsqlOpen === 'function');
|
|
39
|
+
|
|
40
|
+
if (hasHandles) {
|
|
41
|
+
console.log('--- 2. 两个独立引擎实例互不干扰(核心回归)---');
|
|
42
|
+
const dA = tmp('A'), dB = tmp('B');
|
|
43
|
+
const SC = JSON.stringify(SCHEMA);
|
|
44
|
+
const hA = native.jsqlInstanceNew();
|
|
45
|
+
native.jsqlOpenH(hA, dA, 'hybrid');
|
|
46
|
+
native.jsqlCreateTableH(hA, 't', SC);
|
|
47
|
+
native.jsqlInsertH(hA, 't', JSON.stringify([{ tag: 'A1' }]));
|
|
48
|
+
native.jsqlInsertH(hA, 't', JSON.stringify([{ tag: 'A2' }]));
|
|
49
|
+
|
|
50
|
+
// 关键:此时再开第二个库,旧版会把 A 的表全部清空
|
|
51
|
+
const hB = native.jsqlInstanceNew();
|
|
52
|
+
native.jsqlOpenH(hB, dB, 'hybrid');
|
|
53
|
+
native.jsqlCreateTableH(hB, 't', SC);
|
|
54
|
+
native.jsqlInsertH(hB, 't', JSON.stringify([{ tag: 'B1' }]));
|
|
55
|
+
|
|
56
|
+
ok('两个句柄不同', hA !== hB, { hA, hB });
|
|
57
|
+
ok('A 的数据未被 B 的 open() 清空', parseInt(native.jsqlCountH(hA, 't'), 10) === 2,
|
|
58
|
+
native.jsqlCountH(hA, 't'));
|
|
59
|
+
ok('B 的数据独立', parseInt(native.jsqlCountH(hB, 't'), 10) === 1,
|
|
60
|
+
native.jsqlCountH(hB, 't'));
|
|
61
|
+
const aRows = JSON.parse(native.jsqlFindH(hA, 't', '', 10, 0)).map(r => r.fields.tag);
|
|
62
|
+
const bRows = JSON.parse(native.jsqlFindH(hB, 't', '', 10, 0)).map(r => r.fields.tag);
|
|
63
|
+
ok('A 数据内容正确', JSON.stringify(aRows) === '["A1","A2"]', aRows);
|
|
64
|
+
ok('B 数据内容正确', JSON.stringify(bRows) === '["B1"]', bRows);
|
|
65
|
+
|
|
66
|
+
console.log('--- 3. 实例计数与销毁 ---');
|
|
67
|
+
ok('显式实例数为 2', native.jsqlInstanceCount() === 2, native.jsqlInstanceCount());
|
|
68
|
+
native.jsqlCloseH(hA); native.jsqlCloseH(hB);
|
|
69
|
+
JSON.parse(native.jsqlInstanceFree(hA));
|
|
70
|
+
JSON.parse(native.jsqlInstanceFree(hB));
|
|
71
|
+
ok('free 后实例归零', native.jsqlInstanceCount() === 0, native.jsqlInstanceCount());
|
|
72
|
+
ok('访问已销毁实例返回错误而非崩溃',
|
|
73
|
+
/no such database instance/.test(native.jsqlCountH(hA, 't')),
|
|
74
|
+
native.jsqlCountH(hA, 't'));
|
|
75
|
+
fs.rmSync(dA, { recursive: true, force: true });
|
|
76
|
+
fs.rmSync(dB, { recursive: true, force: true });
|
|
77
|
+
} else {
|
|
78
|
+
skipTest('多实例句柄测试', '旧版预编译二进制无 jsqlInstanceNew');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('--- 4. 默认实例向后兼容(无句柄 API)---');
|
|
82
|
+
{
|
|
83
|
+
const dD = tmp('D');
|
|
84
|
+
const r = JSON.parse(native.jsqlOpen(dD, 'hybrid'));
|
|
85
|
+
ok('jsqlOpen 走默认实例可用', r.ok === true, r);
|
|
86
|
+
native.jsqlCreateTable('t', JSON.stringify(SCHEMA));
|
|
87
|
+
native.jsqlInsert('t', JSON.stringify([{ tag: 'legacy' }]));
|
|
88
|
+
ok('jsqlCount 默认实例可读', native.jsqlCount('t') === '1', native.jsqlCount('t'));
|
|
89
|
+
const rows = JSON.parse(native.jsqlFind('t', '', 10, 0)).map(r => r.fields.tag);
|
|
90
|
+
ok('默认实例数据正确', JSON.stringify(rows) === '["legacy"]', rows);
|
|
91
|
+
native.jsqlClose();
|
|
92
|
+
fs.rmSync(dD, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log('--- 5. JS 层:两个 NativeJSQL 并存 ---');
|
|
96
|
+
{
|
|
97
|
+
const dA = tmp('jsA'), dB = tmp('jsB');
|
|
98
|
+
const A = new JSQL({ path: dA, mode: 'hybrid' });
|
|
99
|
+
const B = new JSQL({ path: dB, mode: 'hybrid' });
|
|
100
|
+
await A.start();
|
|
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 });
|
|
105
|
+
} else {
|
|
106
|
+
ok('旧版二进制下回退单实例(_handle 为 null)', A._handle === null);
|
|
107
|
+
}
|
|
108
|
+
await A.createTable('t', SCHEMA);
|
|
109
|
+
await A.insert('t', { tag: 'A1' });
|
|
110
|
+
await A.insert('t', { tag: 'A2' });
|
|
111
|
+
await B.createTable('t', SCHEMA);
|
|
112
|
+
await B.insert('t', { tag: 'B1' });
|
|
113
|
+
|
|
114
|
+
const ca = await A.count('t'), cb = await B.count('t');
|
|
115
|
+
ok('A 保留自己的 2 行', ca === 2, ca);
|
|
116
|
+
ok('B 有自己的 1 行', cb === 1, cb);
|
|
117
|
+
const ra = (await A.find('t', {})).map(r => r.tag);
|
|
118
|
+
ok('A 数据内容正确', JSON.stringify(ra) === '["A1","A2"]', ra);
|
|
119
|
+
|
|
120
|
+
await B.createTable('only_b', { id: { type: 'integer', primaryKey: true } });
|
|
121
|
+
let aSeesB = true;
|
|
122
|
+
try { await A.find('only_b', {}); aSeesB = false; } catch (e) { aSeesB = true; }
|
|
123
|
+
ok('A 看不到 B 独有的表', aSeesB);
|
|
124
|
+
|
|
125
|
+
await A.stop();
|
|
126
|
+
await B.stop();
|
|
127
|
+
if (hasHandles) ok('stop 释放句柄', A._handle === null && B._handle === null);
|
|
128
|
+
fs.rmSync(dA, { recursive: true, force: true });
|
|
129
|
+
fs.rmSync(dB, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(`\n${pass} passed, ${fail} failed${skip ? ', ' + skip + ' skipped' : ''}`);
|
|
133
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
134
|
+
})().catch(e => { console.error('FATAL', e); process.exit(1); });
|
|
@@ -528,6 +528,31 @@ async function main() {
|
|
|
528
528
|
ok('null bulk $-1', JSON.stringify((p('*2\r\n$3\r\nGET\r\n$-1\r\n') || {}).args) === '[null]');
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
+
console.log('--- 版本号一致性(package.json 是唯一真相)---');
|
|
532
|
+
{
|
|
533
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
534
|
+
const raw = fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8');
|
|
535
|
+
const v = pkg.version;
|
|
536
|
+
ok('package.json 版本号格式合法', /^\d+\.\d+\.\d+$/.test(v), v);
|
|
537
|
+
|
|
538
|
+
// README 头部横幅必须与 package.json 一致
|
|
539
|
+
const banner = raw.match(/^> \*\*v(\d+\.\d+\.\d+)\*\*/m);
|
|
540
|
+
ok('README 顶部版本横幅与 package.json 一致', banner && banner[1] === v,
|
|
541
|
+
banner ? `README=${banner[1]} package=${v}` : '未找到顶部横幅');
|
|
542
|
+
|
|
543
|
+
// 所有 "jsql-neo vX.Y.Z" / "文档版本:vX.Y.Z" 形式的版本声明都必须等于当前版本
|
|
544
|
+
const claims = [...raw.matchAll(/(?:jsql-neo v|文档版本:v|ver=)(\d+\.\d+\.\d+)/g)]
|
|
545
|
+
.map(m => m[1]);
|
|
546
|
+
const stale = [...new Set(claims.filter(c => c !== v))];
|
|
547
|
+
ok('README 无陈旧版本号声明', stale.length === 0, stale);
|
|
548
|
+
|
|
549
|
+
// index.js 头部注释也必须同步
|
|
550
|
+
const idx = fs.readFileSync(path.join(__dirname, '..', 'index.js'), 'utf8');
|
|
551
|
+
const hdr = idx.match(/JSQL-NEO v(\d+\.\d+\.\d+)/);
|
|
552
|
+
ok('index.js 头部版本注释与 package.json 一致', hdr && hdr[1] === v,
|
|
553
|
+
hdr ? `index.js=${hdr[1]} package=${v}` : '未找到版本注释');
|
|
554
|
+
}
|
|
555
|
+
|
|
531
556
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
532
557
|
process.exit(fail === 0 ? 0 : 1);
|
|
533
558
|
}
|
package/test/regress-5.1.0.js
CHANGED
|
@@ -17,7 +17,7 @@ const path = require('path');
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const os = require('os');
|
|
19
19
|
const net = require('net');
|
|
20
|
-
const { execFileSync } = require('child_process');
|
|
20
|
+
const { execFileSync, spawn } = require('child_process');
|
|
21
21
|
|
|
22
22
|
const ROOT = path.join(__dirname, '..');
|
|
23
23
|
const Database = require(path.join(ROOT, 'lib/database'));
|
|
@@ -180,12 +180,37 @@ function respClient(port) {
|
|
|
180
180
|
/* ============ CLI (N1) ============ */
|
|
181
181
|
{
|
|
182
182
|
const pkg = require(path.join(ROOT, 'package.json')).version;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
// Windows 上 spawnSync/execFileSync 复用同一个 node.exe 常报 EBUSY
|
|
184
|
+
// (文件被占用),会让整份测试崩在最前面。改用异步 spawn 并容忍失败。
|
|
185
|
+
const runCli = (args) => new Promise((resolve) => {
|
|
186
|
+
let child;
|
|
187
|
+
try {
|
|
188
|
+
child = spawn(process.execPath, [path.join(ROOT, 'bin/jsql'), ...args]);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
return resolve({ ok: false, out: '', err: String(e.message || e) });
|
|
191
|
+
}
|
|
192
|
+
let out = '', err = '';
|
|
193
|
+
child.stdout.on('data', d => { out += d; });
|
|
194
|
+
child.stderr.on('data', d => { err += d; });
|
|
195
|
+
child.on('error', e => resolve({ ok: false, out, err: String(e.message || e) }));
|
|
196
|
+
child.on('close', code => resolve({ ok: code === 0, out: out.trim(), err: err.trim() }));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const skipCli = (why) => {
|
|
200
|
+
console.log('[SKIP] CLI 测试无法在本环境执行: ' + why);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const v = await runCli(['--version']);
|
|
204
|
+
if (v.ok) ok('cli --version reports package version', v.out === pkg, v.out);
|
|
205
|
+
else skipCli(v.err || 'spawn 失败');
|
|
206
|
+
|
|
207
|
+
const v2 = await runCli(['version']);
|
|
208
|
+
if (v2.ok) ok('cli version command matches', v2.out === pkg, v2.out);
|
|
209
|
+
else skipCli(v2.err || 'spawn 失败');
|
|
210
|
+
|
|
211
|
+
const help = await runCli(['ui', '--help']);
|
|
212
|
+
if (help.ok) ok('cli subcommand --help does not crash', help.out.includes('127.0.0.1') && help.out.includes('auth-token'));
|
|
213
|
+
else skipCli(help.err || 'spawn 失败');
|
|
189
214
|
}
|
|
190
215
|
|
|
191
216
|
/* ============ WebUI CORS + auth (N2 / S2) ============ */
|
package/test/sql-parser.test.js
CHANGED
|
@@ -470,6 +470,120 @@ const rowsOf = (r) => (Array.isArray(r) ? r[0] : r).rows;
|
|
|
470
470
|
rbErr instanceof Error && /not supported/.test(rbErr.message));
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
/* ================= BUG 修复: 静默算错的标量函数(对齐 MySQL 8.0) ================= */
|
|
474
|
+
console.log('\n--- BUG 修复: INSTR / REPEAT / SUM 空集 / CAST / ROUND ---');
|
|
475
|
+
{
|
|
476
|
+
const scalar = async (sql) => rowsOf(await executeSQL(engine, sql, OPTS))[0][0];
|
|
477
|
+
// INSTR(str, substr):参数顺序与 LOCATE 相反,此前共用代码导致恒为 0
|
|
478
|
+
ok("INSTR('abc','bc') → 2", await scalar("SELECT INSTR('abc', 'bc') AS x") === 2);
|
|
479
|
+
ok("INSTR('abc','z') → 0", await scalar("SELECT INSTR('abc', 'z') AS x") === 0);
|
|
480
|
+
ok("LOCATE 不受影响", await scalar("SELECT LOCATE('bc', 'abc') AS x") === 2);
|
|
481
|
+
// REPEAT 此前完全缺失
|
|
482
|
+
ok("REPEAT('ab',3) → 'ababab'", await scalar("SELECT REPEAT('ab', 3) AS x") === 'ababab');
|
|
483
|
+
ok("REPEAT('ab',0) → ''", await scalar("SELECT REPEAT('ab', 0) AS x") === '');
|
|
484
|
+
// SUM 空集为 NULL(此前返回 0)
|
|
485
|
+
ok('SUM 空结果集 → null',
|
|
486
|
+
await scalar('SELECT SUM(sal) AS x FROM emp WHERE sal > 100000') === null);
|
|
487
|
+
ok('SUM 非空仍正确', await scalar('SELECT SUM(sal) AS x FROM emp') === 900);
|
|
488
|
+
// CAST(x AS INT):数字前缀解析、非法为 0(此前返回 null)
|
|
489
|
+
ok("CAST('abc' AS INT) → 0", await scalar("SELECT CAST('abc' AS INT) AS x") === 0);
|
|
490
|
+
ok("CAST('12abc' AS INT) → 12", await scalar("SELECT CAST('12abc' AS INT) AS x") === 12);
|
|
491
|
+
ok("CAST('3.9' AS INT) → 3", await scalar("SELECT CAST('3.9' AS INT) AS x") === 3);
|
|
492
|
+
// ROUND 中点浮点偏差
|
|
493
|
+
ok('ROUND(1.005, 2) → 1.01', await scalar('SELECT ROUND(1.005, 2) AS x') === 1.01);
|
|
494
|
+
ok('ROUND(2.5, 0) → 3(半远离零)', await scalar('SELECT ROUND(2.5, 0) AS x') === 3);
|
|
495
|
+
ok('ROUND(-3.5, 0) → -4', await scalar('SELECT ROUND(-3.5, 0) AS x') === -4);
|
|
496
|
+
ok('ROUND(123.456, -1) → 120(负位数不崩溃)', await scalar('SELECT ROUND(123.456, -1) AS x') === 120);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/* ================= BUG 修复: SELECT 列表的括号/标量布尔 ================= */
|
|
500
|
+
console.log('\n--- BUG 修复: SELECT 列表支持括号与标量布尔 ---');
|
|
501
|
+
{
|
|
502
|
+
const scalar = async (sql) => rowsOf(await executeSQL(engine, sql, OPTS))[0][0];
|
|
503
|
+
ok('SELECT (1+2) → 3', await scalar('SELECT (1+2) AS x') === 3);
|
|
504
|
+
ok('SELECT (2*(3+4)) → 14', await scalar('SELECT (2*(3+4)) AS x') === 14);
|
|
505
|
+
ok('SELECT NOT 0 → 1', await scalar('SELECT NOT 0 AS x') === 1);
|
|
506
|
+
ok('SELECT NOT 1 → 0', await scalar('SELECT NOT 1 AS x') === 0);
|
|
507
|
+
ok("SELECT 'a' LIKE 'b' → 0", await scalar("SELECT 'a' LIKE 'b' AS x") === 0);
|
|
508
|
+
ok("SELECT 'a' LIKE 'a' → 1", await scalar("SELECT 'a' LIKE 'a' AS x") === 1);
|
|
509
|
+
ok('SELECT 1 = 1 → 1', await scalar('SELECT 1 = 1 AS x') === 1);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* ================= BUG 修复: 日期加减的月末钳制(MySQL 语义) ================= */
|
|
513
|
+
console.log('\n--- BUG 修复: DATE_ADD/SUB 月末钳制 ---');
|
|
514
|
+
{
|
|
515
|
+
const expr = async (sql) => rowsOf(await executeSQL(engine, sql, OPTS))[0][0];
|
|
516
|
+
// JS setMonth/setFullYear 会溢出(1-31 +1 月 → 3-03),MySQL 夹到月末(→ 2-28)
|
|
517
|
+
ok('1-31 +1 MONTH → 2-28',
|
|
518
|
+
await expr("SELECT DATE_ADD('2023-01-31', INTERVAL 1 MONTH) AS x") === '2023-02-28');
|
|
519
|
+
ok('闰年 2-29 +1 YEAR → 2-28',
|
|
520
|
+
await expr("SELECT DATE_ADD('2024-02-29', INTERVAL 1 YEAR) AS x") === '2025-02-28');
|
|
521
|
+
ok('3-31 +1 QUARTER → 6-30',
|
|
522
|
+
await expr("SELECT DATE_ADD('2023-03-31', INTERVAL 1 QUARTER) AS x") === '2023-06-30');
|
|
523
|
+
ok('2-29 +1 YEAR → 2-28',
|
|
524
|
+
await expr("SELECT DATE_ADD('2020-02-29', INTERVAL 1 YEAR) AS x") === '2021-02-28');
|
|
525
|
+
ok('非月末 +1 MONTH 不变',
|
|
526
|
+
await expr("SELECT DATE_ADD('2023-01-15', INTERVAL 1 MONTH) AS x") === '2023-02-15');
|
|
527
|
+
ok('DATE_SUB 月末同样钳制',
|
|
528
|
+
await expr("SELECT DATE_SUB('2023-03-31', INTERVAL 1 MONTH) AS x") === '2023-02-28');
|
|
529
|
+
ok('12-31 +2 MONTH → 闰年 2-29',
|
|
530
|
+
await expr("SELECT DATE_ADD('2023-12-31', INTERVAL 2 MONTH) AS x") === '2024-02-29');
|
|
531
|
+
ok('DAY 单位不受影响',
|
|
532
|
+
await expr("SELECT DATE_ADD('2023-01-31', INTERVAL 1 DAY) AS x") === '2023-02-01');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* ---- ORDER BY 引用与聚合函数同名的输出别名(曾报 got 'AVG')---- */
|
|
536
|
+
console.log('\n--- BUG 修复: 分组查询的 ORDER BY ---');
|
|
537
|
+
{
|
|
538
|
+
const run = async (sql) => executeSQL(engine, sql, OPTS);
|
|
539
|
+
// mock 的 emp 表:eng(sal 100,200) / ops(sal 300,300)
|
|
540
|
+
// → SUM: eng=300 ops=600 | AVG: eng=150 ops=300 | MAX: eng=200 ops=300 | MIN: eng=100 ops=300
|
|
541
|
+
|
|
542
|
+
// (1) 别名恰好是聚合函数名:AVG/SUM/COUNT/MAX/MIN 都在关键字表里,
|
|
543
|
+
// 词法器标成 keyword 后既进不了 parseColumnRef,也不满足「后面跟 (」的聚合调用条件。
|
|
544
|
+
for (const [fn, agg, expectFirst] of [
|
|
545
|
+
['AVG', 'ROUND(AVG(sal),1)', 300],
|
|
546
|
+
['SUM', 'SUM(sal)', 600],
|
|
547
|
+
['MAX', 'MAX(sal)', 300],
|
|
548
|
+
['MIN', 'MIN(sal)', 300],
|
|
549
|
+
]) {
|
|
550
|
+
const sql = `SELECT dept, ${agg} AS ${fn} FROM emp GROUP BY dept ORDER BY ${fn} DESC`;
|
|
551
|
+
let rows = null, err = null;
|
|
552
|
+
try { rows = (await run(sql)).rows; } catch (e) { err = e; }
|
|
553
|
+
ok(`ORDER BY ${fn}(别名与聚合函数同名)`, err === null && Array.isArray(rows) && rows.length === 2,
|
|
554
|
+
err ? String(err.message || err) : rows);
|
|
555
|
+
ok(` └ ${fn} DESC 排序结果正确(首行 ${expectFirst})`,
|
|
556
|
+
!!rows && rows[0] && rows[0][1] === expectFirst, rows);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// (2) COUNT 两组都是 2,只能验证可解析 + 稳定
|
|
560
|
+
const c = await run(`SELECT dept, COUNT(*) AS COUNT FROM emp GROUP BY dept ORDER BY COUNT DESC`);
|
|
561
|
+
ok('ORDER BY COUNT(别名与聚合函数同名)', c.rows.length === 2, c.rows);
|
|
562
|
+
|
|
563
|
+
// (3) 分组查询此前 ORDER BY 完全不生效(直接 return,跳过了排序)
|
|
564
|
+
const desc = await run(`SELECT dept, SUM(sal) AS total FROM emp GROUP BY dept ORDER BY total DESC`);
|
|
565
|
+
ok('分组 + ORDER BY 别名 DESC 生效(此前被忽略)',
|
|
566
|
+
desc.rows.length === 2 && desc.rows[0][0] === 'ops' && desc.rows[0][1] === 600, desc.rows);
|
|
567
|
+
const asc = await run(`SELECT dept, SUM(sal) AS total FROM emp GROUP BY dept ORDER BY total ASC`);
|
|
568
|
+
ok('分组 + ORDER BY 别名 ASC 生效',
|
|
569
|
+
asc.rows[0][0] === 'eng' && asc.rows[0][1] === 300, asc.rows);
|
|
570
|
+
const byPos = await run(`SELECT dept, SUM(sal) AS total FROM emp GROUP BY dept ORDER BY 2 DESC`);
|
|
571
|
+
ok('分组 + ORDER BY 位置序号生效',
|
|
572
|
+
byPos.rows[0][0] === 'ops' && byPos.rows[0][1] === 600, byPos.rows);
|
|
573
|
+
const byCol = await run(`SELECT dept, sal FROM emp GROUP BY dept ORDER BY dept DESC`);
|
|
574
|
+
ok('分组 + ORDER BY 普通列生效',
|
|
575
|
+
byCol.rows[0][0] === 'ops', byCol.rows);
|
|
576
|
+
|
|
577
|
+
// (4) 嵌套聚合:ROUND(AVG(x),1) 曾恒为 null
|
|
578
|
+
const avg = await run(`SELECT dept, ROUND(AVG(sal),1) AS AVG FROM emp GROUP BY dept ORDER BY AVG DESC`);
|
|
579
|
+
ok('嵌套聚合 ROUND(AVG(sal),1) 返回数值而非 null',
|
|
580
|
+
avg.rows[0][1] === 300 && avg.rows[1][1] === 150, avg.rows);
|
|
581
|
+
|
|
582
|
+
// (5) LIMIT 在分组路径上也要生效
|
|
583
|
+
const lim = await run(`SELECT dept, SUM(sal) AS total FROM emp GROUP BY dept ORDER BY total DESC LIMIT 1`);
|
|
584
|
+
ok('分组 + ORDER BY + LIMIT 只返回 1 行', lim.rows.length === 1 && lim.rows[0][0] === 'ops', lim.rows);
|
|
585
|
+
}
|
|
586
|
+
|
|
473
587
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
474
588
|
process.exit(fail === 0 ? 0 : 1);
|
|
475
589
|
})();
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WAL 崩溃恢复回归测试(P1-1)
|
|
3
|
+
*
|
|
4
|
+
* 背景:6.3.3 及以前的 _recoverFromWAL() 只做 fs.unlinkSync 删除 WAL 文件,
|
|
5
|
+
* 从不回放任何操作;且构造函数用 fs.existsSync(dataFile) 短路,
|
|
6
|
+
* 新建库崩溃后连 WAL 都不会被读取。合起来的效果是:
|
|
7
|
+
* 「WAL / 崩溃恢复」是虚假宣传 —— 实测开启 wal:true 后 kill -9 必然丢数据。
|
|
8
|
+
*
|
|
9
|
+
* 6.3.4 修复:
|
|
10
|
+
* 1. WAL 改为 append-only JSONL + fsync
|
|
11
|
+
* 2. insert/update/removeById 现在都写 WAL
|
|
12
|
+
* 3. _recoverFromWAL 真正按 sequence 顺序回放
|
|
13
|
+
* 4. 构造函数在「数据文件不存在但 WAL 存在」时也走恢复流程
|
|
14
|
+
* 5. .jsql 二进制格式分支不再提前 return 跳过恢复
|
|
15
|
+
*
|
|
16
|
+
* 本测试用「不调用 save() 直接丢弃实例」模拟 kill -9。
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const Database = require('../lib/database.js');
|
|
22
|
+
|
|
23
|
+
let pass = 0, fail = 0;
|
|
24
|
+
function ok(name, cond, extra) {
|
|
25
|
+
if (cond) { pass++; console.log('[OK] ' + name); }
|
|
26
|
+
else { fail++; console.log('[FAIL] ' + name + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tmpFile(tag) {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-wal-' + tag + '-'));
|
|
31
|
+
return { dir, file: path.join(dir, 'db.jsql') };
|
|
32
|
+
}
|
|
33
|
+
const SCHEMA = { id: { type: 'integer', primaryKey: true }, v: { type: 'string' }, n: { type: 'integer' } };
|
|
34
|
+
|
|
35
|
+
(async () => {
|
|
36
|
+
console.log('--- 1. 全新库崩溃:WAL 必须恢复出全部数据 ---');
|
|
37
|
+
{
|
|
38
|
+
const { dir, file } = tmpFile('new');
|
|
39
|
+
const db = new Database(file, { wal: true, autoSave: false });
|
|
40
|
+
await db.createTable('t', SCHEMA);
|
|
41
|
+
await db.insert('t', { id: 1, v: 'important' });
|
|
42
|
+
await db.insert('t', { id: 2, v: 'second' });
|
|
43
|
+
await db.updateById('t', 1, { v: 'updated' });
|
|
44
|
+
await db.insert('t', { id: 3, v: 'third' });
|
|
45
|
+
await db.removeById('t', 3);
|
|
46
|
+
// 模拟 kill -9:直接丢弃,不 save 不 stop
|
|
47
|
+
const db2 = new Database(file, { wal: true });
|
|
48
|
+
const rows = await db2.find('t', {});
|
|
49
|
+
ok('崩溃后表仍存在', Object.keys(db2._tables).includes('t'));
|
|
50
|
+
ok('崩溃后行数正确 (2)', rows.length === 2, rows.length);
|
|
51
|
+
ok('崩溃后 update 已回放', rows.find(r => r.id === 1)?.v === 'updated', rows);
|
|
52
|
+
ok('崩溃后第二行存在', rows.find(r => r.id === 2)?.v === 'second');
|
|
53
|
+
ok('崩溃后 delete 已回放', !rows.find(r => r.id === 3));
|
|
54
|
+
ok('回放后 WAL 已检查点', !fs.existsSync(file + '.wal'));
|
|
55
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('--- 2. 快照 + WAL 叠加:save 后的新写入也要恢复 ---');
|
|
59
|
+
{
|
|
60
|
+
const { dir, file } = tmpFile('mix');
|
|
61
|
+
const db = new Database(file, { wal: true, autoSave: false });
|
|
62
|
+
await db.createTable('t', SCHEMA);
|
|
63
|
+
await db.insert('t', { id: 1, v: 'a' });
|
|
64
|
+
await db.insert('t', { id: 2, v: 'b' });
|
|
65
|
+
db.save(); // 快照:2 行,WAL 应清空
|
|
66
|
+
ok('save 后 WAL 被清空', !fs.existsSync(file + '.wal'));
|
|
67
|
+
await db.insert('t', { id: 3, v: 'c-after-save' }); // 只进 WAL
|
|
68
|
+
const db2 = new Database(file, { wal: true });
|
|
69
|
+
const rows = await db2.find('t', {});
|
|
70
|
+
ok('快照 2 行 + WAL 1 行 = 3 行', rows.length === 3, rows.length);
|
|
71
|
+
ok('WAL 中的新行已恢复', rows.find(r => r.id === 3)?.v === 'c-after-save');
|
|
72
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log('--- 3. 幂等:二次重开不得重复回放 ---');
|
|
76
|
+
{
|
|
77
|
+
const { dir, file } = tmpFile('idem');
|
|
78
|
+
const db = new Database(file, { wal: true, autoSave: false });
|
|
79
|
+
await db.createTable('t', SCHEMA);
|
|
80
|
+
await db.insert('t', { id: 1, v: 'x' });
|
|
81
|
+
await db.insert('t', { id: 2, v: 'y' });
|
|
82
|
+
const db2 = new Database(file, { wal: true });
|
|
83
|
+
const r2 = await db2.find('t', {});
|
|
84
|
+
const db3 = new Database(file, { wal: true });
|
|
85
|
+
const r3 = await db3.find('t', {});
|
|
86
|
+
ok('首次恢复 2 行', r2.length === 2, r2.length);
|
|
87
|
+
ok('二次重开仍 2 行(不重复回放)', r3.length === 2, r3.length);
|
|
88
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log('--- 4. WAL 为 append-only JSONL,崩溃写半的尾行可容错 ---');
|
|
92
|
+
{
|
|
93
|
+
const { dir, file } = tmpFile('torn');
|
|
94
|
+
const db = new Database(file, { wal: true, autoSave: false });
|
|
95
|
+
await db.createTable('t', SCHEMA);
|
|
96
|
+
await db.insert('t', { id: 1, v: 'ok' });
|
|
97
|
+
// 模拟崩溃时最后一行只写了一半
|
|
98
|
+
fs.appendFileSync(file + '.wal', '{"op":"insert","table":"t","ro');
|
|
99
|
+
const db2 = new Database(file, { wal: true });
|
|
100
|
+
const rows = await db2.find('t', {});
|
|
101
|
+
ok('半截尾行被丢弃,完整记录仍恢复', rows.length === 1, rows.length);
|
|
102
|
+
ok('数据内容正确', rows[0] && rows[0].v === 'ok', rows);
|
|
103
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('--- 5. deleteById / dropTable 也进 WAL ---');
|
|
107
|
+
{
|
|
108
|
+
const { dir, file } = tmpFile('del');
|
|
109
|
+
const db = new Database(file, { wal: true, autoSave: false });
|
|
110
|
+
await db.createTable('t', SCHEMA);
|
|
111
|
+
await db.createTable('u', SCHEMA);
|
|
112
|
+
await db.insert('t', { id: 1, v: 'a' });
|
|
113
|
+
await db.insert('u', { id: 1, v: 'b' });
|
|
114
|
+
await db.dropTable('u');
|
|
115
|
+
const db2 = new Database(file, { wal: true });
|
|
116
|
+
ok('被 drop 的表未复活', !db2._tables.u, Object.keys(db2._tables));
|
|
117
|
+
ok('保留的表数据完整', (await db2.find('t', {})).length === 1);
|
|
118
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log('--- 6. 未开启 wal 时不应产生 .wal 文件 ---');
|
|
122
|
+
{
|
|
123
|
+
const { dir, file } = tmpFile('off');
|
|
124
|
+
const db = new Database(file, { wal: false, autoSave: false });
|
|
125
|
+
await db.createTable('t', SCHEMA);
|
|
126
|
+
await db.insert('t', { id: 1, v: 'a' });
|
|
127
|
+
ok('未开启 wal 时无 .wal 文件', !fs.existsSync(file + '.wal'));
|
|
128
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log('--- 6b. 兼容 6.3.3 的旧格式 WAL(单行 JSON 数组)---');
|
|
132
|
+
{
|
|
133
|
+
// 升级场景:用户本地残留旧版写出的数组格式 WAL,不得被静默丢弃
|
|
134
|
+
const { dir, file } = tmpFile('oldfmt');
|
|
135
|
+
fs.writeFileSync(file + '.wal', JSON.stringify([
|
|
136
|
+
{ op: 'createTable', table: 'legacy', schema: SCHEMA },
|
|
137
|
+
]));
|
|
138
|
+
const db = new Database(file, { wal: true });
|
|
139
|
+
ok('旧格式数组 WAL 能被回放', !!db._tables.legacy, Object.keys(db._tables));
|
|
140
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log('--- 7. 回放后索引仍可用(B-Tree 重建)---');
|
|
144
|
+
{
|
|
145
|
+
const { dir, file } = tmpFile('idx');
|
|
146
|
+
const db = new Database(file, { wal: true, autoSave: false });
|
|
147
|
+
await db.createTable('t', SCHEMA);
|
|
148
|
+
db.createTable && 0;
|
|
149
|
+
for (let i = 1; i <= 30; i++) await db.insert('t', { id: i, v: 'v' + i, n: i });
|
|
150
|
+
db._tables.t.createIndex('n');
|
|
151
|
+
const db2 = new Database(file, { wal: true });
|
|
152
|
+
const found = await db2.find('t', { n: 17 });
|
|
153
|
+
ok('回放后按索引字段查询正确', found.length === 1 && found[0].id === 17, found);
|
|
154
|
+
const cnt = await db2.find('t', { n: { $gte: 25 } });
|
|
155
|
+
ok('回放后范围查询正确', cnt.length === 6, cnt.length);
|
|
156
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
160
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
161
|
+
})().catch(e => { console.error('FATAL', e); process.exit(1); });
|