jsql-neo 6.0.0 → 6.0.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 +169 -56
- package/lib/native_client.js +69 -8
- package/lib/pg_server.js +7 -1
- package/lib/redis_server.js +58 -28
- package/lib/sql.js +1267 -117
- package/lib/wasm_client.js +58 -8
- package/native/jsql-neo-native.node +0 -0
- package/nativesrc/jsql-neo-native/Cargo.lock +2 -2
- package/package.json +4 -3
- package/test/readme-audit.test.js +420 -0
package/lib/wasm_client.js
CHANGED
|
@@ -10,6 +10,32 @@ function safeJsonParse(str) {
|
|
|
10
10
|
try { return JSON.parse(str); } catch { return str; }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 序列化并给出可读错误。直接 JSON.stringify 遇到循环引用会抛
|
|
15
|
+
* "Converting circular structure to JSON",指向不到具体列,这里补上表/列信息。
|
|
16
|
+
*/
|
|
17
|
+
function safeStringify(value, table, column) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.stringify(value);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
const msg = (e && e.message) ? e.message : String(e);
|
|
22
|
+
throw new Error(`Cannot serialize column '${column}' of table '${table}': ${msg}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 插入行必须是普通对象;null/undefined/数组/基本类型在这里就被拦下 */
|
|
27
|
+
function assertRowObject(row, index, isBatch) {
|
|
28
|
+
const ok = row !== null && row !== undefined && typeof row === 'object' &&
|
|
29
|
+
!Array.isArray(row) && !(row instanceof Date);
|
|
30
|
+
if (ok) return;
|
|
31
|
+
const got = row === null ? 'null'
|
|
32
|
+
: Array.isArray(row) ? 'array'
|
|
33
|
+
: row instanceof Date ? 'Date'
|
|
34
|
+
: typeof row;
|
|
35
|
+
const where = isBatch ? ` at index ${index}` : '';
|
|
36
|
+
throw new TypeError(`insert(): expected a row object${where}, got ${got}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
13
39
|
const NATIVE_TYPE_MAP = {
|
|
14
40
|
text: 'string',
|
|
15
41
|
varchar: 'string',
|
|
@@ -422,7 +448,7 @@ class JSQL {
|
|
|
422
448
|
if (typeof def === 'string') t = def.toLowerCase();
|
|
423
449
|
else if (def && typeof def === 'object' && def.type) t = String(def.type).toLowerCase();
|
|
424
450
|
if (v !== null && v !== undefined && ['json', 'array', 'object'].includes(t) && typeof v !== 'string') {
|
|
425
|
-
out[k] =
|
|
451
|
+
out[k] = safeStringify(v, table, k);
|
|
426
452
|
} else if (v instanceof Date) {
|
|
427
453
|
out[k] = v.toISOString();
|
|
428
454
|
} else {
|
|
@@ -432,7 +458,7 @@ class JSQL {
|
|
|
432
458
|
return out;
|
|
433
459
|
});
|
|
434
460
|
}
|
|
435
|
-
const r = safeJsonParse(wasmBindings.jsql_insert_json(table,
|
|
461
|
+
const r = safeJsonParse(wasmBindings.jsql_insert_json(table, safeStringify(rows, table, '<rows>')));
|
|
436
462
|
if (r && r.error) throw new Error(r.error);
|
|
437
463
|
if (r && Array.isArray(r) && !this._pkAutoIncrement(table)) {
|
|
438
464
|
const pk = this._pkOf(table);
|
|
@@ -448,24 +474,48 @@ class JSQL {
|
|
|
448
474
|
|
|
449
475
|
async _flush() {
|
|
450
476
|
if (!this._runHooks('beforeFlush', [])) return null;
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
flushed[table] = r;
|
|
456
|
-
}
|
|
477
|
+
// 先把整个 buffer 原子取出再写入。
|
|
478
|
+
// 旧写法是遍历 this._buffer 然后在 await 之后才清空 —— 这期间其他协程会继续
|
|
479
|
+
// 往同一个数组 push,导致同一批行被多个 flush 重复写(并发 insert 行数暴涨)。
|
|
480
|
+
const pending = this._buffer;
|
|
457
481
|
this._buffer = {};
|
|
458
482
|
this._bufferSize = 0;
|
|
483
|
+
const flushed = {};
|
|
484
|
+
try {
|
|
485
|
+
for (const [table, rows] of Object.entries(pending)) {
|
|
486
|
+
if (rows.length === 0) continue;
|
|
487
|
+
flushed[table] = await this._insertBatch(table, rows);
|
|
488
|
+
}
|
|
489
|
+
} catch (e) {
|
|
490
|
+
// 坏行绝不回写 buffer:否则 stop() 二次 flush 会再抛同一个错,进程 exit 1。
|
|
491
|
+
// 但也不能静默丢数据 —— 记到 _flushErrors 供排查(见 lastFlushErrors())。
|
|
492
|
+
if (!this._flushErrors) this._flushErrors = [];
|
|
493
|
+
this._flushErrors.push({
|
|
494
|
+
at: Date.now(),
|
|
495
|
+
message: (e && e.message) ? e.message : String(e),
|
|
496
|
+
tables: Object.keys(pending),
|
|
497
|
+
rowCounts: Object.fromEntries(Object.entries(pending).map(([t, r]) => [t, r.length])),
|
|
498
|
+
});
|
|
499
|
+
throw e;
|
|
500
|
+
}
|
|
459
501
|
this._runHooks('afterFlush', []);
|
|
460
502
|
return flushed;
|
|
461
503
|
}
|
|
462
504
|
|
|
505
|
+
/** 返回历次 flush 失败的记录(坏行不会回写 buffer,只能从这里查) */
|
|
506
|
+
lastFlushErrors() {
|
|
507
|
+
return (this._flushErrors || []).slice();
|
|
508
|
+
}
|
|
509
|
+
|
|
463
510
|
async flush() {
|
|
464
511
|
await this._flush();
|
|
465
512
|
}
|
|
466
513
|
|
|
467
514
|
async insert(table, data) {
|
|
515
|
+
// 入参校验:null / undefined / 非对象会在下游 Object.entries() 处炸成
|
|
516
|
+
// 难以定位的 "Cannot convert undefined or null to object",这里直接给明确 TypeError
|
|
468
517
|
const arr = Array.isArray(data) ? data : [data];
|
|
518
|
+
arr.forEach((row, i) => assertRowObject(row, i, arr.length > 1 || Array.isArray(data)));
|
|
469
519
|
if (!this._runHooks('beforeInsert', [table, arr])) return [];
|
|
470
520
|
if (arr.length > 1) {
|
|
471
521
|
await this._flush();
|
|
Binary file
|
|
@@ -205,7 +205,7 @@ dependencies = [
|
|
|
205
205
|
|
|
206
206
|
[[package]]
|
|
207
207
|
name = "jsql-neo-core"
|
|
208
|
-
version = "
|
|
208
|
+
version = "6.0.0"
|
|
209
209
|
dependencies = [
|
|
210
210
|
"bincode",
|
|
211
211
|
"chrono",
|
|
@@ -220,7 +220,7 @@ dependencies = [
|
|
|
220
220
|
|
|
221
221
|
[[package]]
|
|
222
222
|
name = "jsql-neo-native"
|
|
223
|
-
version = "
|
|
223
|
+
version = "6.0.0"
|
|
224
224
|
dependencies = [
|
|
225
225
|
"jsql-neo-core",
|
|
226
226
|
"napi",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsql-neo",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.4",
|
|
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",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"./wasm/browser.d.ts": "./wasm/browser.d.ts",
|
|
23
23
|
"./wasm/*": "./wasm/*",
|
|
24
|
+
"./lib/plugins": "./lib/plugins/index.js",
|
|
24
25
|
"./lib/*.js": "./lib/*.js",
|
|
25
26
|
"./lib/*": "./lib/*.js",
|
|
26
27
|
"./package.json": "./package.json",
|
|
@@ -45,12 +46,12 @@
|
|
|
45
46
|
],
|
|
46
47
|
"scripts": {
|
|
47
48
|
"postinstall": "node postinstall.js",
|
|
48
|
-
"test": "node test/smoke.js && node test/sql-parser.test.js && node test/query-opt.test.js",
|
|
49
|
+
"test": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js",
|
|
49
50
|
"test:parser": "node test/sql-parser.test.js",
|
|
50
51
|
"test:btree": "node test/btree.test.js",
|
|
51
52
|
"test:native": "node test/native.test.js",
|
|
52
53
|
"test:regress": "node test/regress-5.1.0.js",
|
|
53
|
-
"test:all": "node test/smoke.js && node test/sql-parser.test.js && node test/native.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js",
|
|
54
|
+
"test:all": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js",
|
|
54
55
|
"test:coverage": "c8 --all --include='lib/**' --exclude='lib/mysql_server.js' --exclude='lib/native_client.js' --exclude='lib/wasm_client.js' --exclude='lib/mysql_compat.js' --exclude='lib/nedb_compat.js' --exclude='lib/plugin.js' --reporter=text --reporter=lcov node test/coverage.js",
|
|
55
56
|
"test:orms": "node examples/orms/run-all.js"
|
|
56
57
|
},
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* README 一致性回归测试
|
|
3
|
+
*
|
|
4
|
+
* 背景:README 里承诺的语法与实际解析器长期脱节。曾出现过 40 条文档示例
|
|
5
|
+
* 跑不通(占 16%),以及 GROUP_CONCAT 能解析但静默返回 null 这类
|
|
6
|
+
* 「不报错但结果错」的问题。本测试把 README 当成契约来校验:
|
|
7
|
+
* 1. 所有 ```sql 代码块里的语句都必须能解析
|
|
8
|
+
* 2. 新增语法必须真的能执行出正确结果(而不只是解析通过)
|
|
9
|
+
* 3. 顶层导出的工厂函数不得出现 undefined
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const assert = require('assert');
|
|
14
|
+
const { parseSQL, splitStatements } = require('../lib/sql.js');
|
|
15
|
+
const { executeSQL } = require('../lib/sql.js');
|
|
16
|
+
|
|
17
|
+
let pass = 0, fail = 0;
|
|
18
|
+
function ok(name, cond, extra) {
|
|
19
|
+
if (cond) { pass++; console.log('[OK] ' + name); }
|
|
20
|
+
else { fail++; console.log('[FAIL] ' + name + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const README = fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8').split(/\r?\n/);
|
|
24
|
+
|
|
25
|
+
function extractSqlBlocks() {
|
|
26
|
+
const blocks = [];
|
|
27
|
+
let inBlock = false, lang = '', start = 0, buf = [];
|
|
28
|
+
README.forEach((line, i) => {
|
|
29
|
+
const m = /^```([a-zA-Z]*)\s*$/.exec(line);
|
|
30
|
+
if (!inBlock && m) { inBlock = true; lang = m[1]; start = i + 1; buf = []; return; }
|
|
31
|
+
if (inBlock && /^```\s*$/.test(line)) {
|
|
32
|
+
inBlock = false;
|
|
33
|
+
if (lang === 'sql') blocks.push({ start: start + 1, text: buf.join('\n') });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (inBlock) buf.push(line);
|
|
37
|
+
});
|
|
38
|
+
return blocks;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isGrammar = (t) => /[\[\]{}]/.test(t) || /\|/.test(t) ||
|
|
42
|
+
/\bselect_list\b|\btable_reference\b|\bcolumn_list\b/.test(t);
|
|
43
|
+
|
|
44
|
+
const OPTS = { safety: false };
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
console.log('--- README 中所有 SQL 示例都必须可解析 ---');
|
|
48
|
+
{
|
|
49
|
+
const blocks = extractSqlBlocks();
|
|
50
|
+
const failures = [];
|
|
51
|
+
let total = 0;
|
|
52
|
+
for (const b of blocks) {
|
|
53
|
+
if (isGrammar(b.text)) continue;
|
|
54
|
+
let stmts;
|
|
55
|
+
try { stmts = splitStatements(b.text); } catch (e) { continue; }
|
|
56
|
+
for (const s of stmts) {
|
|
57
|
+
if (!s || /^[-#]/.test(s)) continue;
|
|
58
|
+
if (!/^\(?\s*(select|with|insert|update|delete|create|drop|alter|truncate|show|describe|desc|use|set|begin|commit|rollback|explain|pragma|replace|savepoint|release|start)\b/i.test(s)) continue;
|
|
59
|
+
total++;
|
|
60
|
+
try { parseSQL(s); } catch (e) { failures.push({ line: b.start, sql: s.slice(0, 90), err: e.message }); }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
ok(`README SQL 语句全部可解析(共 ${total} 条)`, failures.length === 0, failures.slice(0, 5));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log('\n--- 新增语法必须能执行出正确结果(不只是解析通过)---');
|
|
67
|
+
{
|
|
68
|
+
const emp = [
|
|
69
|
+
{ id: 1, dept: 'eng', name: 'a', sal: 100, flag: 1 },
|
|
70
|
+
{ id: 2, dept: 'eng', name: 'b', sal: 200, flag: 0 },
|
|
71
|
+
{ id: 3, dept: 'ops', name: 'c', sal: 300, flag: 1 },
|
|
72
|
+
{ id: 4, dept: 'ops', name: 'd', sal: 400, flag: 3 },
|
|
73
|
+
];
|
|
74
|
+
const engine = {
|
|
75
|
+
hasTable: () => true, truncate: async () => {}, flush: async () => {},
|
|
76
|
+
find: async () => emp.map(r => ({ ...r })),
|
|
77
|
+
getTableSchema: async () => ({
|
|
78
|
+
id: { type: 'integer', primaryKey: true }, dept: { type: 'string' },
|
|
79
|
+
name: { type: 'string' }, sal: { type: 'integer' }, flag: { type: 'integer' },
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
const rowsOf = async (sql) => {
|
|
83
|
+
const r = await executeSQL(engine, sql, OPTS);
|
|
84
|
+
return (Array.isArray(r) ? r[0] : r).rows;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// 位运算与取模:词法层曾不产出这些 token,parseTerm 的 % 分支是死代码
|
|
88
|
+
ok('取模 %', JSON.stringify(await rowsOf('SELECT 10 % 3 AS r')) === '[[1]]');
|
|
89
|
+
ok('左移 <<', JSON.stringify(await rowsOf('SELECT 1 << 8 AS r')) === '[[256]]');
|
|
90
|
+
ok('按位与 &', JSON.stringify(await rowsOf('SELECT 255 & 15 AS r')) === '[[15]]');
|
|
91
|
+
ok('按位取反 ~', JSON.stringify(await rowsOf('SELECT ~0 AS r')) === '[[-1]]');
|
|
92
|
+
ok('WHERE 中位运算过滤',
|
|
93
|
+
JSON.stringify(await rowsOf('SELECT name FROM emp WHERE flag & 1 = 1')) === '[["a"],["c"],["d"]]');
|
|
94
|
+
|
|
95
|
+
// 函数实参曾不接受比较运算符,IF 系列全挂
|
|
96
|
+
ok("IF(1 > 0, 'yes', 'no')",
|
|
97
|
+
JSON.stringify(await rowsOf("SELECT IF(1 > 0, 'yes', 'no') AS r")) === '[["yes"]]');
|
|
98
|
+
ok('SUM(IF(...)) 按条件求和',
|
|
99
|
+
JSON.stringify(await rowsOf('SELECT SUM(IF(sal > 150, sal, 0)) AS r FROM emp')) === '[[900]]');
|
|
100
|
+
|
|
101
|
+
// DISTINCT 聚合
|
|
102
|
+
ok('COUNT(DISTINCT col)',
|
|
103
|
+
JSON.stringify(await rowsOf('SELECT COUNT(DISTINCT dept) AS r FROM emp')) === '[[2]]');
|
|
104
|
+
ok('COUNT(DISTINCT a, b) 多列',
|
|
105
|
+
JSON.stringify(await rowsOf('SELECT COUNT(DISTINCT dept, sal) AS r FROM emp')) === '[[4]]');
|
|
106
|
+
|
|
107
|
+
// GROUP_CONCAT:曾能解析但静默返回 null
|
|
108
|
+
ok('GROUP_CONCAT 默认分隔符',
|
|
109
|
+
JSON.stringify(await rowsOf('SELECT GROUP_CONCAT(name) AS g FROM emp')) === '[["a,b,c,d"]]');
|
|
110
|
+
ok('GROUP_CONCAT ORDER BY 生效(曾静默不排序)',
|
|
111
|
+
JSON.stringify(await rowsOf('SELECT GROUP_CONCAT(name ORDER BY sal DESC) AS g FROM emp')) === '[["d,c,b,a"]]');
|
|
112
|
+
ok('GROUP_CONCAT 自定义分隔符',
|
|
113
|
+
JSON.stringify(await rowsOf('SELECT GROUP_CONCAT(name, "|") AS g FROM emp')) === '[["a|b|c|d"]]');
|
|
114
|
+
|
|
115
|
+
// 日期:INTERVAL / EXTRACT
|
|
116
|
+
ok('DATE_ADD + INTERVAL',
|
|
117
|
+
JSON.stringify(await rowsOf("SELECT DATE_ADD('2026-08-12', INTERVAL 1 DAY) AS r")) === '[["2026-08-13"]]');
|
|
118
|
+
ok('DATE_ADD 保留时间部分',
|
|
119
|
+
JSON.stringify(await rowsOf("SELECT DATE_ADD('2026-08-12 10:00:00', INTERVAL 2 HOUR) AS r")) === '[["2026-08-12 12:00:00"]]');
|
|
120
|
+
ok('INTERVAL 负数',
|
|
121
|
+
JSON.stringify(await rowsOf("SELECT DATE_ADD('2026-08-12', INTERVAL -1 DAY) AS r")) === '[["2026-08-11"]]');
|
|
122
|
+
ok('DATE_SUB', JSON.stringify(await rowsOf("SELECT DATE_SUB('2026-08-12', INTERVAL 1 WEEK) AS r")) === '[["2026-08-05"]]');
|
|
123
|
+
ok('EXTRACT(YEAR FROM ...)',
|
|
124
|
+
JSON.stringify(await rowsOf("SELECT EXTRACT(YEAR FROM '2026-08-12') AS r")) === '[[2026]]');
|
|
125
|
+
|
|
126
|
+
// 字符串:标准 FROM / FOR 形式
|
|
127
|
+
ok('SUBSTR(s FROM n FOR m)',
|
|
128
|
+
JSON.stringify(await rowsOf("SELECT SUBSTR('abcdef' FROM 2 FOR 3) AS r")) === '[["bcd"]]');
|
|
129
|
+
ok("TRIM('x' FROM s)", JSON.stringify(await rowsOf("SELECT TRIM('x' FROM 'xxhixx') AS r")) === '[["hi"]]');
|
|
130
|
+
ok("TRIM(LEADING 'x' FROM s)", JSON.stringify(await rowsOf("SELECT TRIM(LEADING 'x' FROM 'xxhi') AS r")) === '[["hi"]]');
|
|
131
|
+
|
|
132
|
+
// WITH ROLLUP 必须能在 HAVING 之前解析(曾放错位置导致带 HAVING 就报错)
|
|
133
|
+
ok('GROUP BY ... WITH ROLLUP HAVING',
|
|
134
|
+
JSON.stringify(await rowsOf('SELECT dept, COUNT(*) AS cnt FROM emp GROUP BY dept WITH ROLLUP HAVING cnt >= 1'))
|
|
135
|
+
=== '[["eng",2],["ops",2],[null,4]]');
|
|
136
|
+
|
|
137
|
+
// FROM 子查询缺少别名时自动补(README 示例本身就没写别名)
|
|
138
|
+
ok('FROM (SELECT ...) 无别名',
|
|
139
|
+
JSON.stringify(await rowsOf('SELECT name FROM (SELECT name, sal FROM emp WHERE sal > 150)')) === '[["b"],["c"],["d"]]');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log('\n--- 方言特性:DIV / XOR / && / || / <=> / :: / -> ->> / LIKE ESCAPE ---');
|
|
143
|
+
{
|
|
144
|
+
const data = [
|
|
145
|
+
{ id: 1, n: 10, flag: 1, s: '100%', nm: 'Alice', meta: '{"name":"Alice","addr":{"city":"SH"},"tags":["x","y"]}' },
|
|
146
|
+
{ id: 2, n: 20, flag: 0, s: '1000', nm: 'Bob', meta: '{"name":"Bob","addr":{"city":"BJ"},"tags":[]}' },
|
|
147
|
+
{ id: 3, n: 30, flag: 1, s: '100', nm: 'Cara', meta: null },
|
|
148
|
+
];
|
|
149
|
+
const engine = {
|
|
150
|
+
hasTable: () => true, truncate: async () => {}, flush: async () => {},
|
|
151
|
+
find: async () => data.map(r => ({ ...r })),
|
|
152
|
+
getTableSchema: async () => ({
|
|
153
|
+
id: { type: 'integer', primaryKey: true }, n: { type: 'integer' }, flag: { type: 'integer' },
|
|
154
|
+
s: { type: 'string' }, nm: { type: 'string' }, meta: { type: 'json' },
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
const rowsOf = async (sql) => {
|
|
158
|
+
const r = await executeSQL(engine, sql, OPTS);
|
|
159
|
+
return (Array.isArray(r) ? r[0] : r).rows;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
ok('DIV 整数除法', JSON.stringify(await rowsOf('SELECT 7 DIV 2 AS r')) === '[[3]]');
|
|
163
|
+
ok('DIV 负数向零取整', JSON.stringify(await rowsOf('SELECT -7 DIV 2 AS r')) === '[[-3]]');
|
|
164
|
+
ok("'5'::INT 类型转换", JSON.stringify(await rowsOf("SELECT '5'::INT AS r")) === '[[5]]');
|
|
165
|
+
ok('XOR 逻辑异或(WHERE)',
|
|
166
|
+
JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE flag = 1 XOR n = 20')) === '[[1],[2],[3]]');
|
|
167
|
+
ok('&& 是 AND 别名',
|
|
168
|
+
JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE flag = 1 && n > 10')) === '[[3]]');
|
|
169
|
+
ok('|| 是 OR 别名',
|
|
170
|
+
JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE flag = 0 || n > 20')) === '[[2],[3]]');
|
|
171
|
+
ok('<=> 非空相等', JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE n <=> 20')) === '[[2]]');
|
|
172
|
+
ok('<=> NULL 安全(NULL<=>NULL 为真)',
|
|
173
|
+
JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE meta <=> NULL')) === '[[3]]');
|
|
174
|
+
ok("->>'name'", JSON.stringify(await rowsOf("SELECT meta->>'name' AS x FROM tbl WHERE id = 1")) === '[["Alice"]]');
|
|
175
|
+
ok("->'addr'->>'city' 链式",
|
|
176
|
+
JSON.stringify(await rowsOf("SELECT meta->'addr'->>'city' AS x FROM tbl WHERE id = 1")) === '[["SH"]]');
|
|
177
|
+
ok("->'tags'->>0 数组下标",
|
|
178
|
+
JSON.stringify(await rowsOf("SELECT meta->'tags'->>0 AS x FROM tbl WHERE id = 1")) === '[["x"]]');
|
|
179
|
+
// PG JSON 包含 / 路径操作符
|
|
180
|
+
ok("meta @> '{...}' 包含",
|
|
181
|
+
JSON.stringify(await rowsOf(`SELECT id FROM tbl WHERE meta @> '{"addr":{"city":"SH"}}'`)) === '[[1]]');
|
|
182
|
+
ok("'{...}' <@ meta 被包含",
|
|
183
|
+
JSON.stringify(await rowsOf(`SELECT id FROM tbl WHERE '{"name":"Bob"}' <@ meta`)) === '[[2]]');
|
|
184
|
+
ok("meta#>'{addr,city}' 路径",
|
|
185
|
+
JSON.stringify(await rowsOf("SELECT meta#>'{addr,city}' AS x FROM tbl WHERE id = 1")) === '[["SH"]]');
|
|
186
|
+
ok("meta#>>'{addr,city}' 路径文本",
|
|
187
|
+
JSON.stringify(await rowsOf("SELECT meta#>>'{addr,city}' AS x FROM tbl WHERE id = 1")) === '[["SH"]]');
|
|
188
|
+
ok("meta#>'{tags,0}' 路径含下标",
|
|
189
|
+
JSON.stringify(await rowsOf("SELECT meta#>'{tags,0}' AS x FROM tbl WHERE id = 1")) === '[["x"]]');
|
|
190
|
+
// JSON 键存在 ? / ?| / ?&
|
|
191
|
+
ok("meta ? 'name'", JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE meta ? 'name'")) === '[[1],[2]]');
|
|
192
|
+
ok("meta ? 'nope'(不存在)",
|
|
193
|
+
JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE meta ? 'nope'")) === '[]');
|
|
194
|
+
ok("meta ?| '{name,nope}'(任一)",
|
|
195
|
+
JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE meta ?| '{name,nope}'")) === '[[1],[2]]');
|
|
196
|
+
ok("meta ?& '{name,addr}'(全部)",
|
|
197
|
+
JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE meta ?& '{name,addr}'")) === '[[1],[2]]');
|
|
198
|
+
ok("meta ?& '{name,nope}'(缺一个)",
|
|
199
|
+
JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE meta ?& '{name,nope}'")) === '[]');
|
|
200
|
+
// PG 正则:~ 敏感 / ~* 不敏感 / !~ 取反
|
|
201
|
+
ok("nm ~ '^A' 大小写敏感", JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE nm ~ '^A'")) === '[[1]]');
|
|
202
|
+
ok("nm ~* '^a' 不敏感", JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE nm ~* '^a'")) === '[[1]]');
|
|
203
|
+
ok("nm !~ '^A' 取反", JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE nm !~ '^A'")) === '[[2],[3]]');
|
|
204
|
+
ok('REGEXP 仍可用', JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE nm REGEXP '^A'")) === '[[1]]');
|
|
205
|
+
ok("LIKE ... ESCAPE '!' 匹配字面量 %",
|
|
206
|
+
JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE s LIKE '100!%' ESCAPE '!'")) === '[[1]]');
|
|
207
|
+
ok('LIKE 反斜杠转义匹配字面量 %',
|
|
208
|
+
JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE s LIKE '100\\%'")) === '[[1]]');
|
|
209
|
+
ok('LIKE 普通 % 仍为通配',
|
|
210
|
+
JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE s LIKE '100%'")) === '[[1],[2],[3]]');
|
|
211
|
+
|
|
212
|
+
// 参数绑定:位置 ? / 命名 :name / 编号 $1(README「Params」一节)
|
|
213
|
+
const rowsOfP = async (sql, params) => {
|
|
214
|
+
const r = await executeSQL(engine, sql, params, OPTS);
|
|
215
|
+
return (Array.isArray(r) ? r[0] : r).rows;
|
|
216
|
+
};
|
|
217
|
+
ok('位置参数 ?', JSON.stringify(await rowsOfP('SELECT id FROM tbl WHERE id = ?', [1])) === '[[1]]');
|
|
218
|
+
ok('命名参数 :id', JSON.stringify(await rowsOfP('SELECT id FROM tbl WHERE id = :id', { id: 2 })) === '[[2]]');
|
|
219
|
+
ok('命名参数多占位', JSON.stringify(await rowsOfP('SELECT id FROM tbl WHERE id = :a OR id = :b', { a: 1, b: 3 })) === '[[1],[3]]');
|
|
220
|
+
ok('编号参数 $1', JSON.stringify(await rowsOfP('SELECT id FROM tbl WHERE id = $1', [3])) === '[[3]]');
|
|
221
|
+
ok('编号参数 $2/$1', JSON.stringify(await rowsOfP('SELECT id FROM tbl WHERE id = $2 OR id = $1', [1, 2])) === '[[1],[2]]');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log('\n--- DDL / 解析器节点覆盖 ---');
|
|
225
|
+
{
|
|
226
|
+
ok('CREATE INDEX 可解析', parseSQL('CREATE INDEX i ON t (a)').type === 'createIndex');
|
|
227
|
+
ok('CREATE UNIQUE INDEX 可解析', parseSQL('CREATE UNIQUE INDEX i ON t (a)').unique === true);
|
|
228
|
+
ok('DROP INDEX 可解析', parseSQL('DROP INDEX i ON t').type === 'dropIndex');
|
|
229
|
+
ok('FULL OUTER JOIN 可解析',
|
|
230
|
+
parseSQL('SELECT * FROM a FULL OUTER JOIN b ON a.id = b.id').from.joins[0].type === 'full');
|
|
231
|
+
ok('CAST 带长度可解析', parseSQL('SELECT CAST(a AS VARCHAR(10)) FROM t').columns[0].scalar.type === 'cast');
|
|
232
|
+
ok('FETCH FIRST 可解析', parseSQL('SELECT * FROM t FETCH FIRST 7 ROWS ONLY').limit === 7);
|
|
233
|
+
ok('ILIKE 可解析', parseSQL("SELECT * FROM t WHERE a ILIKE 'x'").where.ci === true);
|
|
234
|
+
ok('DELETE ... LIMIT 可解析', parseSQL('DELETE FROM t WHERE a = 1 LIMIT 5').limit === 5);
|
|
235
|
+
ok('ON CONFLICT DO UPDATE 可解析',
|
|
236
|
+
parseSQL('INSERT INTO t (a) VALUES (1) ON CONFLICT (a) DO UPDATE SET a = 2').onConflict.action === 'update');
|
|
237
|
+
ok('DEFAULT CURRENT_TIMESTAMP 可解析', (() => {
|
|
238
|
+
const st = parseSQL('CREATE TABLE t (id INT PRIMARY KEY, c DATETIME DEFAULT CURRENT_TIMESTAMP)');
|
|
239
|
+
return st.schema.c.default === 'CURRENT_TIMESTAMP';
|
|
240
|
+
})());
|
|
241
|
+
ok('SERIAL 折叠为整型+自增', (() => {
|
|
242
|
+
const st = parseSQL('CREATE TABLE t (id SERIAL PRIMARY KEY, n TEXT)');
|
|
243
|
+
return st.schema.id.type === 'integer' && st.schema.id.autoIncrement === true;
|
|
244
|
+
})());
|
|
245
|
+
ok('BIGSERIAL 折叠为整型+自增', (() => {
|
|
246
|
+
const st = parseSQL('CREATE TABLE t (id BIGSERIAL PRIMARY KEY)');
|
|
247
|
+
return st.schema.id.type === 'integer' && st.schema.id.autoIncrement === true;
|
|
248
|
+
})());
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log('\n--- executeSQL 结果信封(README 承诺的字段)---');
|
|
252
|
+
{
|
|
253
|
+
const engine = {
|
|
254
|
+
hasTable: () => true, truncate: async () => {}, flush: async () => {},
|
|
255
|
+
find: async () => [{ id: 1, name: 'a' }, { id: 2, name: 'b' }],
|
|
256
|
+
getTableSchema: async () => ({ id: { type: 'integer', primaryKey: true }, name: { type: 'string' } }),
|
|
257
|
+
};
|
|
258
|
+
const r = await executeSQL(engine, 'SELECT * FROM t', OPTS);
|
|
259
|
+
for (const f of ['columns', 'columnTypes', 'rows', 'rowCount', 'affectedRows', 'message', 'command', 'durationMs', 'warnings']) {
|
|
260
|
+
ok(`信封含 ${f}`, r[f] !== undefined, Object.keys(r));
|
|
261
|
+
}
|
|
262
|
+
ok('rowCount = 2', r.rowCount === 2);
|
|
263
|
+
ok('command = SELECT', r.command === 'SELECT');
|
|
264
|
+
ok('columnTypes 可推断(id → INT)', Array.isArray(r.columnTypes) && r.columnTypes[0] === 'INT');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
console.log('\n--- 顶层工厂导出不得为 undefined ---');
|
|
268
|
+
{
|
|
269
|
+
const jsql = require('../index.js');
|
|
270
|
+
for (const n of ['createMysqlServer', 'createPgServer', 'createRedisServer',
|
|
271
|
+
'createMongoServer', 'createMultiServer', 'createTUI']) {
|
|
272
|
+
ok(`导出 ${n}`, typeof jsql[n] === 'function');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
console.log('\n--- 子路径导出(README 里写的 require 路径必须真能引到)---');
|
|
277
|
+
{
|
|
278
|
+
const mod = require('../package.json');
|
|
279
|
+
const exportsMap = mod.exports || {};
|
|
280
|
+
ok('exports 暴露 ./lib/plugins 目录入口', !!exportsMap['./lib/plugins']);
|
|
281
|
+
try {
|
|
282
|
+
const plugins = require('../lib/plugins/index.js');
|
|
283
|
+
ok('lib/plugins 可加载且导出工厂函数', typeof plugins.createTimestamps === 'function');
|
|
284
|
+
} catch (e) {
|
|
285
|
+
ok('lib/plugins 可加载', false, e.message);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
console.log('\n--- 数据正确性:查不存在的列必须报错(曾静默返回 null)---');
|
|
290
|
+
{
|
|
291
|
+
const engine = {
|
|
292
|
+
hasTable: () => true, truncate: async () => {}, flush: async () => {},
|
|
293
|
+
find: async () => [{ id: 1, name: 'a', dept: 'eng' }],
|
|
294
|
+
getTableSchema: async () => ({
|
|
295
|
+
id: { type: 'integer', primaryKey: true }, name: { type: 'string' }, dept: { type: 'string' },
|
|
296
|
+
}),
|
|
297
|
+
};
|
|
298
|
+
const expectErr = async (sql) => {
|
|
299
|
+
let threw = null;
|
|
300
|
+
try { await executeSQL(engine, sql, OPTS); } catch (e) { threw = e; }
|
|
301
|
+
return threw;
|
|
302
|
+
};
|
|
303
|
+
ok('SELECT 不存在的列报错', !!(await expectErr('SELECT nosuchcol FROM t')));
|
|
304
|
+
ok('SELECT 混有不存在列时报错', !!(await expectErr('SELECT id, nope FROM t')));
|
|
305
|
+
ok('WHERE 引用不存在列报错', !!(await expectErr('SELECT id FROM t WHERE ghost = 1')));
|
|
306
|
+
ok('GROUP BY 不存在列报错', !!(await expectErr('SELECT COUNT(*) AS c FROM t GROUP BY ghost')));
|
|
307
|
+
// 不能误伤:正常查询与聚合别名都要放行
|
|
308
|
+
let normal = null;
|
|
309
|
+
try { await executeSQL(engine, 'SELECT id, UPPER(name) FROM t', OPTS); } catch (e) { normal = e; }
|
|
310
|
+
ok('正常查询不受影响', normal === null, normal && normal.message);
|
|
311
|
+
let aliasOk = null;
|
|
312
|
+
try {
|
|
313
|
+
await executeSQL(engine, 'SELECT dept, COUNT(*) AS cnt FROM t GROUP BY dept HAVING cnt >= 1', OPTS);
|
|
314
|
+
} catch (e) { aliasOk = e; }
|
|
315
|
+
ok('HAVING 引用 SELECT 别名不报错', aliasOk === null, aliasOk && aliasOk.message);
|
|
316
|
+
let subOk = null;
|
|
317
|
+
try { await executeSQL(engine, 'SELECT id FROM t WHERE id IN (SELECT id FROM t)', OPTS); } catch (e) { subOk = e; }
|
|
318
|
+
ok('子查询内部列名不误判', subOk === null, subOk && subOk.message);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
console.log('\n--- 并发 insert 不得重复写(曾行数暴涨 / 丢失)---');
|
|
322
|
+
{
|
|
323
|
+
// 两个引擎都要验收:曾只测 wasm,导致 native(默认引擎)的同款竞态长期漏网。
|
|
324
|
+
for (const engineName of ['wasm_client', 'native_client']) {
|
|
325
|
+
let JSQL, db;
|
|
326
|
+
try {
|
|
327
|
+
({ JSQL } = require('../lib/' + engineName + '.js'));
|
|
328
|
+
db = new JSQL();
|
|
329
|
+
await db.start();
|
|
330
|
+
} catch (e) {
|
|
331
|
+
console.log(`[SKIP] ${engineName} 不可用:` + e.message.slice(0, 60));
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const tag = `[${engineName}]`;
|
|
335
|
+
let id = 0;
|
|
336
|
+
await db.createTable('conc', { id: { type: 'INT', primaryKey: true }, b: { type: 'INT' } });
|
|
337
|
+
await Promise.all(Array.from({ length: 200 }, () => db.insert('conc', { id: ++id, b: 5 })));
|
|
338
|
+
const cnt = await db.count('conc');
|
|
339
|
+
ok(`${tag} 并发 insert 200 行 → count 恰为 200(实际 ${cnt})`, cnt === 200);
|
|
340
|
+
|
|
341
|
+
// flush 失败不得把坏行留在 buffer 里二次爆炸
|
|
342
|
+
ok(`${tag} insert(null) 抛 TypeError`, await (async () => {
|
|
343
|
+
try { await db.insert('conc', null); return false; } catch (e) { return e instanceof TypeError; }
|
|
344
|
+
})());
|
|
345
|
+
ok(`${tag} insert(undefined) 抛 TypeError`, await (async () => {
|
|
346
|
+
try { await db.insert('conc', undefined); return false; } catch (e) { return e instanceof TypeError; }
|
|
347
|
+
})());
|
|
348
|
+
ok(`${tag} insert(非对象) 抛 TypeError`, await (async () => {
|
|
349
|
+
try { await db.insert('conc', 42); return false; } catch (e) { return e instanceof TypeError; }
|
|
350
|
+
})());
|
|
351
|
+
|
|
352
|
+
// 环形对象:应给可读错误,而不是 "Converting circular structure" 裸崩
|
|
353
|
+
await db.createTable('cyc', { id: { type: 'INT', primaryKey: true }, meta: { type: 'JSON' } });
|
|
354
|
+
const cyc = { self: null };
|
|
355
|
+
cyc.self = cyc;
|
|
356
|
+
let cycErr = null;
|
|
357
|
+
try { await db.insert('cyc', { id: 1, meta: cyc }); } catch (e) { cycErr = e; }
|
|
358
|
+
ok(`${tag} 环形对象给出带表/列名的错误`,
|
|
359
|
+
!!cycErr && /Cannot serialize column 'meta'/.test(cycErr.message),
|
|
360
|
+
cycErr && cycErr.message);
|
|
361
|
+
await db.stop();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
console.log('\n--- 唯一约束:非主键 UNIQUE 必须报 ER_DUP_ENTRY ---');
|
|
366
|
+
{
|
|
367
|
+
let JSQL, db;
|
|
368
|
+
try {
|
|
369
|
+
({ JSQL } = require('../lib/native_client.js'));
|
|
370
|
+
db = new JSQL();
|
|
371
|
+
await db.start();
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.log('[SKIP] native 不可用:' + e.message.slice(0, 60));
|
|
374
|
+
}
|
|
375
|
+
if (db) {
|
|
376
|
+
const run = (sql) => executeSQL(db, sql, OPTS);
|
|
377
|
+
const dupErrMsg = async (sql) => {
|
|
378
|
+
try { await run(sql); return null; } catch (e) { return e.message; }
|
|
379
|
+
};
|
|
380
|
+
await run('CREATE TABLE u (id SERIAL PRIMARY KEY, email TEXT UNIQUE, nick TEXT UNIQUE)');
|
|
381
|
+
await run("INSERT INTO u (email, nick) VALUES ('a@x.com', 'a')");
|
|
382
|
+
ok('唯一列重复 → ER_DUP_ENTRY',
|
|
383
|
+
/ER_DUP_ENTRY/.test(await dupErrMsg("INSERT INTO u (email, nick) VALUES ('a@x.com', 'z')") || ''));
|
|
384
|
+
ok('批内唯一重复 → ER_DUP_ENTRY',
|
|
385
|
+
/ER_DUP_ENTRY/.test(await dupErrMsg("INSERT INTO u (email, nick) VALUES ('b@x.com', 'n1'), ('c@x.com', 'n1')") || ''));
|
|
386
|
+
const r1 = await run('SELECT id FROM u');
|
|
387
|
+
ok('冲突后仍只有 1 行', r1.rows.length === 1, r1.rows);
|
|
388
|
+
await run("INSERT INTO u (email, nick) VALUES (NULL, 'x'), (NULL, 'y')");
|
|
389
|
+
const r2 = await run('SELECT id FROM u');
|
|
390
|
+
ok('UNIQUE 允许多个 NULL', r2.rows.length === 3, r2.rows);
|
|
391
|
+
await db.stop();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
console.log('\n--- Redis:RESP2 帧解析(曾 inline 命令恒为 unknown command)---');
|
|
396
|
+
{
|
|
397
|
+
const { RedisServer } = require('../lib/redis_server.js');
|
|
398
|
+
const s = new RedisServer({ port: 0 });
|
|
399
|
+
const p = (raw) => s._parse(Buffer.from(raw, 'utf8'));
|
|
400
|
+
|
|
401
|
+
ok('RESP 数组 PING', JSON.stringify((p('*1\r\n$4\r\nPING\r\n') || {}).cmd) === '"PING"');
|
|
402
|
+
ok('RESP SET foo bar',
|
|
403
|
+
JSON.stringify((p('*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n') || {}).args) === '["foo","bar"]');
|
|
404
|
+
// 曾经这里返回数组,导致 switch(cmd) 永远匹配不上
|
|
405
|
+
ok('inline 命令 cmd 是字符串', (p('PING\r\n') || {}).cmd === 'PING');
|
|
406
|
+
ok('inline 带参数', JSON.stringify((p('SET k v\r\n') || {}).args) === '["k","v"]');
|
|
407
|
+
ok('*0 不崩溃', (p('*0\r\n') || {}).ignore === true);
|
|
408
|
+
ok('*-1 不崩溃', (p('*-1\r\n') || {}).ignore === true);
|
|
409
|
+
// $N 是字节长度:按字符切片会把多字节值截断
|
|
410
|
+
const cn = p('*3\r\n$3\r\nSET\r\n$2\r\nk1\r\n$12\r\n中文测试\r\n');
|
|
411
|
+
ok('多字节 UTF-8 按字节解析', JSON.stringify(cn && cn.args) === '["k1","中文测试"]');
|
|
412
|
+
ok('不完整帧返回 null(等待续包)', p('*3\r\n$3\r\nSET\r\n') === null);
|
|
413
|
+
ok('null bulk $-1', JSON.stringify((p('*2\r\n$3\r\nGET\r\n$-1\r\n') || {}).args) === '[null]');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
417
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
main().catch(e => { console.error(e); process.exit(1); });
|