jsql-neo 6.0.0-beta2 → 6.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
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--- DDL / 解析器节点覆盖 ---');
143
+ {
144
+ ok('CREATE INDEX 可解析', parseSQL('CREATE INDEX i ON t (a)').type === 'createIndex');
145
+ ok('CREATE UNIQUE INDEX 可解析', parseSQL('CREATE UNIQUE INDEX i ON t (a)').unique === true);
146
+ ok('DROP INDEX 可解析', parseSQL('DROP INDEX i ON t').type === 'dropIndex');
147
+ ok('FULL OUTER JOIN 可解析',
148
+ parseSQL('SELECT * FROM a FULL OUTER JOIN b ON a.id = b.id').from.joins[0].type === 'full');
149
+ ok('CAST 带长度可解析', parseSQL('SELECT CAST(a AS VARCHAR(10)) FROM t').columns[0].scalar.type === 'cast');
150
+ ok('FETCH FIRST 可解析', parseSQL('SELECT * FROM t FETCH FIRST 7 ROWS ONLY').limit === 7);
151
+ ok('ILIKE 可解析', parseSQL("SELECT * FROM t WHERE a ILIKE 'x'").where.ci === true);
152
+ ok('DELETE ... LIMIT 可解析', parseSQL('DELETE FROM t WHERE a = 1 LIMIT 5').limit === 5);
153
+ ok('ON CONFLICT DO UPDATE 可解析',
154
+ parseSQL('INSERT INTO t (a) VALUES (1) ON CONFLICT (a) DO UPDATE SET a = 2').onConflict.action === 'update');
155
+ ok('DEFAULT CURRENT_TIMESTAMP 可解析', (() => {
156
+ const st = parseSQL('CREATE TABLE t (id INT PRIMARY KEY, c DATETIME DEFAULT CURRENT_TIMESTAMP)');
157
+ return st.schema.c.default === 'CURRENT_TIMESTAMP';
158
+ })());
159
+ }
160
+
161
+ console.log('\n--- 顶层工厂导出不得为 undefined ---');
162
+ {
163
+ const jsql = require('../index.js');
164
+ for (const n of ['createMysqlServer', 'createPgServer', 'createRedisServer',
165
+ 'createMongoServer', 'createMultiServer', 'createTUI']) {
166
+ ok(`导出 ${n}`, typeof jsql[n] === 'function');
167
+ }
168
+ }
169
+
170
+ console.log('\n--- 子路径导出(README 里写的 require 路径必须真能引到)---');
171
+ {
172
+ const mod = require('../package.json');
173
+ const exportsMap = mod.exports || {};
174
+ ok('exports 暴露 ./lib/plugins 目录入口', !!exportsMap['./lib/plugins']);
175
+ try {
176
+ const plugins = require('../lib/plugins/index.js');
177
+ ok('lib/plugins 可加载且导出工厂函数', typeof plugins.createTimestamps === 'function');
178
+ } catch (e) {
179
+ ok('lib/plugins 可加载', false, e.message);
180
+ }
181
+ }
182
+
183
+ console.log('\n--- 数据正确性:查不存在的列必须报错(曾静默返回 null)---');
184
+ {
185
+ const engine = {
186
+ hasTable: () => true, truncate: async () => {}, flush: async () => {},
187
+ find: async () => [{ id: 1, name: 'a', dept: 'eng' }],
188
+ getTableSchema: async () => ({
189
+ id: { type: 'integer', primaryKey: true }, name: { type: 'string' }, dept: { type: 'string' },
190
+ }),
191
+ };
192
+ const expectErr = async (sql) => {
193
+ let threw = null;
194
+ try { await executeSQL(engine, sql, OPTS); } catch (e) { threw = e; }
195
+ return threw;
196
+ };
197
+ ok('SELECT 不存在的列报错', !!(await expectErr('SELECT nosuchcol FROM t')));
198
+ ok('SELECT 混有不存在列时报错', !!(await expectErr('SELECT id, nope FROM t')));
199
+ ok('WHERE 引用不存在列报错', !!(await expectErr('SELECT id FROM t WHERE ghost = 1')));
200
+ ok('GROUP BY 不存在列报错', !!(await expectErr('SELECT COUNT(*) AS c FROM t GROUP BY ghost')));
201
+ // 不能误伤:正常查询与聚合别名都要放行
202
+ let normal = null;
203
+ try { await executeSQL(engine, 'SELECT id, UPPER(name) FROM t', OPTS); } catch (e) { normal = e; }
204
+ ok('正常查询不受影响', normal === null, normal && normal.message);
205
+ let aliasOk = null;
206
+ try {
207
+ await executeSQL(engine, 'SELECT dept, COUNT(*) AS cnt FROM t GROUP BY dept HAVING cnt >= 1', OPTS);
208
+ } catch (e) { aliasOk = e; }
209
+ ok('HAVING 引用 SELECT 别名不报错', aliasOk === null, aliasOk && aliasOk.message);
210
+ let subOk = null;
211
+ try { await executeSQL(engine, 'SELECT id FROM t WHERE id IN (SELECT id FROM t)', OPTS); } catch (e) { subOk = e; }
212
+ ok('子查询内部列名不误判', subOk === null, subOk && subOk.message);
213
+ }
214
+
215
+ console.log('\n--- 并发 insert 不得重复写(曾行数暴涨 / 丢失)---');
216
+ {
217
+ const { JSQL } = require('../lib/wasm_client.js');
218
+ let db;
219
+ try {
220
+ db = new JSQL();
221
+ await db.start();
222
+ } catch (e) {
223
+ console.log('[SKIP] WASM 客户端不可用:' + e.message.slice(0, 60));
224
+ }
225
+ if (db) {
226
+ let id = 0;
227
+ await db.createTable('conc', { id: { type: 'INT', primaryKey: true }, b: { type: 'INT' } });
228
+ await Promise.all(Array.from({ length: 200 }, () => db.insert('conc', { id: ++id, b: 5 })));
229
+ const cnt = await db.count('conc');
230
+ ok(`并发 insert 200 行 → count 恰为 200(实际 ${cnt})`, cnt === 200);
231
+
232
+ // flush 失败不得把坏行留在 buffer 里二次爆炸
233
+ ok('insert(null) 抛 TypeError', await (async () => {
234
+ try { await db.insert('conc', null); return false; } catch (e) { return e instanceof TypeError; }
235
+ })());
236
+ ok('insert(undefined) 抛 TypeError', await (async () => {
237
+ try { await db.insert('conc', undefined); return false; } catch (e) { return e instanceof TypeError; }
238
+ })());
239
+ ok('insert(非对象) 抛 TypeError', await (async () => {
240
+ try { await db.insert('conc', 42); return false; } catch (e) { return e instanceof TypeError; }
241
+ })());
242
+
243
+ // 环形对象:应给可读错误,而不是 "Converting circular structure" 裸崩
244
+ await db.createTable('cyc', { id: { type: 'INT', primaryKey: true }, meta: { type: 'JSON' } });
245
+ const cyc = { self: null };
246
+ cyc.self = cyc;
247
+ let cycErr = null;
248
+ try { await db.insert('cyc', { id: 1, meta: cyc }); } catch (e) { cycErr = e; }
249
+ ok('环形对象给出带表/列名的错误',
250
+ !!cycErr && /Cannot serialize column 'meta'/.test(cycErr.message),
251
+ cycErr && cycErr.message);
252
+ await db.stop();
253
+ }
254
+ }
255
+
256
+ console.log('\n--- Redis:RESP2 帧解析(曾 inline 命令恒为 unknown command)---');
257
+ {
258
+ const { RedisServer } = require('../lib/redis_server.js');
259
+ const s = new RedisServer({ port: 0 });
260
+ const p = (raw) => s._parse(Buffer.from(raw, 'utf8'));
261
+
262
+ ok('RESP 数组 PING', JSON.stringify((p('*1\r\n$4\r\nPING\r\n') || {}).cmd) === '"PING"');
263
+ ok('RESP SET foo bar',
264
+ JSON.stringify((p('*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n') || {}).args) === '["foo","bar"]');
265
+ // 曾经这里返回数组,导致 switch(cmd) 永远匹配不上
266
+ ok('inline 命令 cmd 是字符串', (p('PING\r\n') || {}).cmd === 'PING');
267
+ ok('inline 带参数', JSON.stringify((p('SET k v\r\n') || {}).args) === '["k","v"]');
268
+ ok('*0 不崩溃', (p('*0\r\n') || {}).ignore === true);
269
+ ok('*-1 不崩溃', (p('*-1\r\n') || {}).ignore === true);
270
+ // $N 是字节长度:按字符切片会把多字节值截断
271
+ const cn = p('*3\r\n$3\r\nSET\r\n$2\r\nk1\r\n$12\r\n中文测试\r\n');
272
+ ok('多字节 UTF-8 按字节解析', JSON.stringify(cn && cn.args) === '["k1","中文测试"]');
273
+ ok('不完整帧返回 null(等待续包)', p('*3\r\n$3\r\nSET\r\n') === null);
274
+ ok('null bulk $-1', JSON.stringify((p('*2\r\n$3\r\nGET\r\n$-1\r\n') || {}).args) === '[null]');
275
+ }
276
+
277
+ console.log(`\n${pass} passed, ${fail} failed`);
278
+ process.exit(fail === 0 ? 0 : 1);
279
+ }
280
+
281
+ main().catch(e => { console.error(e); process.exit(1); });
package/wasm/browser.mjs CHANGED
@@ -174,10 +174,16 @@ export class JSQL {
174
174
  }
175
175
  }
176
176
 
177
- use(plugin) {
177
+ use(plugin, opts) {
178
+ if (typeof plugin === 'string') {
179
+ throw new Error('use: builtin plugin names require the Node plugin runtime; pass a plugin object');
180
+ }
178
181
  if (typeof plugin === 'function') {
179
182
  plugin = { install: plugin };
180
183
  }
184
+ if (opts && plugin && typeof plugin === 'object') {
185
+ plugin.config = Object.assign({}, plugin.config || {}, opts);
186
+ }
181
187
  if (plugin.hooks) {
182
188
  for (const [event, fn] of Object.entries(plugin.hooks)) {
183
189
  if (this._hooks[event]) {
@@ -377,7 +383,10 @@ export class JSQL {
377
383
  this._buffer[table].push(arr[0]);
378
384
  this._bufferSize++;
379
385
  const flushed = await this._flush();
380
- return flushed && flushed[table] ? flushed[table] : null;
386
+ const result = flushed && flushed[table] ? flushed[table] : null;
387
+ this._emit('insert', { table, count: arr.length, ids: result });
388
+ this._runHooks('afterInsert', [table, arr, result]);
389
+ return result;
381
390
  }
382
391
 
383
392
  async insertMany(table, data) {
@@ -386,6 +395,7 @@ export class JSQL {
386
395
 
387
396
  async createTable(name, schema) {
388
397
  await this._flush();
398
+ if (typeof schema === 'string') schema = parseSchemaString(schema);
389
399
  if (!this._runHooks('beforeCreateTable', [name, schema])) return null;
390
400
  const r = safeJsonParse(jsql_create_table(name, JSON.stringify(mapNativeSchema(schema))));
391
401
  if (r && r.ok === false) throw new Error(r.error || 'create table failed');
package/wasm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo-wasm",
3
- "version": "5.6.0",
3
+ "version": "6.0.0",
4
4
  "files": [
5
5
  "jsql_neo_wasm_bg.wasm",
6
6
  "jsql_neo_wasm.js",