jsql-neo 6.3.6 → 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/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 +856 -81
- 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/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/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,140 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 非保留关键字当标识符测试。
|
|
3
|
+
*
|
|
4
|
+
* 问题:词法层的 KEYWORDS 表把一些「MySQL 里其实是非保留」的词也标成了 keyword,
|
|
5
|
+
* 而 parseTableName / parseColumnRef 只认 ident,于是这些最普通的写法被拒:
|
|
6
|
+
* CREATE TABLE t (date DATE, text TEXT, count INT)
|
|
7
|
+
* SELECT date, count FROM t
|
|
8
|
+
* 实测 64 个常见列名里有 31 个被拒,其中 date / text / count / engine /
|
|
9
|
+
* first / after / unsigned / zerofill / spatial / ignore / start
|
|
10
|
+
* 在 MySQL 里都不是保留字 —— 用户没有理由必须加反引号。
|
|
11
|
+
*
|
|
12
|
+
* 修法:不让词法层去区分(parseColumnDef 还依赖 type === 'keyword' 来认类型),
|
|
13
|
+
* 改在「期望一个名字」的位置放行 NON_RESERVED_KEYWORDS 里的词。
|
|
14
|
+
* 这与文件里 OVER / PARTITION 那条注释是同一个思路。
|
|
15
|
+
*
|
|
16
|
+
* 真正的保留字(TABLE / SELECT / KEY / ORDER / INDEX / GROUP / CASE ...)
|
|
17
|
+
* 仍然要求反引号 —— 这些用例用来防止放行范围被不小心扩大。
|
|
18
|
+
*/
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const ROOT = path.join(__dirname, '..');
|
|
22
|
+
const { Database } = require(path.join(ROOT, 'index.js'));
|
|
23
|
+
const { executeSQL } = require(path.join(ROOT, 'lib/sql.js'));
|
|
24
|
+
|
|
25
|
+
const OPTS = { safety: false };
|
|
26
|
+
let pass = 0, fail = 0;
|
|
27
|
+
|
|
28
|
+
function ok(name, cond, extra) {
|
|
29
|
+
if (cond) { pass++; console.log('[OK] ', name); }
|
|
30
|
+
else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 140) : ''); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
(async () => {
|
|
34
|
+
const db = new Database(':memory:');
|
|
35
|
+
const q = (sql) => executeSQL(db, sql, OPTS);
|
|
36
|
+
const rows = async (sql) => (await q(sql)).rows;
|
|
37
|
+
const okSql = async (sql) => { try { await q(sql); return true; } catch (_) { return false; } };
|
|
38
|
+
|
|
39
|
+
console.log('\n--- 非保留关键字可以当列名 ---');
|
|
40
|
+
const asColumn = ['first', 'after', 'date', 'time', 'datetime', 'timestamp', 'text',
|
|
41
|
+
'engine', 'ignore', 'spatial', 'unsigned', 'zerofill', 'start', 'count',
|
|
42
|
+
'number', 'value', 'type', 'state', 'level', 'source', 'data', 'total'];
|
|
43
|
+
for (const n of asColumn) {
|
|
44
|
+
ok(`列名 ${n}`, await okSql(`CREATE TABLE c_${n} (id INT PRIMARY KEY, ${n} TEXT)`));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log('\n--- 非保留关键字可以当表名 ---');
|
|
48
|
+
for (const n of ['date', 'text', 'count', 'engine', 'first']) {
|
|
49
|
+
ok(`表名 ${n}`, await okSql(`CREATE TABLE ${n} (id INT PRIMARY KEY)`));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log('\n--- 建表后能正常读写 ---');
|
|
53
|
+
{
|
|
54
|
+
await q('CREATE TABLE events (id INT PRIMARY KEY, date TEXT, text TEXT, count INT, engine TEXT)');
|
|
55
|
+
await q("INSERT INTO events VALUES (1, '2026-01-01', 'hello', 3, 'innodb')");
|
|
56
|
+
const r = await rows('SELECT id, date, text, count, engine FROM events');
|
|
57
|
+
ok('按这些列名查询正常', JSON.stringify(r) === '[[1,"2026-01-01","hello",3,"innodb"]]', r);
|
|
58
|
+
|
|
59
|
+
const w = await rows("SELECT id FROM events WHERE date = '2026-01-01' AND count = 3");
|
|
60
|
+
ok('按这些列名过滤正常', JSON.stringify(w) === '[[1]]', w);
|
|
61
|
+
|
|
62
|
+
await q('UPDATE events SET count = 4 WHERE id = 1');
|
|
63
|
+
ok('按这些列名更新正常', (await rows('SELECT count FROM events'))[0][0] === 4);
|
|
64
|
+
|
|
65
|
+
await q('CREATE INDEX idx_date ON events (date)');
|
|
66
|
+
ok('按这些列名建索引正常', (await rows("SELECT id FROM events WHERE date = '2026-01-01'"))[0][0] === 1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log('\n--- ALTER TABLE 上也适用 ---');
|
|
70
|
+
{
|
|
71
|
+
await q('CREATE TABLE a1 (id INT PRIMARY KEY)');
|
|
72
|
+
await q('ALTER TABLE a1 ADD COLUMN date TEXT');
|
|
73
|
+
await q('ALTER TABLE a1 ADD COLUMN count INT');
|
|
74
|
+
ok('ADD COLUMN 用非保留关键字', (await q('SELECT * FROM a1')).columns.join(',') === 'id,date,count');
|
|
75
|
+
|
|
76
|
+
await q('ALTER TABLE a1 RENAME COLUMN date TO time');
|
|
77
|
+
ok('RENAME COLUMN 用非保留关键字', (await q('SELECT * FROM a1')).columns.join(',') === 'id,time,count');
|
|
78
|
+
|
|
79
|
+
await q('ALTER TABLE a1 DROP COLUMN time');
|
|
80
|
+
ok('DROP COLUMN 用非保留关键字', (await q('SELECT * FROM a1')).columns.join(',') === 'id,count');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log('\n--- 主键 / 唯一约束里也能用 ---');
|
|
84
|
+
{
|
|
85
|
+
await q('CREATE TABLE k1 (date TEXT PRIMARY KEY, count INT UNIQUE)');
|
|
86
|
+
await q("INSERT INTO k1 VALUES ('a', 1)");
|
|
87
|
+
let threw = false;
|
|
88
|
+
try { await q("INSERT INTO k1 VALUES ('a', 2)"); } catch (_) { threw = true; }
|
|
89
|
+
ok('非保留关键字作主键仍受约束', threw);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('\n--- ORDER BY / GROUP BY / 别名 不受影响 ---');
|
|
93
|
+
{
|
|
94
|
+
await q('CREATE TABLE s1 (id INT PRIMARY KEY, count INT, date TEXT)');
|
|
95
|
+
await q("INSERT INTO s1 VALUES (1,1,'a'),(2,2,'b'),(3,1,'c')");
|
|
96
|
+
const ob = await rows('SELECT count FROM s1 ORDER BY count DESC, id');
|
|
97
|
+
ok('ORDER BY 用这些列名', JSON.stringify(ob) === '[[2],[1],[1]]', ob);
|
|
98
|
+
const gb = await rows('SELECT count, COUNT(*) AS n FROM s1 GROUP BY count ORDER BY count');
|
|
99
|
+
ok('GROUP BY 用这些列名', JSON.stringify(gb) === '[[1,2],[2,1]]', gb);
|
|
100
|
+
const al = await rows('SELECT count AS total FROM s1 WHERE id = 2');
|
|
101
|
+
ok('AS 别名不受影响', JSON.stringify(al) === '[[2]]', al);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log('\n--- 函数名仍然优先当函数解析 ---');
|
|
105
|
+
{
|
|
106
|
+
const one = async (sql) => (await q(sql)).rows[0][0];
|
|
107
|
+
ok('COUNT(*) 仍是聚合函数', await one('SELECT COUNT(*) FROM (SELECT 1 AS x) t') === 1);
|
|
108
|
+
ok('MAX() 仍是函数', await one('SELECT MAX(x) FROM (SELECT 1 AS x UNION ALL SELECT 2) t') === 2);
|
|
109
|
+
ok('MIN() 仍是函数', await one('SELECT MIN(x) FROM (SELECT 1 AS x UNION ALL SELECT 2) t') === 1);
|
|
110
|
+
ok('SUM() 仍是函数', await one('SELECT SUM(x) FROM (SELECT 3 AS x) t') === 3);
|
|
111
|
+
ok('DATE() 仍是函数', typeof await one("SELECT DATE('2026-01-01')") === 'string');
|
|
112
|
+
ok('COUNT() 紧跟括号才是函数', await one('SELECT COUNT(*) FROM (SELECT 1 AS x) t') === 1);
|
|
113
|
+
ok('裸 count 当列时不吞掉后面的 FROM', await okSql('SELECT count FROM (SELECT 1 AS count) t'));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log('\n--- 真保留字仍要求反引号(防止放行范围被扩大)---');
|
|
117
|
+
const reserved = ['key', 'order', 'group', 'index', 'column', 'table',
|
|
118
|
+
'case', 'when', 'then', 'else', 'end', 'select', 'from', 'where'];
|
|
119
|
+
for (const n of reserved) {
|
|
120
|
+
ok(`裸写 ${n} 作列名仍被拒`, !(await okSql(`CREATE TABLE r_${n} (${n} TEXT)`)));
|
|
121
|
+
}
|
|
122
|
+
ok('反引号包裹后可用', await okSql('CREATE TABLE r_ok (`key` TEXT, `order` INT)'));
|
|
123
|
+
{
|
|
124
|
+
await q('CREATE TABLE r_ok2 (`key` TEXT, `order` INT)');
|
|
125
|
+
await q("INSERT INTO r_ok2 VALUES ('k', 1)");
|
|
126
|
+
const r = await rows('SELECT `key`, `order` FROM r_ok2');
|
|
127
|
+
ok('反引号包裹后可读写', JSON.stringify(r) === '[["k",1]]', r);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log('\n--- 带点号的限定名 ---');
|
|
131
|
+
{
|
|
132
|
+
await q('CREATE TABLE q1 (date TEXT)');
|
|
133
|
+
await q("INSERT INTO q1 VALUES ('x')");
|
|
134
|
+
const r = await rows('SELECT q1.date FROM q1');
|
|
135
|
+
ok('表名.非保留关键字列名', JSON.stringify(r) === '[["x"]]', r);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
139
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
140
|
+
})().catch(e => { console.error('💥', e); process.exit(1); });
|
|
@@ -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); });
|