jsql-neo 6.3.5 → 6.3.10
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 +78 -8239
- package/index.js +1 -1
- package/lib/cache.js +33 -27
- package/lib/client.js +11 -2
- package/lib/database.js +370 -56
- package/lib/date-types.js +51 -13
- package/lib/errors.js +4 -1
- package/lib/jsql_format.js +30 -21
- package/lib/mod.js +27 -6
- package/lib/mongo_server.js +143 -26
- package/lib/mysql_compat.js +57 -11
- package/lib/mysql_server.js +104 -4
- package/lib/native_client.js +30 -0
- package/lib/nedb_compat.js +9 -6
- package/lib/pg_server.js +4 -11
- package/lib/plugin.js +20 -2
- package/lib/prepared.js +10 -0
- package/lib/query.js +9 -2
- package/lib/redis_server.js +88 -16
- package/lib/sql.js +861 -82
- package/lib/sqlite_compat.js +2 -1
- package/lib/sqlite_worker.js +8 -2
- package/lib/table.js +68 -26
- package/lib/tui.js +1 -1
- package/lib/wasm_client.js +35 -0
- package/lib/web_ui.js +24 -8
- package/native/jsql-neo-native.node +0 -0
- package/package.json +10 -3
- package/test/alter-table.test.js +289 -0
- package/test/dml-constraints.test.js +41 -1
- package/test/identifier-keywords.test.js +140 -0
- package/test/index-ddl.test.js +203 -0
- package/test/mongo-group.test.js +105 -0
- package/test/null-semantics.test.js +126 -0
- package/test/recursive-cte.test.js +123 -0
- package/test/savepoint.test.js +388 -0
- package/test/sql-parser.test.js +30 -5
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 索引管理测试(CREATE INDEX / CREATE UNIQUE INDEX / DROP INDEX)。
|
|
3
|
+
*
|
|
4
|
+
* 此前 DROP INDEX 一律报「not supported」,根因是两层语义错配:
|
|
5
|
+
* - 标准 SQL 的 `DROP INDEX idx` 不带表名,而存储层 Table.dropIndex(field)
|
|
6
|
+
* 收的是**列名** —— 索引名被当列名传,必然找不到
|
|
7
|
+
* - CREATE INDEX 是逐列建索引(复合索引 a,b 各建一个),
|
|
8
|
+
* 但删除时只清首列,复合索引会残留一半
|
|
9
|
+
* 另外还修了 CREATE INDEX 顺带往 SELECT 输出里塞一个 `id` 列的问题。
|
|
10
|
+
*/
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const ROOT = path.join(__dirname, '..');
|
|
14
|
+
const { Database, JSQL, NativeJSQL } = require(path.join(ROOT, 'index.js'));
|
|
15
|
+
const { executeSQL } = require(path.join(ROOT, 'lib/sql.js'));
|
|
16
|
+
|
|
17
|
+
const OPTS = { safety: false };
|
|
18
|
+
let pass = 0, fail = 0;
|
|
19
|
+
|
|
20
|
+
function ok(name, cond, extra) {
|
|
21
|
+
if (cond) { pass++; console.log('[OK] ', name); }
|
|
22
|
+
else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 120) : ''); }
|
|
23
|
+
}
|
|
24
|
+
const rowsOf = r => (Array.isArray(r) ? r[0] : r).rows;
|
|
25
|
+
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
26
|
+
|
|
27
|
+
async function t(name, fn) {
|
|
28
|
+
const db = new Database(':memory:');
|
|
29
|
+
try {
|
|
30
|
+
const r = await fn({
|
|
31
|
+
exec: (sql) => executeSQL(db, sql, OPTS),
|
|
32
|
+
rows: (sql) => executeSQL(db, sql, OPTS).then(rowsOf),
|
|
33
|
+
tbl: () => db._ensureTable('t'),
|
|
34
|
+
db,
|
|
35
|
+
});
|
|
36
|
+
if (r === true) ok(name, true);
|
|
37
|
+
else ok(name, false, r);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
ok(name, false, String(e.message || e).slice(0, 110));
|
|
40
|
+
} finally {
|
|
41
|
+
try { await db.stop(); } catch (_) { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const idxFields = (t) => Object.keys(t._indexes || {}).sort();
|
|
45
|
+
|
|
46
|
+
(async () => {
|
|
47
|
+
console.log('\n--- CREATE INDEX ---');
|
|
48
|
+
await t('单列索引建后可查', async ({ exec, tbl, rows }) => {
|
|
49
|
+
await exec(`CREATE TABLE t (a INT, b TEXT)`);
|
|
50
|
+
await exec(`CREATE INDEX idx_a ON t(a)`);
|
|
51
|
+
if (!idxFields(tbl()).includes('a')) return { fields: idxFields(tbl()) };
|
|
52
|
+
return eq(await rows(`SELECT b FROM t WHERE a = 1`), []);
|
|
53
|
+
});
|
|
54
|
+
await t('复合索引逐列建', async ({ exec, tbl }) => {
|
|
55
|
+
await exec(`CREATE TABLE t (a INT, b TEXT, c INT)`);
|
|
56
|
+
await exec(`CREATE UNIQUE INDEX idx_ab ON t(a,b)`);
|
|
57
|
+
return eq(idxFields(tbl()), ['a', 'b']);
|
|
58
|
+
});
|
|
59
|
+
await t('多索引并存', async ({ exec, tbl }) => {
|
|
60
|
+
await exec(`CREATE TABLE t (a INT, b TEXT, c INT)`);
|
|
61
|
+
await exec(`CREATE INDEX i1 ON t(a)`);
|
|
62
|
+
await exec(`CREATE INDEX i2 ON t(b)`);
|
|
63
|
+
await exec(`CREATE INDEX i3 ON t(c)`);
|
|
64
|
+
return eq(idxFields(tbl()), ['a', 'b', 'c']);
|
|
65
|
+
});
|
|
66
|
+
await t('建索引不影响查询结果', async ({ exec, rows }) => {
|
|
67
|
+
await exec(`CREATE TABLE t (a INT, b TEXT)`);
|
|
68
|
+
await exec(`INSERT INTO t VALUES (1,'x'),(2,'y'),(3,'z')`);
|
|
69
|
+
const before = await rows(`SELECT a, b FROM t ORDER BY a`);
|
|
70
|
+
await exec(`CREATE INDEX idx_a ON t(a)`);
|
|
71
|
+
return eq(await rows(`SELECT a, b FROM t ORDER BY a`), before);
|
|
72
|
+
});
|
|
73
|
+
await t('建索引前后 SELECT * 列一致', async ({ exec, rows }) => {
|
|
74
|
+
// 注意:CREATE TABLE t (a INT, b TEXT) 本身会补一个自增主键 id,
|
|
75
|
+
// 所以基线是 ['id','a','b'],不是 ['a','b']。要验证的是「建索引不改变列集」。
|
|
76
|
+
await exec(`CREATE TABLE t (a INT, b TEXT)`);
|
|
77
|
+
await exec(`INSERT INTO t VALUES (1,'x')`);
|
|
78
|
+
const before = await rows(`SELECT * FROM t`);
|
|
79
|
+
await exec(`CREATE INDEX idx_a ON t(a)`);
|
|
80
|
+
return eq(await rows(`SELECT * FROM t`), before);
|
|
81
|
+
});
|
|
82
|
+
await t('索引对查询正确性无影响(重复值)', async ({ exec, rows }) => {
|
|
83
|
+
await exec(`CREATE TABLE t (a INT, b TEXT)`);
|
|
84
|
+
await exec(`INSERT INTO t VALUES (1,'x'),(1,'y'),(2,'z')`);
|
|
85
|
+
await exec(`CREATE INDEX idx_a ON t(a)`);
|
|
86
|
+
return eq(await rows(`SELECT b FROM t WHERE a = 1 ORDER BY b`), [['x'], ['y']]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
console.log('\n--- DROP INDEX ---');
|
|
90
|
+
await t('DROP INDEX 按索引名删(不带表名)', async ({ exec, tbl }) => {
|
|
91
|
+
await exec(`CREATE TABLE t (a INT, b TEXT)`);
|
|
92
|
+
await exec(`CREATE INDEX idx_a ON t(a)`);
|
|
93
|
+
await exec(`DROP INDEX idx_a`);
|
|
94
|
+
return eq(idxFields(tbl()), []);
|
|
95
|
+
});
|
|
96
|
+
await t('DROP INDEX 后查询仍正确', async ({ exec, rows, tbl }) => {
|
|
97
|
+
await exec(`CREATE TABLE t (a INT, b TEXT)`);
|
|
98
|
+
await exec(`INSERT INTO t VALUES (1,'x'),(2,'y')`);
|
|
99
|
+
await exec(`CREATE INDEX idx_a ON t(a)`);
|
|
100
|
+
await exec(`DROP INDEX idx_a`);
|
|
101
|
+
if (idxFields(tbl()).length) return { leftover: idxFields(tbl()) };
|
|
102
|
+
return eq(await rows(`SELECT b FROM t WHERE a = 2`), [['y']]);
|
|
103
|
+
});
|
|
104
|
+
await t('DROP INDEX 复合索引清掉全部列', async ({ exec, tbl }) => {
|
|
105
|
+
await exec(`CREATE TABLE t (a INT, b TEXT, c INT)`);
|
|
106
|
+
await exec(`CREATE UNIQUE INDEX idx_ab ON t(a,b)`);
|
|
107
|
+
await exec(`DROP INDEX idx_ab`);
|
|
108
|
+
// 此前只删首列,b 会残留
|
|
109
|
+
return eq(idxFields(tbl()), []);
|
|
110
|
+
});
|
|
111
|
+
await t('DROP INDEX 只删指定的,其余保留', async ({ exec, tbl }) => {
|
|
112
|
+
await exec(`CREATE TABLE t (a INT, b TEXT, c INT)`);
|
|
113
|
+
await exec(`CREATE INDEX i1 ON t(a)`);
|
|
114
|
+
await exec(`CREATE INDEX i2 ON t(b)`);
|
|
115
|
+
await exec(`CREATE INDEX i3 ON t(c)`);
|
|
116
|
+
await exec(`DROP INDEX i2`);
|
|
117
|
+
return eq(idxFields(tbl()), ['a', 'c']);
|
|
118
|
+
});
|
|
119
|
+
await t('DROP INDEX IF EXISTS 不存在的索引 → skipped', async ({ exec }) => {
|
|
120
|
+
await exec(`CREATE TABLE t (a INT)`);
|
|
121
|
+
const r = await exec(`DROP INDEX IF EXISTS nope`);
|
|
122
|
+
return r.skipped === true;
|
|
123
|
+
});
|
|
124
|
+
await t('DROP INDEX 不存在的索引 → 报错', async ({ exec }) => {
|
|
125
|
+
await exec(`CREATE TABLE t (a INT)`);
|
|
126
|
+
let threw = false;
|
|
127
|
+
try { await exec(`DROP INDEX nope`); } catch (_) { threw = true; }
|
|
128
|
+
return threw;
|
|
129
|
+
});
|
|
130
|
+
await t('DROP INDEX t.idx 形式', async ({ exec, tbl }) => {
|
|
131
|
+
await exec(`CREATE TABLE t (a INT)`);
|
|
132
|
+
await exec(`CREATE INDEX idx_a ON t(a)`);
|
|
133
|
+
await exec(`DROP INDEX t.idx_a`);
|
|
134
|
+
return eq(idxFields(tbl()), []);
|
|
135
|
+
});
|
|
136
|
+
await t('删除后索引可重建', async ({ exec, tbl }) => {
|
|
137
|
+
await exec(`CREATE TABLE t (a INT)`);
|
|
138
|
+
await exec(`CREATE INDEX i1 ON t(a)`);
|
|
139
|
+
await exec(`DROP INDEX i1`);
|
|
140
|
+
await exec(`CREATE INDEX i2 ON t(a)`);
|
|
141
|
+
return eq(idxFields(tbl()), ['a']);
|
|
142
|
+
});
|
|
143
|
+
await t('DROP TABLE 不受残留索引影响', async ({ exec, rows }) => {
|
|
144
|
+
await exec(`CREATE TABLE t (a INT)`);
|
|
145
|
+
await exec(`CREATE INDEX i1 ON t(a)`);
|
|
146
|
+
await exec(`DROP INDEX i1`);
|
|
147
|
+
await exec(`DROP TABLE t`);
|
|
148
|
+
let threw = false;
|
|
149
|
+
try { await exec(`SELECT * FROM t`); } catch (_) { threw = true; }
|
|
150
|
+
return threw;
|
|
151
|
+
});
|
|
152
|
+
await t('建索引不破坏已有数据', async ({ exec, rows }) => {
|
|
153
|
+
await exec(`CREATE TABLE t (a INT, b TEXT)`);
|
|
154
|
+
await exec(`INSERT INTO t VALUES (1,'x'),(2,'y'),(3,'z')`);
|
|
155
|
+
await exec(`CREATE INDEX idx_a ON t(a)`);
|
|
156
|
+
await exec(`DROP INDEX idx_a`);
|
|
157
|
+
return eq(await rows(`SELECT a, b FROM t ORDER BY a`), [[1, 'x'], [2, 'y'], [3, 'z']]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
console.log('\n--- 三个 engine 行为一致 ---');
|
|
161
|
+
// JSQL(wasm) / NativeJSQL 的索引由 Rust 侧二进制管理,JS 层看不到 _indexes,
|
|
162
|
+
// 也没有 createIndex/dropIndex 钩子 —— DDL 会明确报「不支持」而不是静默成功。
|
|
163
|
+
// 这里验证 Database(JS 引擎)的索引可建可删,并确认另外两个报的是明确的错。
|
|
164
|
+
{
|
|
165
|
+
const db = new Database(':memory:');
|
|
166
|
+
try {
|
|
167
|
+
await executeSQL(db, `CREATE TABLE t (a INT, b TEXT)`, OPTS);
|
|
168
|
+
await executeSQL(db, `CREATE INDEX idx_a ON t(a)`, OPTS);
|
|
169
|
+
const built = !!db._ensureTable('t')._indexes.a;
|
|
170
|
+
const r = await executeSQL(db, `DROP INDEX idx_a`, OPTS);
|
|
171
|
+
const left = Object.keys(db._ensureTable('t')._indexes).length;
|
|
172
|
+
ok('Database 建/删索引', built && r.ok === true && left === 0, { built, left });
|
|
173
|
+
} catch (e) {
|
|
174
|
+
ok('Database 建/删索引', false, String(e.message).slice(0, 80));
|
|
175
|
+
} finally { try { await db.stop(); } catch (_) { /* ignore */ } }
|
|
176
|
+
}
|
|
177
|
+
for (const [name, mk] of [
|
|
178
|
+
['JSQL', () => new JSQL({ mode: 'memory' })],
|
|
179
|
+
['NativeJSQL', () => new NativeJSQL({ mode: 'memory' })],
|
|
180
|
+
]) {
|
|
181
|
+
const db = await mk();
|
|
182
|
+
if (db.start) await db.start();
|
|
183
|
+
try {
|
|
184
|
+
await executeSQL(db, `CREATE TABLE t (a INT)`, OPTS);
|
|
185
|
+
let msg = '', failed = false;
|
|
186
|
+
try { await executeSQL(db, `CREATE INDEX i ON t(a)`, OPTS); } catch (e) { failed = true; msg = String(e.message); }
|
|
187
|
+
// 要么真支持(CREATE 成功且 DROP 也成功),要么给明确的「不支持」——
|
|
188
|
+
// 最糟的是 CREATE 静默成功让人以为索引生效了。
|
|
189
|
+
if (failed) {
|
|
190
|
+
ok(`${name} 索引 DDL 明确报不支持`, /not supported/i.test(msg), msg.slice(0, 60));
|
|
191
|
+
} else {
|
|
192
|
+
let dropOk = false, dropMsg = '';
|
|
193
|
+
try { await executeSQL(db, `DROP INDEX i`, OPTS); dropOk = true; } catch (e) { dropMsg = String(e.message); }
|
|
194
|
+
ok(`${name} 索引 DDL 成对可用`, dropOk, dropMsg.slice(0, 60));
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
ok(`${name} 索引 DDL`, false, String(e.message).slice(0, 80));
|
|
198
|
+
} finally { try { await db.stop(); } catch (_) { /* ignore */ } }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
202
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
203
|
+
})();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Test: MongoDB $group aggregation operators
|
|
2
|
+
// Directly tests the _dispatch method of createMongoServer
|
|
3
|
+
const { createMongoServer } = require('../lib/mongo_server.js');
|
|
4
|
+
|
|
5
|
+
let pass = 0, fail = 0;
|
|
6
|
+
const ok = (name, cond, extra) => {
|
|
7
|
+
if (cond) { pass++; console.log('[OK]', name); }
|
|
8
|
+
else { fail++; console.log('[FAIL]', name, extra !== undefined ? '→ ' + JSON.stringify(extra) : ''); }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Build a test server with a pre-populated collection
|
|
12
|
+
const srv = createMongoServer({ port: 0, noAuth: true, dataDir: ':memory:' });
|
|
13
|
+
srv.listen(() => {
|
|
14
|
+
(async () => {
|
|
15
|
+
// Get the engine and pre-populate
|
|
16
|
+
const engine = await srv._getEngine();
|
|
17
|
+
engine.createTable('sales', { schema: { _id: { type: 'integer' }, item: { type: 'string' }, qty: { type: 'integer' }, price: { type: 'integer' } } });
|
|
18
|
+
const table = engine._ensureTable('sales');
|
|
19
|
+
table._rows = [
|
|
20
|
+
{ _id: 1, item: 'A', qty: 10, price: 5 },
|
|
21
|
+
{ _id: 2, item: 'B', qty: 5, price: 10 },
|
|
22
|
+
{ _id: 3, item: 'A', qty: 3, price: 5 },
|
|
23
|
+
{ _id: 4, item: 'B', qty: 7, price: 10 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// --- Test 1: $group with $sum on field (the bug we're fixing) ---
|
|
27
|
+
let r = await srv._dispatch('aggregate', {
|
|
28
|
+
aggregate: 'sales',
|
|
29
|
+
pipeline: [{ '$group': { _id: '$item', total: { '$sum': '$qty' } } }],
|
|
30
|
+
'$db': 'test'
|
|
31
|
+
});
|
|
32
|
+
const batch1 = r.cursor ? r.cursor.firstBatch : [];
|
|
33
|
+
const byItem = {};
|
|
34
|
+
for (const row of batch1) byItem[row._id] = row.total;
|
|
35
|
+
ok('$group $sum on field (item:A)', byItem['A'] === 13, byItem);
|
|
36
|
+
ok('$group $sum on field (item:B)', byItem['B'] === 12, byItem);
|
|
37
|
+
|
|
38
|
+
// --- Test 2: $group with $sum:1 (count) ---
|
|
39
|
+
r = await srv._dispatch('aggregate', {
|
|
40
|
+
aggregate: 'sales',
|
|
41
|
+
pipeline: [{ '$group': { _id: '$item', count: { '$sum': 1 } } }],
|
|
42
|
+
'$db': 'test'
|
|
43
|
+
});
|
|
44
|
+
const batch2 = r.cursor ? r.cursor.firstBatch : [];
|
|
45
|
+
const byCount = {};
|
|
46
|
+
for (const row of batch2) byCount[row._id] = row.count;
|
|
47
|
+
ok('$group $sum:1 count (item:A)', byCount['A'] === 2, byCount);
|
|
48
|
+
ok('$group $sum:1 count (item:B)', byCount['B'] === 2, byCount);
|
|
49
|
+
|
|
50
|
+
// --- Test 3: $group with $avg ---
|
|
51
|
+
r = await srv._dispatch('aggregate', {
|
|
52
|
+
aggregate: 'sales',
|
|
53
|
+
pipeline: [{ '$group': { _id: '$item', avgQty: { '$avg': '$qty' } } }],
|
|
54
|
+
'$db': 'test'
|
|
55
|
+
});
|
|
56
|
+
const batch3 = r.cursor ? r.cursor.firstBatch : [];
|
|
57
|
+
const byAvg = {};
|
|
58
|
+
for (const row of batch3) byAvg[row._id] = row.avgQty;
|
|
59
|
+
ok('$group $avg on field (item:A)', byAvg['A'] === 6.5, byAvg);
|
|
60
|
+
ok('$group $avg on field (item:B)', byAvg['B'] === 6, byAvg);
|
|
61
|
+
|
|
62
|
+
// --- Test 4: $group with $min / $max ---
|
|
63
|
+
r = await srv._dispatch('aggregate', {
|
|
64
|
+
aggregate: 'sales',
|
|
65
|
+
pipeline: [{ '$group': { _id: '$item', minQty: { '$min': '$qty' }, maxQty: { '$max': '$qty' } } }],
|
|
66
|
+
'$db': 'test'
|
|
67
|
+
});
|
|
68
|
+
const batch4 = r.cursor ? r.cursor.firstBatch : [];
|
|
69
|
+
const byRange = {};
|
|
70
|
+
for (const row of batch4) byRange[row._id] = { min: row.minQty, max: row.maxQty };
|
|
71
|
+
ok('$group $min (item:A)', byRange['A'] && byRange['A'].min === 3, byRange);
|
|
72
|
+
ok('$group $min (item:B)', byRange['B'] && byRange['B'].min === 5, byRange);
|
|
73
|
+
ok('$group $max (item:A)', byRange['A'] && byRange['A'].max === 10, byRange);
|
|
74
|
+
ok('$group $max (item:B)', byRange['B'] && byRange['B'].max === 7, byRange);
|
|
75
|
+
|
|
76
|
+
// --- Test 5: existing $sum:1 still works ---
|
|
77
|
+
r = await srv._dispatch('aggregate', {
|
|
78
|
+
aggregate: 'sales',
|
|
79
|
+
pipeline: [{ '$group': { _id: '$item', cnt: { '$sum': 1 } } }],
|
|
80
|
+
'$db': 'test'
|
|
81
|
+
});
|
|
82
|
+
const batch5 = r.cursor ? r.cursor.firstBatch : [];
|
|
83
|
+
const byCnt = {};
|
|
84
|
+
for (const row of batch5) byCnt[row._id] = row.cnt;
|
|
85
|
+
ok('$group $sum:1 (count) still works', byCnt['A'] === 2 && byCnt['B'] === 2, byCnt);
|
|
86
|
+
|
|
87
|
+
// --- Test 6: $group with $sum on a different field ---
|
|
88
|
+
r = await srv._dispatch('aggregate', {
|
|
89
|
+
aggregate: 'sales',
|
|
90
|
+
pipeline: [{ '$group': { _id: '$item', totalPrice: { '$sum': '$price' } } }],
|
|
91
|
+
'$db': 'test'
|
|
92
|
+
});
|
|
93
|
+
const batch6 = r.cursor ? r.cursor.firstBatch : [];
|
|
94
|
+
const byPrice = {};
|
|
95
|
+
for (const row of batch6) byPrice[row._id] = row.totalPrice;
|
|
96
|
+
ok('$group $sum on different field (price)', byPrice['A'] === 10 && byPrice['B'] === 20, byPrice);
|
|
97
|
+
|
|
98
|
+
srv.close();
|
|
99
|
+
console.log(fail === 0 ? '\nALL TESTS PASSED' : `\n${fail} FAILURES`);
|
|
100
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
101
|
+
})().catch(e => {
|
|
102
|
+
console.error('FATAL:', e.message, e.stack);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 标量函数的 NULL 语义测试(COALESCE / GREATEST / LEAST / CONCAT / CONCAT_WS)。
|
|
3
|
+
*
|
|
4
|
+
* 这一批全是「静默算错」——不抛异常、不影响其他测试,只有拿标准答案逐条对照才暴露。
|
|
5
|
+
*
|
|
6
|
+
* 三处偏差:
|
|
7
|
+
* 1. COALESCE 全部取不到值时返回 undefined。而 undefined 在结果行里会
|
|
8
|
+
* 让整个键消失(JSON.stringify 直接丢掉),等于这一列凭空不见。
|
|
9
|
+
* IFNULL 返回的是 null,两者不一致。
|
|
10
|
+
* 2. GREATEST / LEAST 直接拿 JS 的 > / < 比较,而 JS 里 null 会强转成 0 ——
|
|
11
|
+
* GREATEST(NULL,1) 得 1、LEAST(NULL,1) 得 null,这一对函数自相矛盾。
|
|
12
|
+
* 3. CONCAT 把 NULL 当成空串。而本引擎其余所有标量函数(UPPER / LENGTH /
|
|
13
|
+
* TRIM / ABS / SUBSTRING / REPLACE ...)遇到 NULL 一律返回 NULL,
|
|
14
|
+
* CONCAT 是唯一的例外;MySQL 也是返回 NULL。
|
|
15
|
+
* 把 NULL 当空串会拼出一个「看起来正常」的错值:
|
|
16
|
+
* CONCAT(first,' ',last) 里 last 为 NULL 时得到 "John " 而不是 NULL,
|
|
17
|
+
* 缺失的字段就此被掩盖。
|
|
18
|
+
* 4. CONCAT_WS 的分隔符为 NULL 时会悄悄退回逗号(MySQL 返回 NULL)。
|
|
19
|
+
* CONCAT_WS 本身跳过 NULL 参数是对的,这个行为要保住。
|
|
20
|
+
*/
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const ROOT = path.join(__dirname, '..');
|
|
24
|
+
const { Database } = require(path.join(ROOT, 'index.js'));
|
|
25
|
+
const { executeSQL } = require(path.join(ROOT, 'lib/sql.js'));
|
|
26
|
+
|
|
27
|
+
const OPTS = { safety: false };
|
|
28
|
+
let pass = 0, fail = 0;
|
|
29
|
+
|
|
30
|
+
function ok(name, cond, extra) {
|
|
31
|
+
if (cond) { pass++; console.log('[OK] ', name); }
|
|
32
|
+
else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 140) : ''); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
(async () => {
|
|
36
|
+
const db = new Database(':memory:');
|
|
37
|
+
const q = (sql) => executeSQL(db, sql, OPTS);
|
|
38
|
+
/** 取第一行第一列,并如实区分 null / undefined —— 这正是本组的重点 */
|
|
39
|
+
const one = async (sql) => {
|
|
40
|
+
const r = await q(sql);
|
|
41
|
+
return r.rows[0][0];
|
|
42
|
+
};
|
|
43
|
+
const isNull = async (sql) => (await one(sql)) === null;
|
|
44
|
+
|
|
45
|
+
console.log('\n--- COALESCE ---');
|
|
46
|
+
ok('COALESCE(NULL, NULL) 是 null 而不是 undefined', await isNull('SELECT COALESCE(NULL, NULL)'),
|
|
47
|
+
await one('SELECT COALESCE(NULL, NULL)'));
|
|
48
|
+
ok('COALESCE 返回 null 时结果行里键仍在(不被 JSON 丢掉)',
|
|
49
|
+
JSON.stringify((await q('SELECT COALESCE(NULL, NULL) AS c')).rows) === '[[null]]',
|
|
50
|
+
(await q('SELECT COALESCE(NULL, NULL) AS c')).rows);
|
|
51
|
+
ok('COALESCE(NULL, 1)', await one('SELECT COALESCE(NULL, 1)') === 1);
|
|
52
|
+
ok('COALESCE(1, NULL)', await one('SELECT COALESCE(1, NULL)') === 1);
|
|
53
|
+
ok('COALESCE 取第一个非 NULL', await one('SELECT COALESCE(NULL, NULL, 3, 4)') === 3);
|
|
54
|
+
ok('COALESCE 与 IFNULL 对全 NULL 的处理一致',
|
|
55
|
+
(await isNull('SELECT COALESCE(NULL, NULL)')) && (await isNull('SELECT IFNULL(NULL, NULL)')));
|
|
56
|
+
ok('COALESCE 传 0 不会跳过(0 不是 NULL)', await one('SELECT COALESCE(0, 9)') === 0);
|
|
57
|
+
ok('COALESCE 传空串不会跳过', await one("SELECT COALESCE('', 'x')") === '');
|
|
58
|
+
// 引擎按 MySQL 语义把布尔字面量归一成 0/1,而不是 JS 的 false/true
|
|
59
|
+
ok('COALESCE 传 FALSE 不会跳过(返回 0,不是 NULL)', await one('SELECT COALESCE(FALSE, 9)') === 0);
|
|
60
|
+
|
|
61
|
+
console.log('\n--- GREATEST / LEAST ---');
|
|
62
|
+
ok('GREATEST(NULL, 1) 返回 NULL', await isNull('SELECT GREATEST(NULL, 1)'));
|
|
63
|
+
ok('LEAST(NULL, 1) 返回 NULL', await isNull('SELECT LEAST(NULL, 1)'));
|
|
64
|
+
ok('两者对 NULL 的处理一致(此前 GREATEST 得 1、LEAST 得 null)',
|
|
65
|
+
(await isNull('SELECT GREATEST(NULL, 1)')) === (await isNull('SELECT LEAST(NULL, 1)')));
|
|
66
|
+
ok('GREATEST(NULL, NULL)', await isNull('SELECT GREATEST(NULL, NULL)'));
|
|
67
|
+
ok('LEAST(NULL, NULL)', await isNull('SELECT LEAST(NULL, NULL)'));
|
|
68
|
+
ok('GREATEST 正常取最大', await one('SELECT GREATEST(3, 1, 2)') === 3);
|
|
69
|
+
ok('LEAST 正常取最小', await one('SELECT LEAST(3, 1, 2)') === 1);
|
|
70
|
+
ok('GREATEST 单参数', await one('SELECT GREATEST(5)') === 5);
|
|
71
|
+
ok('LEAST 单参数', await one('SELECT LEAST(5)') === 5);
|
|
72
|
+
ok('GREATEST 带负数', await one('SELECT GREATEST(-5, -10)') === -5);
|
|
73
|
+
ok('LEAST 带负数', await one('SELECT LEAST(-5, -10)') === -10);
|
|
74
|
+
ok('GREATEST 不把 0 当 NULL', await one('SELECT GREATEST(0, -1)') === 0);
|
|
75
|
+
|
|
76
|
+
console.log('\n--- CONCAT ---');
|
|
77
|
+
ok('CONCAT 遇到 NULL 返回 NULL', await isNull("SELECT CONCAT('a', NULL, 'b')"));
|
|
78
|
+
ok('NULL 在首位也返回 NULL', await isNull("SELECT CONCAT(NULL, 'a')"));
|
|
79
|
+
ok('NULL 在末位也返回 NULL', await isNull("SELECT CONCAT('a', NULL)"));
|
|
80
|
+
ok('全 NULL 返回 NULL', await isNull('SELECT CONCAT(NULL, NULL)'));
|
|
81
|
+
ok('正常拼接不受影响', await one("SELECT CONCAT('a', 'b', 'c')") === 'abc');
|
|
82
|
+
ok('数字会转成字符串', await one("SELECT CONCAT('n=', 42)") === 'n=42');
|
|
83
|
+
ok('空串不是 NULL', await one("SELECT CONCAT('a', '', 'b')") === 'ab');
|
|
84
|
+
ok('0 不是 NULL', await one("SELECT CONCAT('a', 0)") === 'a0');
|
|
85
|
+
ok('CONCAT 与其余标量函数的 NULL 行为一致',
|
|
86
|
+
(await isNull("SELECT CONCAT('a', NULL)")) && (await isNull('SELECT UPPER(NULL)')));
|
|
87
|
+
|
|
88
|
+
console.log('\n--- CONCAT_WS ---');
|
|
89
|
+
ok('CONCAT_WS 跳过 NULL 参数(与 CONCAT 相反)',
|
|
90
|
+
await one("SELECT CONCAT_WS('-', 'a', NULL, 'b')") === 'a-b');
|
|
91
|
+
ok('CONCAT_WS 全 NULL 参数给空串',
|
|
92
|
+
await one("SELECT CONCAT_WS('-', NULL, NULL)") === '');
|
|
93
|
+
ok('CONCAT_WS 分隔符为 NULL → 返回 NULL(此前悄悄退回逗号)',
|
|
94
|
+
await isNull("SELECT CONCAT_WS(NULL, 'a', 'b')"));
|
|
95
|
+
ok('CONCAT_WS 正常分隔', await one("SELECT CONCAT_WS(',', 'a', 'b', 'c')") === 'a,b,c');
|
|
96
|
+
ok('CONCAT_WS 单参数即原值', await one("SELECT CONCAT_WS('-', 'a')") === 'a');
|
|
97
|
+
ok('CONCAT_WS 空串分隔符', await one("SELECT CONCAT_WS('', 'a', 'b')") === 'ab');
|
|
98
|
+
|
|
99
|
+
console.log('\n--- 与其余标量函数的一致性(回归护栏)---');
|
|
100
|
+
const nullPropagating = [
|
|
101
|
+
'UPPER(NULL)', 'LOWER(NULL)', 'LENGTH(NULL)', 'TRIM(NULL)', 'ABS(NULL)',
|
|
102
|
+
'ROUND(NULL, 1)', 'SUBSTRING(NULL, 1, 1)', "REPLACE(NULL, 'a', 'b')",
|
|
103
|
+
"INSTR(NULL, 'a')", 'SQRT(NULL)', 'CONCAT(NULL)',
|
|
104
|
+
];
|
|
105
|
+
for (const expr of nullPropagating) {
|
|
106
|
+
ok(`${expr} 传播 NULL`, await isNull(`SELECT ${expr}`));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log('\n--- 表数据上验证 ---');
|
|
110
|
+
{
|
|
111
|
+
await q('CREATE TABLE people (id INT PRIMARY KEY, first TEXT, last TEXT)');
|
|
112
|
+
await q("INSERT INTO people VALUES (1,'John','Doe'),(2,'Jane',NULL)");
|
|
113
|
+
const rows = (await q('SELECT id, CONCAT(first, " ", last) AS full_name FROM people ORDER BY id')).rows;
|
|
114
|
+
ok('拼接缺字段的行得到 NULL 而不是 "Jane "',
|
|
115
|
+
JSON.stringify(rows) === '[[1,"John Doe"],[2,null]]', rows);
|
|
116
|
+
|
|
117
|
+
const co = (await q("SELECT COALESCE(last, first) AS v FROM people ORDER BY id")).rows;
|
|
118
|
+
ok('COALESCE 在表数据上正常兜底', JSON.stringify(co) === '[["Doe"],["Jane"]]', co);
|
|
119
|
+
|
|
120
|
+
const mx = (await q('SELECT GREATEST(id, 1) AS g, LEAST(id, 1) AS l FROM people ORDER BY id')).rows;
|
|
121
|
+
ok('GREATEST / LEAST 在表数据上正常', JSON.stringify(mx) === '[[1,1],[2,1]]', mx);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
125
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
126
|
+
})().catch(e => { console.error('💥', e); process.exit(1); });
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 递归 CTE(WITH RECURSIVE)测试。
|
|
3
|
+
*
|
|
4
|
+
* 此前解析层能识别(recursive:true、UNION ALL 结构都对),
|
|
5
|
+
* 但执行时把 CTE 的自身引用内联成子查询,导致报 no such column;
|
|
6
|
+
* 修好后又暴露三个问题,逐个都曾让结果错误:
|
|
7
|
+
* 1. 虚拟行被加了表名前缀 → 递归项里的 `n+1` 取不到值
|
|
8
|
+
* 2. 每轮把全部累积行喂回递归项 → 行数按轮递增(1..5 得到 1,2,2,3,2,3,…)
|
|
9
|
+
* 3. 只限制迭代次数不够,行数翻倍会先打爆内存(实测 OOM)
|
|
10
|
+
*/
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const ROOT = path.join(__dirname, '..');
|
|
14
|
+
const { Database, JSQL, NativeJSQL } = require(path.join(ROOT, 'index.js'));
|
|
15
|
+
const { executeSQL } = require(path.join(ROOT, 'lib/sql.js'));
|
|
16
|
+
const { parseSQL } = require(path.join(ROOT, 'lib/sql.js'));
|
|
17
|
+
|
|
18
|
+
const OPTS = { safety: false };
|
|
19
|
+
let pass = 0, fail = 0;
|
|
20
|
+
|
|
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).slice(0, 120) : ''); }
|
|
24
|
+
}
|
|
25
|
+
const rowsOf = r => (Array.isArray(r) ? r[0] : r).rows;
|
|
26
|
+
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
27
|
+
|
|
28
|
+
/** 每个用例独立 :memory: 库,避免状态污染 */
|
|
29
|
+
async function t(name, sql, expect, engineName) {
|
|
30
|
+
const db = engineName === 'JSQL' ? new JSQL({ mode: 'memory' })
|
|
31
|
+
: engineName === 'NativeJSQL' ? new NativeJSQL({ mode: 'memory' })
|
|
32
|
+
: new Database(':memory:');
|
|
33
|
+
try {
|
|
34
|
+
if (db.start) await db.start();
|
|
35
|
+
const got = rowsOf(await executeSQL(db, sql, OPTS));
|
|
36
|
+
if (eq(got, expect)) ok(name, true);
|
|
37
|
+
else ok(name, false, { got, expect });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
ok(name, false, String(e.message || e).slice(0, 100));
|
|
40
|
+
} finally {
|
|
41
|
+
try { await db.stop(); } catch (_) { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
(async () => {
|
|
46
|
+
console.log('\n--- 解析 ---');
|
|
47
|
+
{
|
|
48
|
+
const a = parseSQL('WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n<5) SELECT * FROM c');
|
|
49
|
+
ok('识别 RECURSIVE', a.recursive === true, a.recursive);
|
|
50
|
+
ok('记录显式列名', JSON.stringify(a.ctes[0].columns) === '["n"]', a.ctes[0].columns);
|
|
51
|
+
ok('记录 UNION ALL', a.ctes[0].select.union && a.ctes[0].select.union.all === true);
|
|
52
|
+
// RECURSIVE 放在第一个 CTE 之后也接受
|
|
53
|
+
const b = parseSQL('WITH k AS (SELECT 1 AS m), RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n<3) SELECT * FROM c');
|
|
54
|
+
ok('CTE 之间的 RECURSIVE 也能解析', b.ctes.length === 2, b.ctes.length);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log('\n--- 基本递归 ---');
|
|
58
|
+
await t('计数 1..5', 'WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n<5) SELECT * FROM c',
|
|
59
|
+
[[1], [2], [3], [4], [5]]);
|
|
60
|
+
await t('1..10 求和 = 55', 'WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n<10) SELECT SUM(n) AS s FROM c',
|
|
61
|
+
[[55]]);
|
|
62
|
+
await t('倒计数 3..1', 'WITH RECURSIVE c(n) AS (SELECT 3 UNION ALL SELECT n-1 FROM c WHERE n>1) SELECT * FROM c',
|
|
63
|
+
[[3], [2], [1]]);
|
|
64
|
+
await t('显式列名 + 外层过滤',
|
|
65
|
+
'WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c WHERE x<3) SELECT * FROM c WHERE x>1',
|
|
66
|
+
[[2], [3]]);
|
|
67
|
+
await t('UNION(去重)终止',
|
|
68
|
+
'WITH RECURSIVE c(n) AS (SELECT 1 UNION SELECT 1 FROM c WHERE n<3) SELECT * FROM c',
|
|
69
|
+
[[1]]);
|
|
70
|
+
await t('多列递归',
|
|
71
|
+
'WITH RECURSIVE c(a,b) AS (SELECT 1,1 UNION ALL SELECT a+1,b+a FROM c WHERE a<4) SELECT * FROM c',
|
|
72
|
+
[[1, 1], [2, 2], [3, 4], [4, 7]]);
|
|
73
|
+
await t('外层聚合递归结果',
|
|
74
|
+
'WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n<4) SELECT COUNT(*) AS k, MAX(n) AS mx FROM c',
|
|
75
|
+
[[4, 4]]);
|
|
76
|
+
await t('多锚点分支(UNION 链 > 2 节)',
|
|
77
|
+
'WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT n+1 FROM c WHERE n<3) SELECT * FROM c',
|
|
78
|
+
[[1], [2], [2], [3], [3]]);
|
|
79
|
+
await t('日期序列',
|
|
80
|
+
`WITH RECURSIVE d(x) AS (SELECT DATE('2026-01-01') UNION ALL SELECT DATE_ADD(x, INTERVAL 1 DAY) FROM d WHERE x < DATE('2026-01-04')) SELECT * FROM d`,
|
|
81
|
+
[['2026-01-01'], ['2026-01-02'], ['2026-01-03'], ['2026-01-04']]);
|
|
82
|
+
await t('递归 CTE 与普通 JOIN',
|
|
83
|
+
'WITH c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n<3) SELECT c1.n, c2.n FROM c c1, c c2 WHERE c2.n = c1.n + 1 ORDER BY c1.n',
|
|
84
|
+
[[1, 2], [2, 3]]);
|
|
85
|
+
await t('递归 CTE 可被外层引用多次',
|
|
86
|
+
'WITH c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n<3) SELECT (SELECT COUNT(*) FROM c) AS a, (SELECT MAX(n) FROM c) AS b',
|
|
87
|
+
[[3, 3]]);
|
|
88
|
+
|
|
89
|
+
console.log('\n--- 安全性:无限递归必须有兜底 ---');
|
|
90
|
+
{
|
|
91
|
+
const db = new Database(':memory:');
|
|
92
|
+
const t0 = Date.now();
|
|
93
|
+
let got = null, err = null;
|
|
94
|
+
try {
|
|
95
|
+
// WHERE 恒真 → 无限增长
|
|
96
|
+
got = rowsOf(await executeSQL(db, 'WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c) SELECT COUNT(*) AS k FROM c', OPTS));
|
|
97
|
+
} catch (e) { err = e; }
|
|
98
|
+
ok('无限递归不 OOM、不挂死(有限时间内返回)', err !== null || (got && got[0][0] > 0), err ? String(err.message).slice(0, 60) : got);
|
|
99
|
+
ok('兜底在 30s 内返回', Date.now() - t0 < 30000, ((Date.now() - t0) / 1000).toFixed(1) + 's');
|
|
100
|
+
await db.stop();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log('\n--- 非递归 CTE 回归对照 ---');
|
|
104
|
+
await t('普通 CTE 不受影响', 'WITH x AS (SELECT 1 AS n) SELECT * FROM x', [[1]]);
|
|
105
|
+
await t('普通 CTE 引用前一个',
|
|
106
|
+
'WITH a AS (SELECT 1 AS m), b AS (SELECT m+1 AS n FROM a) SELECT * FROM b', [[2]]);
|
|
107
|
+
await t('普通 CTE 显式列名',
|
|
108
|
+
'WITH x(q) AS (SELECT 5) SELECT q FROM x', [[5]]);
|
|
109
|
+
await t('非递归 UNION ALL 正常',
|
|
110
|
+
'SELECT 1 AS n UNION ALL SELECT 2', [[1], [2]]);
|
|
111
|
+
await t('UNION 去重正常', 'SELECT 1 AS n UNION SELECT 1', [[1]]);
|
|
112
|
+
await t('INTERSECT / EXCEPT 正常',
|
|
113
|
+
'SELECT 1 AS n UNION SELECT 2 INTERSECT SELECT 1', [[1]]);
|
|
114
|
+
|
|
115
|
+
console.log('\n--- 三个 engine 一致性 ---');
|
|
116
|
+
for (const eng of ['JSQL', 'NativeJSQL', 'Database']) {
|
|
117
|
+
await t(`${eng} 递归 CTE`, 'WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n<4) SELECT * FROM c',
|
|
118
|
+
[[1], [2], [3], [4]], eng);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
122
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
123
|
+
})();
|