jsql-neo 6.5.12 → 6.5.14
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 +1 -1
- package/index.js +1 -1
- package/lib/btree.js +52 -116
- package/lib/database.js +18 -21
- package/lib/errors.js +4 -5
- package/lib/migrate.js +6 -2
- package/lib/mongo_server.js +20 -3
- package/lib/mysql_server.js +3 -2
- package/lib/nedb_compat.js +15 -2
- package/lib/pg_server.js +4 -1
- package/lib/plugin.js +14 -3
- package/lib/sql.js +44 -7
- package/lib/star-nag.js +20 -9
- package/lib/table.js +41 -16
- package/lib/web_ui.js +8 -5
- package/lib/xql/src/compiler.js +18 -8
- package/package.json +2 -1
- package/src/cli/index.js +12 -0
- package/src/cli/plugin.js +346 -0
- package/test/plugin-xql.test.js +23 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> **One engine to rule them all** — a Rust-powered embedded database that speaks your language:
|
|
4
4
|
> MySQL, PostgreSQL, MongoDB, Redis, SQL, TypeScript, **and the browser**.
|
|
5
5
|
|
|
6
|
-
> **v6.5.
|
|
6
|
+
> **v6.5.14** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
|
|
7
7
|
|
|
8
8
|

|
|
9
9
|

|
package/index.js
CHANGED
package/lib/btree.js
CHANGED
|
@@ -24,8 +24,6 @@ class BTree {
|
|
|
24
24
|
this._unique = unique;
|
|
25
25
|
this._root = new BTreeNode();
|
|
26
26
|
this._size = 0;
|
|
27
|
-
this._minKeys = Math.floor(order / 2) - 1;
|
|
28
|
-
this._minDegree = Math.ceil(order / 2);
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
get size() { return this._size; }
|
|
@@ -273,122 +271,81 @@ class BTree {
|
|
|
273
271
|
|
|
274
272
|
remove(key, rowIndex) {
|
|
275
273
|
if (!this._root) return false;
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
return removed;
|
|
274
|
+
const res = this._removeFromNode(this._root, key, rowIndex);
|
|
275
|
+
if (!res.found) return false;
|
|
276
|
+
if (!res.remaining) this._size--;
|
|
277
|
+
// 只做惰性删除 + 分隔键归一:不再在下降途中借用/合并。
|
|
278
|
+
// 旧实现先对路由到的子节点做 borrow/merge 再检查键是否存在,
|
|
279
|
+
// 删除一个不存在的键(或 rowIndex 不匹配)也会重构整棵树,
|
|
280
|
+
// 合并时丢掉的分隔键副本可能正好是仍然存活的数据键 —— 条目静默丢失。
|
|
281
|
+
this._normalize();
|
|
282
|
+
return !res.remaining;
|
|
287
283
|
}
|
|
288
284
|
|
|
289
285
|
_removeFromNode(node, key, rowIndex) {
|
|
290
286
|
if (node.leaf) {
|
|
291
287
|
const i = this._lowerBound(node.keys, key);
|
|
292
|
-
if (i >= node.keys.length || node.keys[i] !== key) return false;
|
|
293
|
-
|
|
288
|
+
if (i >= node.keys.length || node.keys[i] !== key) return { found: false };
|
|
289
|
+
const pos = node.values[i].indexOf(rowIndex);
|
|
290
|
+
if (pos === -1) return { found: false };
|
|
294
291
|
const vals = node.values[i].filter(v => v !== rowIndex);
|
|
295
292
|
if (vals.length === 0) {
|
|
296
293
|
node.keys.splice(i, 1);
|
|
297
294
|
node.values.splice(i, 1);
|
|
298
|
-
return true;
|
|
295
|
+
return { found: true, remaining: false };
|
|
299
296
|
}
|
|
300
297
|
node.values[i] = vals;
|
|
301
|
-
return
|
|
298
|
+
return { found: true, remaining: true };
|
|
302
299
|
}
|
|
303
300
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
// 下降前确保子节点有足够键,避免欠满子节点
|
|
301
|
+
const i = this._route(node, key);
|
|
307
302
|
const child = node.children[i];
|
|
308
|
-
if (child
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
return this._removeFromNode(node.children[i], key, rowIndex);
|
|
303
|
+
if (!child) return { found: false };
|
|
304
|
+
return this._removeFromNode(child, key, rowIndex);
|
|
312
305
|
}
|
|
313
306
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (i > 0) {
|
|
324
|
-
this._mergeChildren(parent, i - 1);
|
|
325
|
-
return i - 1;
|
|
326
|
-
}
|
|
327
|
-
this._mergeChildren(parent, i);
|
|
328
|
-
return i;
|
|
307
|
+
/**
|
|
308
|
+
* 惰性回收:删掉节点里空的叶子/空子树,折叠只剩一个孩子的内部节点,
|
|
309
|
+
* 并按「分隔键 = 左子树最大键」的约定重算所有内部键,最后重建叶子链表。
|
|
310
|
+
* 节点欠满不影响正确性(插入时仍然按满则分裂),因此无需借用/合并。
|
|
311
|
+
*/
|
|
312
|
+
_normalize() {
|
|
313
|
+
const root = this._cleanNode(this._root);
|
|
314
|
+
this._root = root || new BTreeNode();
|
|
315
|
+
this._relinkLeaves();
|
|
329
316
|
}
|
|
330
317
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const v = left.values.pop();
|
|
338
|
-
right.keys.unshift(k);
|
|
339
|
-
right.values.unshift(v);
|
|
340
|
-
// 父节点分隔键应为左叶新的最大值(pop 后的最后一个键)
|
|
341
|
-
parent.keys[i - 1] = left.keys[left.keys.length - 1];
|
|
342
|
-
parent.values[i - 1] = left.values[left.values.length - 1];
|
|
343
|
-
} else {
|
|
344
|
-
right.keys.unshift(parent.keys[i - 1]);
|
|
345
|
-
right.values.unshift(parent.values[i - 1]);
|
|
346
|
-
parent.keys[i - 1] = left.keys.pop();
|
|
347
|
-
parent.values[i - 1] = left.values.pop();
|
|
348
|
-
right.children.unshift(left.children.pop());
|
|
318
|
+
_cleanNode(node) {
|
|
319
|
+
if (node.leaf) return node.keys.length === 0 ? null : node;
|
|
320
|
+
const kids = [];
|
|
321
|
+
for (const c of node.children) {
|
|
322
|
+
const kept = this._cleanNode(c);
|
|
323
|
+
if (kept) kids.push(kept);
|
|
349
324
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
left.keys.push(k);
|
|
360
|
-
left.values.push(v);
|
|
361
|
-
parent.keys[i] = k;
|
|
362
|
-
parent.values[i] = v;
|
|
363
|
-
} else {
|
|
364
|
-
left.keys.push(parent.keys[i]);
|
|
365
|
-
left.values.push(parent.values[i]);
|
|
366
|
-
parent.keys[i] = right.keys.shift();
|
|
367
|
-
parent.values[i] = right.values.shift();
|
|
368
|
-
left.children.push(right.children.shift());
|
|
325
|
+
node.children = kids;
|
|
326
|
+
if (kids.length === 0) return null;
|
|
327
|
+
if (kids.length === 1) return kids[0];
|
|
328
|
+
node.keys = [];
|
|
329
|
+
node.values = [];
|
|
330
|
+
for (let i = 0; i < kids.length - 1; i++) {
|
|
331
|
+
const m = this._getMax(kids[i]);
|
|
332
|
+
node.keys.push(m.key);
|
|
333
|
+
node.values.push(m.value);
|
|
369
334
|
}
|
|
335
|
+
return node;
|
|
370
336
|
}
|
|
371
337
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
* +28:1 +24:4 +0:0 +14:2 -24:4 +25:4 +24:2 +28:1 -26:2
|
|
382
|
-
* 前 8 步都正常,第 9 步把 keys=[24,25] 变成 [24,24]。
|
|
383
|
-
*/
|
|
384
|
-
_fixSeparators(node) {
|
|
385
|
-
if (node.leaf) return;
|
|
386
|
-
for (let i = 0; i < node.keys.length; i++) {
|
|
387
|
-
const m = this._getMax(node.children[i]);
|
|
388
|
-
node.keys[i] = m.key;
|
|
389
|
-
node.values[i] = m.value;
|
|
338
|
+
_relinkLeaves() {
|
|
339
|
+
let head = this._root;
|
|
340
|
+
while (head && !head.leaf) head = head.children[0];
|
|
341
|
+
let prev = null;
|
|
342
|
+
let node = head;
|
|
343
|
+
while (node) {
|
|
344
|
+
if (prev) prev.next = node;
|
|
345
|
+
prev = node;
|
|
346
|
+
node = node.next;
|
|
390
347
|
}
|
|
391
|
-
|
|
348
|
+
if (prev) prev.next = null;
|
|
392
349
|
}
|
|
393
350
|
|
|
394
351
|
_getMax(node) {
|
|
@@ -397,27 +354,6 @@ class BTree {
|
|
|
397
354
|
return { key: node.keys[i], value: node.values[i] };
|
|
398
355
|
}
|
|
399
356
|
|
|
400
|
-
_mergeChildren(parent, index) {
|
|
401
|
-
const left = parent.children[index];
|
|
402
|
-
const right = parent.children[index + 1];
|
|
403
|
-
const midKey = parent.keys[index];
|
|
404
|
-
const midVal = parent.values[index];
|
|
405
|
-
|
|
406
|
-
// 叶子节点:分隔键是叶子的副本(已在 left 或 right 中),不能重复压入
|
|
407
|
-
if (!left.leaf) {
|
|
408
|
-
left.keys.push(midKey);
|
|
409
|
-
left.values.push(midVal);
|
|
410
|
-
}
|
|
411
|
-
for (const __e of right.keys) left.keys.push(__e);
|
|
412
|
-
for (const __e of right.values) left.values.push(__e);
|
|
413
|
-
if (!left.leaf) for (const __e of right.children) left.children.push(__e);
|
|
414
|
-
if (left.leaf) left.next = right.next;
|
|
415
|
-
|
|
416
|
-
parent.keys.splice(index, 1);
|
|
417
|
-
parent.values.splice(index, 1);
|
|
418
|
-
parent.children.splice(index + 1, 1);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
357
|
// ============================================================
|
|
422
358
|
// 遍历
|
|
423
359
|
// ============================================================
|
package/lib/database.js
CHANGED
|
@@ -1344,15 +1344,11 @@ class Database {
|
|
|
1344
1344
|
startedAt: Date.now()
|
|
1345
1345
|
};
|
|
1346
1346
|
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
for (const [name, table] of Object.entries(this._tables)) {
|
|
1353
|
-
this._transaction.autoIncrements[name] = table._autoIncrement;
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1347
|
+
// 无论隔离级别都要保存完整快照:ROLLBACK 必须撤销本事务写入的行。
|
|
1348
|
+
// 此前 READ_COMMITTED 只记 autoIncrement,回滚后行原样残留(静默不撤销)。
|
|
1349
|
+
// 隔离级别只影响「读取时能否看到其他事务的已提交数据」(这里是单连接内嵌库,
|
|
1350
|
+
// 不涉及并发可见性),不应影响回滚能力。
|
|
1351
|
+
this._transaction.snapshot = this._captureTxSnapshot();
|
|
1356
1352
|
|
|
1357
1353
|
this._savepointStack = [];
|
|
1358
1354
|
this._walWrite({ op: 'begin', level });
|
|
@@ -1374,12 +1370,6 @@ class Database {
|
|
|
1374
1370
|
|
|
1375
1371
|
if (this._transaction.snapshot) {
|
|
1376
1372
|
this._restoreTxSnapshot(this._transaction.snapshot);
|
|
1377
|
-
} else {
|
|
1378
|
-
// READ_COMMITTED:没有行快照,只能退回自增值
|
|
1379
|
-
for (const [name, table] of Object.entries(this._tables)) {
|
|
1380
|
-
const v = this._transaction.autoIncrements[name];
|
|
1381
|
-
if (v !== undefined) table._autoIncrement = v;
|
|
1382
|
-
}
|
|
1383
1373
|
}
|
|
1384
1374
|
|
|
1385
1375
|
this._walWrite({ op: 'rollback', at: this._transaction.startedAt });
|
|
@@ -1668,12 +1658,19 @@ class Database {
|
|
|
1668
1658
|
const pluginName = manifest.name || entry.name;
|
|
1669
1659
|
// 查找入口文件:manifest.main || manifest.entry || 'index.js'
|
|
1670
1660
|
const entryFile = manifest.main || manifest.entry || 'index.js';
|
|
1671
|
-
//
|
|
1661
|
+
// 兼容三种布局:
|
|
1662
|
+
// 1. entry 是仓库相对路径(如 "plugins/xql/index.js")且文件在根级 → basename
|
|
1663
|
+
// 2. entry 是根级相对路径(如 "index.js")且文件在根级
|
|
1664
|
+
// 3. plugins/ 子目录(旧布局)
|
|
1672
1665
|
const candidates = [
|
|
1673
1666
|
path.join(dir, entryFile),
|
|
1674
1667
|
path.join(dir, entryFile + '.js'),
|
|
1668
|
+
path.join(dir, path.basename(entryFile)),
|
|
1669
|
+
path.join(dir, path.basename(entryFile) + '.js'),
|
|
1675
1670
|
path.join(dir, 'plugins', entryFile),
|
|
1676
1671
|
path.join(dir, 'plugins', entryFile + '.js'),
|
|
1672
|
+
path.join(dir, 'plugins', path.basename(entryFile)),
|
|
1673
|
+
path.join(dir, 'plugins', path.basename(entryFile) + '.js'),
|
|
1677
1674
|
];
|
|
1678
1675
|
let entryPath = null;
|
|
1679
1676
|
for (const c of candidates) {
|
|
@@ -1850,8 +1847,8 @@ class Database {
|
|
|
1850
1847
|
let rows = table._applyFilterOptimized(table._getVisibleRows(), filter || {});
|
|
1851
1848
|
if (limit > 0) rows = rows.slice(offset, offset + limit);
|
|
1852
1849
|
else if (offset > 0) rows = rows.slice(offset);
|
|
1853
|
-
// P1-11:
|
|
1854
|
-
rows = rows.map(r => (
|
|
1850
|
+
// P1-11: 返回深拷贝,防止调用方修改(含嵌套对象/数组)影响内部数据
|
|
1851
|
+
rows = rows.map(r => Table.cloneRow(r));
|
|
1855
1852
|
this._touchTable(tableName);
|
|
1856
1853
|
await this._runHooks('afterFind', [tableName, { filter, opts }, rows]);
|
|
1857
1854
|
return rows;
|
|
@@ -1883,8 +1880,8 @@ class Database {
|
|
|
1883
1880
|
const row = this._resolveId(table, id);
|
|
1884
1881
|
// P1-4: 软删除行对普通读取不可见
|
|
1885
1882
|
if (row && row._deleted) return null;
|
|
1886
|
-
// P1-11:
|
|
1887
|
-
const result = row ?
|
|
1883
|
+
// P1-11: 返回深拷贝,防止调用方修改(含嵌套对象/数组)影响内部数据
|
|
1884
|
+
const result = row ? Table.cloneRow(row) : null;
|
|
1888
1885
|
this._touchTable(tableName);
|
|
1889
1886
|
await this._runHooks('afterFind', [tableName, { id }, result]);
|
|
1890
1887
|
return result;
|
|
@@ -1895,7 +1892,7 @@ class Database {
|
|
|
1895
1892
|
if (!table) throw createError('ER_NO_SUCH_TABLE', tableName);
|
|
1896
1893
|
if (!(await this._runHooks('beforeFind', [tableName, { ids }]))) return null;
|
|
1897
1894
|
// P1-4: 过滤掉软删除行;P1-11: 返回浅拷贝
|
|
1898
|
-
const rows = ids.map(id => this._resolveId(table, id)).filter(r => r !== null && !r._deleted).map(r => (
|
|
1895
|
+
const rows = ids.map(id => this._resolveId(table, id)).filter(r => r !== null && !r._deleted).map(r => Table.cloneRow(r));
|
|
1899
1896
|
this._touchTable(tableName);
|
|
1900
1897
|
await this._runHooks('afterFind', [tableName, { ids }, rows]);
|
|
1901
1898
|
return rows;
|
package/lib/errors.js
CHANGED
|
@@ -81,11 +81,10 @@ function createError(codeKey, ...args) {
|
|
|
81
81
|
return new JSQL_Error('UNKNOWN', `Unknown error: ${codeKey}`, { codeKey, args });
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
84
|
+
// 单趟替换所有 %s:既避免参数值里本身含有 %s 时被后续替换再次消费造成串位,
|
|
85
|
+
// 也避免实参不足时消息里残留裸 %s。
|
|
86
|
+
let idx = 0;
|
|
87
|
+
const msg = template.msg.replace(/%s/g, () => (idx < args.length ? String(args[idx++]) : ''));
|
|
89
88
|
|
|
90
89
|
return new JSQL_Error(template.code, msg, { args });
|
|
91
90
|
}
|
package/lib/migrate.js
CHANGED
|
@@ -30,8 +30,12 @@ function normalizeSchema(schema) {
|
|
|
30
30
|
if (d.type === 'varchar' || d.type === 'text' || d.type === 'char') d.type = 'string';
|
|
31
31
|
if (d.type === 'double' || d.type === 'real' || d.type === 'decimal' || d.type === 'numeric') d.type = 'float';
|
|
32
32
|
if (d.type === 'bool') d.type = 'boolean';
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// 保留用户显式配置的 length;maxLength 只是别名,仅在 length 未给出时回填。
|
|
34
|
+
// 此前无条件 delete d.length 再仅从 maxLength 恢复,导致显式 length 丢失。
|
|
35
|
+
if (d.maxLength !== undefined) {
|
|
36
|
+
if (d.length === undefined) d.length = d.maxLength;
|
|
37
|
+
delete d.maxLength;
|
|
38
|
+
}
|
|
35
39
|
out[name] = d;
|
|
36
40
|
}
|
|
37
41
|
return out;
|
package/lib/mongo_server.js
CHANGED
|
@@ -461,7 +461,7 @@ class MongoServer {
|
|
|
461
461
|
await engine.insert(coll, merged);
|
|
462
462
|
value = doc.new ? { ...merged } : null;
|
|
463
463
|
}
|
|
464
|
-
return { ok: 1, value, lastErrorObject: { n: found ? 1 : (doc.upsert ? 1 : 0), updatedExisting: !!found && !doc.remove } };
|
|
464
|
+
return { ok: 1, value: value ? this._publicDoc(value) : value, lastErrorObject: { n: found ? 1 : (doc.upsert ? 1 : 0), updatedExisting: !!found && !doc.remove } };
|
|
465
465
|
}
|
|
466
466
|
case 'distinct': {
|
|
467
467
|
const coll = String(doc.distinct);
|
|
@@ -496,7 +496,7 @@ class MongoServer {
|
|
|
496
496
|
if (skip > 0) rows = rows.slice(skip);
|
|
497
497
|
const limit = typeof doc.limit === 'number' ? doc.limit : 0;
|
|
498
498
|
if (limit > 0) rows = rows.slice(0, limit);
|
|
499
|
-
return { ok: 1, cursor: { id: { $long: 0 }, ns: `${this._dbOf(doc)}.${coll}`, firstBatch: rows } };
|
|
499
|
+
return { ok: 1, cursor: { id: { $long: 0 }, ns: `${this._dbOf(doc)}.${coll}`, firstBatch: rows.map(r => this._publicDoc(r)) } };
|
|
500
500
|
}
|
|
501
501
|
case 'getMore':
|
|
502
502
|
return { ok: 1, cursor: { id: { $long: 0 }, ns: String(doc.collection || ''), nextBatch: [] } };
|
|
@@ -673,7 +673,7 @@ class MongoServer {
|
|
|
673
673
|
rows = out;
|
|
674
674
|
}
|
|
675
675
|
}
|
|
676
|
-
return { ok: 1, cursor: { id: { $long: 0 }, ns: `${this._dbOf(doc)}.${coll}`, firstBatch: rows } };
|
|
676
|
+
return { ok: 1, cursor: { id: { $long: 0 }, ns: `${this._dbOf(doc)}.${coll}`, firstBatch: rows.map(r => this._publicDoc(r)) } };
|
|
677
677
|
}
|
|
678
678
|
default:
|
|
679
679
|
return { ok: 1, n: 0 };
|
|
@@ -684,6 +684,23 @@ class MongoServer {
|
|
|
684
684
|
return row._id != null ? row._id : row.id;
|
|
685
685
|
}
|
|
686
686
|
|
|
687
|
+
/**
|
|
688
|
+
* 剥离 JSQL 内部字段后再返回给 Mongo 客户端。
|
|
689
|
+
*
|
|
690
|
+
* _deleted(软删除标记)、_rid(内部行号)、_score(全文检索得分)
|
|
691
|
+
* 都只属于引擎内部实现,泄漏到文档里会污染用户数据、影响后续 $set/$unset
|
|
692
|
+
* 以及客户端 schema 推断。
|
|
693
|
+
*/
|
|
694
|
+
_publicDoc(row) {
|
|
695
|
+
if (!row || typeof row !== 'object') return row;
|
|
696
|
+
if (row._deleted === undefined && row._rid === undefined && row._score === undefined) return row;
|
|
697
|
+
const out = { ...row };
|
|
698
|
+
delete out._deleted;
|
|
699
|
+
delete out._rid;
|
|
700
|
+
delete out._score;
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
|
|
687
704
|
async _matching(engine, coll, filter) {
|
|
688
705
|
const rows = await engine.find(coll, {});
|
|
689
706
|
return rows.filter((r) => this._match(r, filter || {}));
|
package/lib/mysql_server.js
CHANGED
|
@@ -578,8 +578,9 @@ class MysqlConnection {
|
|
|
578
578
|
case 0x19: this._handleStmtClose(body); break; // COM_STMT_CLOSE
|
|
579
579
|
case 0x1a: this._send(okPacket()); break; // COM_STMT_RESET
|
|
580
580
|
case 0x0a: { // COM_PROCESS_INFO
|
|
581
|
-
|
|
582
|
-
|
|
581
|
+
// _handlePacket 已把 this.sequence 设为 cmdSeq+1,_send 会在写入后自增。
|
|
582
|
+
// 此前这里额外自增一次,导致首个响应包序列号比请求大 2(跳号),
|
|
583
|
+
// 严格校验序列号的客户端会判定协议错误并断开。
|
|
583
584
|
this._send(encodeLenenc(1));
|
|
584
585
|
this._send(columnDefinition({ name: 'Id', type: MYSQL_TYPE_LONG }));
|
|
585
586
|
this._send(eofPacket());
|
package/lib/nedb_compat.js
CHANGED
|
@@ -77,8 +77,21 @@ function matchOperator(value, op, expected) {
|
|
|
77
77
|
case '$in': return Array.isArray(expected) && expected.some(e => looseEqual(value, e));
|
|
78
78
|
case '$nin': return Array.isArray(expected) && !expected.some(e => looseEqual(value, e));
|
|
79
79
|
case '$exists': return expected ? value !== undefined : value === undefined;
|
|
80
|
-
case '$regex':
|
|
81
|
-
|
|
80
|
+
case '$regex': {
|
|
81
|
+
if (typeof value !== 'string') return false;
|
|
82
|
+
// expected 允许两种写法:/re/flags(RegExp),或 MySQL 风格的字符串模式。
|
|
83
|
+
// 此前直接读 expected.source:字符串没有 source → new RegExp(undefined)
|
|
84
|
+
// 等价于 /(?:)/,静默匹配所有文档。
|
|
85
|
+
let re;
|
|
86
|
+
if (expected instanceof RegExp) {
|
|
87
|
+
re = expected;
|
|
88
|
+
} else if (typeof expected === 'string') {
|
|
89
|
+
try { re = new RegExp(expected); } catch { return false; }
|
|
90
|
+
} else {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return re.test(value);
|
|
94
|
+
}
|
|
82
95
|
case '$size': return Array.isArray(value) && value.length === expected;
|
|
83
96
|
default: return false;
|
|
84
97
|
}
|
package/lib/pg_server.js
CHANGED
|
@@ -792,7 +792,10 @@ class PgServer {
|
|
|
792
792
|
if (user !== this.options.user) return undefined;
|
|
793
793
|
return this.options.password || '';
|
|
794
794
|
}
|
|
795
|
-
|
|
795
|
+
// 未配置任何认证信息:默认拒绝连接(返回 undefined → 认证失败)。
|
|
796
|
+
// 此前返回 '' 会被 _startAuth 当成 trust,导致任意用户名 + 空密码即可连入,
|
|
797
|
+
// 与 MysqlServer 的默认拒绝策略保持一致。仅显式 noAuth: true 才放行。
|
|
798
|
+
return undefined;
|
|
796
799
|
}
|
|
797
800
|
|
|
798
801
|
_canAccessDb(user, dbName) {
|
package/lib/plugin.js
CHANGED
|
@@ -104,10 +104,16 @@ function definePlugin(name, def = {}) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/** 登记单个钩子;未知钩子名会自动创建,便于插件自定义扩展点 */
|
|
107
|
-
function registerHook(engine, hook, fn) {
|
|
107
|
+
function registerHook(engine, hook, fn, priority = 0) {
|
|
108
108
|
if (typeof fn !== 'function') return engine;
|
|
109
109
|
if (!engine._hooks) engine._hooks = createHookRegistry();
|
|
110
110
|
if (!engine._hooks[hook]) engine._hooks[hook] = [];
|
|
111
|
+
// 记录优先级:即使插件是后加载的,也应按 priority 决定执行顺序,
|
|
112
|
+
// 而不是简单追加在已有钩子之后(此前 sort(_plugins) 只排插件列表,
|
|
113
|
+
// 钩子链仍按注册顺序执行,导致 priority 实际无效)。
|
|
114
|
+
try {
|
|
115
|
+
Object.defineProperty(fn, '__jsqlHookPriority', { value: priority, configurable: true, writable: true });
|
|
116
|
+
} catch (e) { /* 冻结函数等无法打标,按 0 处理 */ }
|
|
111
117
|
engine._hooks[hook].push(fn);
|
|
112
118
|
return engine;
|
|
113
119
|
}
|
|
@@ -115,9 +121,10 @@ function registerHook(engine, hook, fn) {
|
|
|
115
121
|
/** 把一个插件声明的所有钩子登记到引擎上 */
|
|
116
122
|
function registerHooks(engine, plugin) {
|
|
117
123
|
if (!plugin || !plugin.hooks) return engine;
|
|
124
|
+
const priority = plugin.priority || 0;
|
|
118
125
|
for (const [hook, val] of Object.entries(plugin.hooks)) {
|
|
119
126
|
const fns = Array.isArray(val) ? val : [val];
|
|
120
|
-
for (const fn of fns) registerHook(engine, hook, fn);
|
|
127
|
+
for (const fn of fns) registerHook(engine, hook, fn, priority);
|
|
121
128
|
}
|
|
122
129
|
return engine;
|
|
123
130
|
}
|
|
@@ -253,7 +260,11 @@ function runHooks(engine, hookName, args) {
|
|
|
253
260
|
|
|
254
261
|
return (async () => {
|
|
255
262
|
try {
|
|
256
|
-
|
|
263
|
+
// priority 大的先执行;同 priority 保持注册顺序(sort 在现代引擎是稳定的)
|
|
264
|
+
const ordered = hooks.length > 1
|
|
265
|
+
? hooks.slice().sort((a, b) => (b.__jsqlHookPriority || 0) - (a.__jsqlHookPriority || 0))
|
|
266
|
+
: hooks;
|
|
267
|
+
for (const fn of ordered) {
|
|
257
268
|
const result = await fn(...args);
|
|
258
269
|
if (result === false) return false;
|
|
259
270
|
}
|
package/lib/sql.js
CHANGED
|
@@ -2811,8 +2811,15 @@ class Parser {
|
|
|
2811
2811
|
// MySQL 中 RLIKE 是 REGEXP 的同义词
|
|
2812
2812
|
if (t2.type === 'keyword' && (t2.value === 'REGEXP' || t2.value === 'RLIKE')) {
|
|
2813
2813
|
this.next();
|
|
2814
|
-
|
|
2815
|
-
|
|
2814
|
+
let pattern = this.parseValue();
|
|
2815
|
+
// `expr REGEXP BINARY 'pat'`:BINARY 前缀表示大小写敏感匹配。
|
|
2816
|
+
// 解析出的 pattern 会包一层 { type:'binary', operand },这里解包并标记 ci=false。
|
|
2817
|
+
let binary = false;
|
|
2818
|
+
if (pattern && typeof pattern === 'object' && pattern.type === 'binary') {
|
|
2819
|
+
binary = true;
|
|
2820
|
+
pattern = pattern.operand;
|
|
2821
|
+
}
|
|
2822
|
+
return { type: 'regexp', operand: left, pattern, not, ci: !binary };
|
|
2816
2823
|
}
|
|
2817
2824
|
|
|
2818
2825
|
// PG JSON 包含操作符
|
|
@@ -4476,7 +4483,14 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
4476
4483
|
switch (expr.type) {
|
|
4477
4484
|
case 'and': return evaluateExpr(expr.left, row, ctx) && evaluateExpr(expr.right, row, ctx);
|
|
4478
4485
|
case 'or': return evaluateExpr(expr.left, row, ctx) || evaluateExpr(expr.right, row, ctx);
|
|
4479
|
-
case 'xor':
|
|
4486
|
+
case 'xor': {
|
|
4487
|
+
// 三值逻辑:任一操作数为 UNKNOWN 时结果也是 UNKNOWN。
|
|
4488
|
+
// 旧实现用 Boolean() 归一,Boolean(null)=false 会把 NULL XOR TRUE 误判为 TRUE。
|
|
4489
|
+
const l = evaluateExpr(expr.left, row, ctx);
|
|
4490
|
+
const r = evaluateExpr(expr.right, row, ctx);
|
|
4491
|
+
return sqlXor(l === null || l === undefined ? null : Boolean(l),
|
|
4492
|
+
r === null || r === undefined ? null : Boolean(r));
|
|
4493
|
+
}
|
|
4480
4494
|
case 'not': {
|
|
4481
4495
|
// 三值逻辑:NOT UNKNOWN = UNKNOWN(返回 null,在 WHERE/ON/HAVING 中视为不选中)
|
|
4482
4496
|
// 必须用 resolveOperand 取值:evaluateExpr 只认布尔类节点,
|
|
@@ -4569,8 +4583,18 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
4569
4583
|
case 'regexp': {
|
|
4570
4584
|
const v = resolveOperand(expr.operand, row, ctx);
|
|
4571
4585
|
if (v === null || v === undefined) return false;
|
|
4572
|
-
//
|
|
4573
|
-
|
|
4586
|
+
// pattern 可能是字面量、列引用或表达式:统一用 resolveOperand 取值。
|
|
4587
|
+
// 过去 String(expr.pattern) 对 { type:'binary'|'column' } 对象只会得到
|
|
4588
|
+
// "[object Object]",BINARY 前缀的大小写敏感语义也因此完全失效。
|
|
4589
|
+
// pattern 可能是字面量、列引用或表达式:字面量(primitive)直接使用,
|
|
4590
|
+
// 只有 AST 节点才走 resolveOperand —— 否则字符串会被当成列名。
|
|
4591
|
+
const pat = (expr.pattern && typeof expr.pattern === 'object')
|
|
4592
|
+
? resolveOperand(expr.pattern, row, ctx)
|
|
4593
|
+
: expr.pattern;
|
|
4594
|
+
if (pat === null || pat === undefined) return false;
|
|
4595
|
+
let re;
|
|
4596
|
+
try { re = new RegExp(String(pat), expr.ci === false ? '' : 'i'); } catch { return false; }
|
|
4597
|
+
// REGEXP / RLIKE 默认大小写不敏感;`~` 敏感、`~*` 不敏感;REGEXP BINARY 敏感
|
|
4574
4598
|
const r = re.test(String(v));
|
|
4575
4599
|
return expr.not ? !r : r;
|
|
4576
4600
|
}
|
|
@@ -4590,8 +4614,11 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
4590
4614
|
// 三值逻辑:任一操作数为 NULL 时结果是 UNKNOWN(用 null 表示),
|
|
4591
4615
|
// 与普通比较运算符一致;不能返回 false,否则 NOT BETWEEN 会被误判为真。
|
|
4592
4616
|
if (v === null || v === undefined || lo === null || lo === undefined || hi === null || hi === undefined) return null;
|
|
4593
|
-
|
|
4594
|
-
|
|
4617
|
+
// 数值(含数字字符串)必须按数值比较,否则 '10' BETWEEN 2 AND 9 会退化成
|
|
4618
|
+
// 字典序 ('10' >= '2' 为 false),与 MySQL 不一致。
|
|
4619
|
+
const nv = numericIfNumeric(v), nlo = numericIfNumeric(lo), nhi = numericIfNumeric(hi);
|
|
4620
|
+
const inRange = nv !== null && nlo !== null && nhi !== null
|
|
4621
|
+
? nv >= nlo && nv <= nhi
|
|
4595
4622
|
: String(v) >= String(lo) && String(v) <= String(hi);
|
|
4596
4623
|
return expr.not ? !inRange : inRange;
|
|
4597
4624
|
}
|
|
@@ -5447,11 +5474,21 @@ class SQLExecutor {
|
|
|
5447
5474
|
}
|
|
5448
5475
|
if (typeof statement.limit === 'number') candidates = candidates.slice(0, statement.limit);
|
|
5449
5476
|
let count = 0;
|
|
5477
|
+
// UPDATE 的赋值表达式同样可能含标量子查询,SELECT 路径在 _materialize
|
|
5478
|
+
// 里统一物化,但 UPDATE 从未调用它 —— 子查询节点 _value 一直是 undefined,
|
|
5479
|
+
// resolveOperand 直接返回 NULL,`SET x = (SELECT ...)` 静默写成 NULL。
|
|
5480
|
+
for (const [, val] of statement.assignments) {
|
|
5481
|
+
if (val && typeof val === 'object') await this._materialize(val);
|
|
5482
|
+
}
|
|
5450
5483
|
for (const row of candidates) {
|
|
5451
5484
|
const id = this._rowPkId(row, pkCols);
|
|
5452
5485
|
if (id !== undefined) {
|
|
5453
5486
|
const data = {};
|
|
5454
5487
|
for (const [col, val] of statement.assignments) {
|
|
5488
|
+
if (val && typeof val === 'object' && val.type) {
|
|
5489
|
+
// 相关子查询:按当前行重算(非相关时重算是幂等的,结果一致)
|
|
5490
|
+
for (const sub of this._collectSubqueries(val)) await this._evalCorrelated(sub, row);
|
|
5491
|
+
}
|
|
5455
5492
|
data[col] = typeof val === 'object' && val !== null && val.type ? resolveOperand(val, row, this.ctx) : val;
|
|
5456
5493
|
}
|
|
5457
5494
|
// 必须 await:wasm / native client 的 updateById 是异步的,
|
package/lib/star-nag.js
CHANGED
|
@@ -156,22 +156,33 @@ function checkStarred() {
|
|
|
156
156
|
// 用同步 API:CLI 启动阶段还没有 event loop 可用
|
|
157
157
|
try {
|
|
158
158
|
const { execFileSync } = require('child_process');
|
|
159
|
-
|
|
159
|
+
// 用 -w 追加 HTTP 状态码,按状态码判定,而不是靠 body 长度猜。
|
|
160
|
+
// 该端点语义:已 star → 204(空 body);未 star → 404。
|
|
161
|
+
// 此前把「空 body」一律当已 star:遇到 302/500 等空 body 响应或代理异常时
|
|
162
|
+
// 会误判为 star 过,从此永久闭嘴。
|
|
163
|
+
const raw = execFileSync('curl', [
|
|
160
164
|
'-sS', '-m', '4',
|
|
165
|
+
'-w', '\n%{http_code}',
|
|
161
166
|
'-H', `Authorization: Bearer ${token}`,
|
|
162
167
|
'-H', 'Accept: application/vnd.github+json',
|
|
163
168
|
'-H', 'X-GitHub-Api-Version: 2022-11-28',
|
|
164
169
|
`https://api.github.com/user/starred/${REPO}`,
|
|
165
170
|
], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString();
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
171
|
+
const nl = raw.lastIndexOf('\n');
|
|
172
|
+
const status = Number(raw.slice(nl + 1).trim());
|
|
173
|
+
const body = raw.slice(0, nl).trim();
|
|
174
|
+
if (status === 204) return true;
|
|
175
|
+
if (status === 404) return false;
|
|
176
|
+
if (status === 200 && body) {
|
|
177
|
+
// 兼容返回数组的旧行为:数组里有该仓库才算 star
|
|
178
|
+
try {
|
|
179
|
+
const j = JSON.parse(body);
|
|
180
|
+
if (Array.isArray(j)) return j.length > 0;
|
|
181
|
+
} catch (e) { /* 非 JSON,无法确认 */ }
|
|
182
|
+
}
|
|
183
|
+
return null; // 401/403/3xx/5xx → 无法确认
|
|
173
184
|
} catch (e) {
|
|
174
|
-
return null; // 网络不通 /
|
|
185
|
+
return null; // 网络不通 / 没装 curl → 不确定
|
|
175
186
|
}
|
|
176
187
|
}
|
|
177
188
|
|
package/lib/table.js
CHANGED
|
@@ -118,6 +118,24 @@ function isSafeRegex(pattern) {
|
|
|
118
118
|
return groups.length === 0;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* 深拷贝一行数据(普通对象/数组/Date),用于隔离内部存储与调用方。
|
|
123
|
+
*
|
|
124
|
+
* 过去 insert/find 只做 `{ ...row }` 浅拷贝:行里的嵌套对象/数组与内部存储
|
|
125
|
+
* 共享引用,调用方修改返回值(或插入后继续改传入对象)会静默污染数据库内容。
|
|
126
|
+
* 非普通对象(Map/Set/类实例等)保持原引用,避免破坏无法安全克隆的类型。
|
|
127
|
+
*/
|
|
128
|
+
function cloneRow(value) {
|
|
129
|
+
if (value === null || typeof value !== 'object') return value;
|
|
130
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
131
|
+
if (Array.isArray(value)) return value.map(cloneRow);
|
|
132
|
+
const proto = Object.getPrototypeOf(value);
|
|
133
|
+
if (proto !== Object.prototype && proto !== null) return value;
|
|
134
|
+
const out = {};
|
|
135
|
+
for (const k of Object.keys(value)) out[k] = cloneRow(value[k]);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
121
139
|
class Table {
|
|
122
140
|
constructor(name, schema, db) {
|
|
123
141
|
this._name = name;
|
|
@@ -245,7 +263,7 @@ class Table {
|
|
|
245
263
|
}
|
|
246
264
|
|
|
247
265
|
const rowIndex = this._rows.length;
|
|
248
|
-
this._rows.push(
|
|
266
|
+
this._rows.push(cloneRow(data));
|
|
249
267
|
this._addToIndexes(rowIndex, data);
|
|
250
268
|
this._dirty = true;
|
|
251
269
|
this._db._markDirty(this._name);
|
|
@@ -299,7 +317,7 @@ class Table {
|
|
|
299
317
|
const result = new Array(N);
|
|
300
318
|
for (let i = 0; i < N; i++) {
|
|
301
319
|
// 在副本上做默认值/计算列/类型归一,避免污染调用方传入的原始对象
|
|
302
|
-
let data =
|
|
320
|
+
let data = cloneRow(items[i]);
|
|
303
321
|
for (const [f, dv] of Object.entries(defaults)) {
|
|
304
322
|
if (data[f] === undefined) data[f] = typeof dv === 'function' ? dv() : dv === 'CURRENT_TIMESTAMP' ? now() : dv;
|
|
305
323
|
}
|
|
@@ -308,7 +326,8 @@ class Table {
|
|
|
308
326
|
data = this._validateDataTypes(data);
|
|
309
327
|
for (const field of this._cachedSchemaFields) {
|
|
310
328
|
const def = schema[field];
|
|
311
|
-
|
|
329
|
+
const isPk = def.primaryKey || def.primary === true;
|
|
330
|
+
if ((def.required || isPk) && (data[field] === undefined || data[field] === null)
|
|
312
331
|
&& !(def.autoIncrement && data[field] === undefined)) throw createError('ER_BAD_NULL_ERROR', field);
|
|
313
332
|
if (def.unique && data[field] !== undefined) {
|
|
314
333
|
if (uniqueTracker[field].has(data[field])) throw createError('ER_DUP_ENTRY', String(data[field]), field);
|
|
@@ -346,7 +365,7 @@ class Table {
|
|
|
346
365
|
this._autoIncrement = data[autoIncField];
|
|
347
366
|
}
|
|
348
367
|
const rowIdx = this._rows.length;
|
|
349
|
-
this._rows.push(
|
|
368
|
+
this._rows.push(cloneRow(data));
|
|
350
369
|
if (this._pkIndex && this._primaryKey) {
|
|
351
370
|
const pkVal = data[this._primaryKey];
|
|
352
371
|
if (pkVal !== undefined && pkVal !== null) {
|
|
@@ -391,7 +410,7 @@ class Table {
|
|
|
391
410
|
if (Object.keys(query).length > 0) {
|
|
392
411
|
rows = this._applyFilterOptimized(rows, query);
|
|
393
412
|
}
|
|
394
|
-
return rows.map(r => (
|
|
413
|
+
return rows.map(r => cloneRow(r));
|
|
395
414
|
}
|
|
396
415
|
|
|
397
416
|
findOne(query = {}) {
|
|
@@ -399,7 +418,7 @@ class Table {
|
|
|
399
418
|
if (Object.keys(query).length > 0) {
|
|
400
419
|
rows = this._applyFilterOptimized(rows, query);
|
|
401
420
|
}
|
|
402
|
-
return rows.length > 0 ?
|
|
421
|
+
return rows.length > 0 ? cloneRow(rows[0]) : null;
|
|
403
422
|
}
|
|
404
423
|
|
|
405
424
|
exists(query = {}) {
|
|
@@ -415,7 +434,7 @@ class Table {
|
|
|
415
434
|
const rowIndex = this._pkIndex.get(id);
|
|
416
435
|
if (rowIndex !== undefined) {
|
|
417
436
|
const row = this._rows[rowIndex];
|
|
418
|
-
if (row && !row._deleted) return
|
|
437
|
+
if (row && !row._deleted) return cloneRow(row);
|
|
419
438
|
}
|
|
420
439
|
return null;
|
|
421
440
|
}
|
|
@@ -423,7 +442,7 @@ class Table {
|
|
|
423
442
|
const indices = this._btrees[this._primaryKey].search(id);
|
|
424
443
|
if (indices.length > 0) {
|
|
425
444
|
const row = this._rows[indices[0]];
|
|
426
|
-
if (row && !row._deleted) return
|
|
445
|
+
if (row && !row._deleted) return cloneRow(row);
|
|
427
446
|
}
|
|
428
447
|
return null;
|
|
429
448
|
}
|
|
@@ -431,7 +450,7 @@ class Table {
|
|
|
431
450
|
}
|
|
432
451
|
|
|
433
452
|
findAll() {
|
|
434
|
-
return this._getVisibleRows().map(r => (
|
|
453
|
+
return this._getVisibleRows().map(r => cloneRow(r));
|
|
435
454
|
}
|
|
436
455
|
|
|
437
456
|
count(query = {}) {
|
|
@@ -995,18 +1014,22 @@ class Table {
|
|
|
995
1014
|
for (const [field, condition] of Object.entries(query)) {
|
|
996
1015
|
if (field === '$or' || field === '$and' || field === '$not') continue;
|
|
997
1016
|
if (typeof condition !== 'object' || condition === null || Array.isArray(condition) || condition instanceof RegExp) {
|
|
998
|
-
|
|
1017
|
+
// 索引只收非 NULL 值:condition 为 null/undefined 时必须全表扫描,
|
|
1018
|
+
// 否则 search(null) 命中空数组直接返回 0 行(索引列 IS NULL 漏行)。
|
|
1019
|
+
const nullish = condition === null || condition === undefined;
|
|
1020
|
+
if (!nullish && this._btrees[field]) {
|
|
999
1021
|
const indices = this._btrees[field].search(condition);
|
|
1000
1022
|
if (indices.length === 0) return [];
|
|
1001
1023
|
return this._applyFilter(this._rowsFromIndices(indices), query);
|
|
1002
1024
|
}
|
|
1003
|
-
if (this._indexes[field] && this._indexes[field].has(condition)) {
|
|
1025
|
+
if (!nullish && this._indexes[field] && this._indexes[field].has(condition)) {
|
|
1004
1026
|
return this._applyFilter(this._rowsFromIndices(this._indexes[field].get(condition)), query);
|
|
1005
1027
|
}
|
|
1006
|
-
if (this._indexes[field]) return [];
|
|
1028
|
+
if (!nullish && this._indexes[field]) return [];
|
|
1007
1029
|
}
|
|
1008
1030
|
if (condition && typeof condition === 'object') {
|
|
1009
|
-
if (this._btrees[field] && Array.isArray(condition.$in) && condition.$in.length > 0
|
|
1031
|
+
if (this._btrees[field] && Array.isArray(condition.$in) && condition.$in.length > 0
|
|
1032
|
+
&& !condition.$in.some(v => v === null || v === undefined)) {
|
|
1010
1033
|
const indices = this._btrees[field].searchMany(condition.$in);
|
|
1011
1034
|
if (indices.length === 0) return [];
|
|
1012
1035
|
return this._applyFilter(this._rowsFromIndices(indices), query);
|
|
@@ -1355,8 +1378,9 @@ class Table {
|
|
|
1355
1378
|
for (const [field, def] of Object.entries(this._schema)) {
|
|
1356
1379
|
if (field === '_softDelete') continue;
|
|
1357
1380
|
|
|
1358
|
-
// NOT NULL
|
|
1359
|
-
|
|
1381
|
+
// NOT NULL(主键隐式 NOT NULL,否则唯一约束检查会因 NULL 短路而被绕过)
|
|
1382
|
+
const isPk = def.primaryKey || def.primary === true;
|
|
1383
|
+
if ((def.required || isPk) && (data[field] === undefined || data[field] === null)
|
|
1360
1384
|
&& !(def.autoIncrement && data[field] === undefined)) {
|
|
1361
1385
|
throw createError('ER_BAD_NULL_ERROR', field);
|
|
1362
1386
|
}
|
|
@@ -1664,4 +1688,5 @@ class Table {
|
|
|
1664
1688
|
}
|
|
1665
1689
|
}
|
|
1666
1690
|
|
|
1667
|
-
module.exports = Table;
|
|
1691
|
+
module.exports = Table;
|
|
1692
|
+
Table.cloneRow = cloneRow;
|
package/lib/web_ui.js
CHANGED
|
@@ -69,19 +69,22 @@ tr:nth-child(even) td { background: #0d1117; }
|
|
|
69
69
|
<script>
|
|
70
70
|
let dbs = [], cur = null;
|
|
71
71
|
const q = async (u, o) => { const r = await fetch(u, o); return r.json(); };
|
|
72
|
-
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
72
|
+
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
|
73
73
|
async function load() {
|
|
74
74
|
dbs = await q('/api/databases');
|
|
75
75
|
const el = document.getElementById('dbs');
|
|
76
|
-
el.innerHTML = dbs.map(d => '<div class="db"
|
|
76
|
+
el.innerHTML = dbs.map(d => '<div class="db" data-name="' + esc(d.name) + '">' + esc(d.name) + ' <span class="rows">(' + d.tables + ' tbl)</span></div>').join('');
|
|
77
|
+
el.querySelectorAll('.db').forEach(e => e.addEventListener('click', () => openDb(e.dataset.name)));
|
|
77
78
|
if (!cur && dbs.length) openDb(dbs[0].name);
|
|
78
79
|
}
|
|
79
80
|
async function openDb(n) {
|
|
80
81
|
cur = n;
|
|
81
|
-
document.querySelectorAll('.db').forEach(e => e.classList.toggle('active', e.
|
|
82
|
+
document.querySelectorAll('.db').forEach(e => e.classList.toggle('active', e.dataset.name === n));
|
|
82
83
|
const r = await q('/api/tables?db=' + encodeURIComponent(n));
|
|
83
|
-
document.getElementById('tbls')
|
|
84
|
-
|
|
84
|
+
const tl = document.getElementById('tbls');
|
|
85
|
+
tl.innerHTML = r.tables.map(t =>
|
|
86
|
+
'<div class="tbl" data-name="' + esc(t.name) + '">' + esc(t.name) + ' (' + t.count + ')</div>').join('');
|
|
87
|
+
tl.querySelectorAll('.tbl').forEach(e => e.addEventListener('click', () => sel(e.dataset.name)));
|
|
85
88
|
}
|
|
86
89
|
function sel(n) { document.getElementById('sql').value = 'SELECT * FROM ' + n + ' LIMIT 100'; run(); }
|
|
87
90
|
async function run() {
|
package/lib/xql/src/compiler.js
CHANGED
|
@@ -136,7 +136,7 @@ class Emitter {
|
|
|
136
136
|
const lo = this.emit(node.lo);
|
|
137
137
|
const hi = this.emit(node.hi);
|
|
138
138
|
const t = this.tmp();
|
|
139
|
-
this.push(`let ${t} = (${v} == null ? null : (${v} >= ${lo} && ${v} <= ${hi}));`);
|
|
139
|
+
this.push(`let ${t} = (${v} == null || ${lo} == null || ${hi} == null ? null : (${v} >= ${lo} && ${v} <= ${hi}));`);
|
|
140
140
|
return t;
|
|
141
141
|
}
|
|
142
142
|
case 'like': {
|
|
@@ -183,8 +183,8 @@ class Emitter {
|
|
|
183
183
|
case '*': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} * ${r});`); break;
|
|
184
184
|
case '/': this.push(`let ${t} = (${l} == null || ${r} == null || Number(${r}) === 0 ? null : ${l} / ${r});`); break;
|
|
185
185
|
case '%': this.push(`let ${t} = (${l} == null || ${r} == null || Number(${r}) === 0 ? null : ${l} % ${r});`); break;
|
|
186
|
-
case '=': this.push(`let ${t} = (${l} == null ? null : ${l} === ${r});`); break;
|
|
187
|
-
case '!=': this.push(`let ${t} = (${l} == null ? null : ${l} !== ${r});`); break;
|
|
186
|
+
case '=': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} === ${r});`); break;
|
|
187
|
+
case '!=': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} !== ${r});`); break;
|
|
188
188
|
case '<': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} < ${r});`); break;
|
|
189
189
|
case '>': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} > ${r});`); break;
|
|
190
190
|
case '<=': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} <= ${r});`); break;
|
|
@@ -197,15 +197,25 @@ class Emitter {
|
|
|
197
197
|
emitIn(node) {
|
|
198
198
|
const v = this.emit(node.e);
|
|
199
199
|
const t = this.tmp();
|
|
200
|
-
const not = node.neg ? '!' : '';
|
|
201
|
-
const join = node.neg ? ' && ' : ' || ';
|
|
202
200
|
if (node.list.every(n => n.k === 'lit')) {
|
|
203
201
|
const name = '_set' + this.sets.length;
|
|
204
202
|
this.sets.push({ name, set: new Set(node.list.map(n => n.v)) });
|
|
205
|
-
|
|
203
|
+
// 完整三值逻辑:被查找值为 NULL → UNKNOWN;列表含 NULL 且未匹配 → UNKNOWN。
|
|
204
|
+
const hasNull = node.list.some(n => n.v === null);
|
|
205
|
+
if (node.neg) {
|
|
206
|
+
this.push(`let ${t} = (${v} == null ? null : (${name}.has(${v}) ? false : (${hasNull} ? null : true)));`);
|
|
207
|
+
} else {
|
|
208
|
+
this.push(`let ${t} = (${v} == null ? null : (${name}.has(${v}) ? true : (${hasNull} ? null : false)));`);
|
|
209
|
+
}
|
|
206
210
|
} else {
|
|
207
|
-
const
|
|
208
|
-
|
|
211
|
+
const elems = node.list.map(n => this.emit(n));
|
|
212
|
+
const anyMatch = elems.map(e => `(${v} === ${e})`).join(' || ');
|
|
213
|
+
const anyNull = elems.map(e => `${e} == null`).join(' || ');
|
|
214
|
+
if (node.neg) {
|
|
215
|
+
this.push(`let ${t} = (${v} == null ? null : (${anyMatch} ? false : (${anyNull} ? null : true)));`);
|
|
216
|
+
} else {
|
|
217
|
+
this.push(`let ${t} = (${v} == null ? null : (${anyMatch} ? true : (${anyNull} ? null : false)));`);
|
|
218
|
+
}
|
|
209
219
|
}
|
|
210
220
|
return t;
|
|
211
221
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsql-neo",
|
|
3
|
-
"version": "6.5.
|
|
3
|
+
"version": "6.5.14",
|
|
4
4
|
"description": "JSQL-NEO \u2014 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",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"index.d.ts",
|
|
36
36
|
"lib/",
|
|
37
37
|
"bin/",
|
|
38
|
+
"src/",
|
|
38
39
|
"wasm/",
|
|
39
40
|
"native/",
|
|
40
41
|
"nativesrc/",
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSQL Plugin Registry CLI
|
|
3
|
+
*
|
|
4
|
+
* 协议文档: /root/.openclaw/workspace/registry-protocol.md
|
|
5
|
+
* Registry API: https://registry-beta.vexify.top/?action=xxx
|
|
6
|
+
*
|
|
7
|
+
* 注意: 当前 registry-beta 实例仅实现了 plugins / search / registry 三个 action,
|
|
8
|
+
* 尚未实现 info 和 download。本模块优先使用实际 API,缺失能力时给出友好提示。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { execFileSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const REGISTRY_BASE = 'https://registry-beta.vexify.top';
|
|
17
|
+
const PLUGIN_DIR = path.join(os.homedir(), '.jsql', 'plugins');
|
|
18
|
+
const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
|
|
19
|
+
const MAX_ARCHIVE_ENTRIES = 10000;
|
|
20
|
+
|
|
21
|
+
// ─── 工具函数 ──────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** 插件名只允许字母/数字/点/下划线/连字符,杜绝路径注入 */
|
|
24
|
+
function assertValidPluginName(name) {
|
|
25
|
+
if (typeof name !== 'string' || !name) {
|
|
26
|
+
throw new Error('Plugin name must be a non-empty string');
|
|
27
|
+
}
|
|
28
|
+
if (name.length > 128) {
|
|
29
|
+
throw new Error(`Invalid plugin name: too long (${name.length} > 128)`);
|
|
30
|
+
}
|
|
31
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes('..')) {
|
|
32
|
+
throw new Error(`Invalid plugin name: '${name}'`);
|
|
33
|
+
}
|
|
34
|
+
return name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 校验 tar 清单:拒绝绝对路径、`..`、设备/符号链接/硬链接 */
|
|
38
|
+
function assertSafeTarEntries(archivePath, destDir) {
|
|
39
|
+
const names = execFileSync('tar', ['-tzf', archivePath], {
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
42
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
43
|
+
}).split(/\r?\n/).filter(Boolean);
|
|
44
|
+
if (names.length > MAX_ARCHIVE_ENTRIES) {
|
|
45
|
+
throw new Error(`Archive contains too many entries (${names.length} > ${MAX_ARCHIVE_ENTRIES})`);
|
|
46
|
+
}
|
|
47
|
+
const destResolved = path.resolve(destDir);
|
|
48
|
+
for (const entry of names) {
|
|
49
|
+
if (entry.startsWith('/') || entry.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(entry)) {
|
|
50
|
+
throw new Error(`Archive contains absolute path: ${entry}`);
|
|
51
|
+
}
|
|
52
|
+
if (entry.split(/[\\/]+/).includes('..')) {
|
|
53
|
+
throw new Error(`Archive contains path traversal: ${entry}`);
|
|
54
|
+
}
|
|
55
|
+
const target = path.resolve(destResolved, entry);
|
|
56
|
+
if (target !== destResolved && !target.startsWith(destResolved + path.sep)) {
|
|
57
|
+
throw new Error(`Archive entry escapes plugin directory: ${entry}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// 类型检查单独用 -tvzf,避免解析带空格的路径
|
|
61
|
+
const verbose = execFileSync('tar', ['-tvzf', archivePath], {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
65
|
+
});
|
|
66
|
+
for (const line of verbose.split(/\r?\n/)) {
|
|
67
|
+
if (!line) continue;
|
|
68
|
+
const type = line.charAt(0);
|
|
69
|
+
if (type === 'l' || type === 'h') {
|
|
70
|
+
throw new Error('Archive contains links, which are not allowed');
|
|
71
|
+
}
|
|
72
|
+
if (type !== '-' && type !== 'd') {
|
|
73
|
+
throw new Error(`Archive contains unsupported entry type '${type}'`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 确保插件目录存在 */
|
|
79
|
+
function ensurePluginDir() {
|
|
80
|
+
if (!fs.existsSync(PLUGIN_DIR)) {
|
|
81
|
+
fs.mkdirSync(PLUGIN_DIR, { recursive: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 从可能含有 PHP Warning 前缀的响应文本中提取 JSON */
|
|
86
|
+
function extractJson(text) {
|
|
87
|
+
// 找到第一个 '{' 的位置,从那里开始解析
|
|
88
|
+
const idx = text.indexOf('{');
|
|
89
|
+
if (idx === -1) return null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(text.slice(idx));
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 发起 registry API 请求,返回解析后的 JSON */
|
|
98
|
+
async function apiRequest(action, params = {}) {
|
|
99
|
+
const url = new URL(REGISTRY_BASE);
|
|
100
|
+
url.searchParams.set('action', action);
|
|
101
|
+
for (const [k, v] of Object.entries(params)) {
|
|
102
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const res = await fetch(url.toString());
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
const body = extractJson(text);
|
|
108
|
+
|
|
109
|
+
if (!res.ok || !body || body.status !== 'success') {
|
|
110
|
+
const msg = body?.error || `HTTP ${res.status}`;
|
|
111
|
+
throw new Error(`Registry error: ${msg}`);
|
|
112
|
+
}
|
|
113
|
+
return body.data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── 核心操作 ──────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/** 列出所有插件(支持 category / tag / author / q / sort / order / page / limit) */
|
|
119
|
+
async function listPlugins(opts = {}) {
|
|
120
|
+
const params = {};
|
|
121
|
+
if (opts.category) params.category = opts.category;
|
|
122
|
+
if (opts.tag) params.tag = opts.tag;
|
|
123
|
+
if (opts.author) params.author = opts.author;
|
|
124
|
+
if (opts.q) params.q = opts.q;
|
|
125
|
+
if (opts.sort) params.sort = opts.sort;
|
|
126
|
+
if (opts.order) params.order = opts.order;
|
|
127
|
+
if (opts.page) params.page = opts.page;
|
|
128
|
+
if (opts.limit) params.limit = opts.limit;
|
|
129
|
+
return apiRequest('plugins', params);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 搜索插件(q 参数必填) */
|
|
133
|
+
async function searchPlugins(query, opts = {}) {
|
|
134
|
+
if (!query) throw new Error('Search query is required');
|
|
135
|
+
const params = { q: query };
|
|
136
|
+
if (opts.category) params.category = opts.category;
|
|
137
|
+
if (opts.tag) params.tag = opts.tag;
|
|
138
|
+
if (opts.sort) params.sort = opts.sort;
|
|
139
|
+
return apiRequest('search', params);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 获取单个插件信息(实际 API 未实现 info action,通过 plugins 列表过滤) */
|
|
143
|
+
async function getPluginInfo(name) {
|
|
144
|
+
const plugins = await listPlugins({ q: name, limit: 100 });
|
|
145
|
+
const plugin = plugins.find(p => p.name === name);
|
|
146
|
+
if (!plugin) throw new Error(`Plugin '${name}' not found in registry`);
|
|
147
|
+
return plugin;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 带重试的 fetch(P1 修复:install 网络间歇性失败 30-40%)
|
|
152
|
+
* 3 次重试 + 指数退避(500ms, 1s, 2s)
|
|
153
|
+
*/
|
|
154
|
+
async function fetchWithRetry(url, retries = 3, baseDelay = 500) {
|
|
155
|
+
for (let i = 0; i < retries; i++) {
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetch(url.toString());
|
|
158
|
+
if (res.ok) return res;
|
|
159
|
+
// 5xx / 429 可重试
|
|
160
|
+
if ((res.status >= 500 || res.status === 429) && i < retries - 1) {
|
|
161
|
+
const delay = baseDelay * Math.pow(2, i);
|
|
162
|
+
await new Promise(r => setTimeout(r, delay));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
return res;
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (i < retries - 1) {
|
|
168
|
+
const delay = baseDelay * Math.pow(2, i);
|
|
169
|
+
await new Promise(r => setTimeout(r, delay));
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
throw e;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 安装插件:下载 tgz 并解压到 ~/.jsql/plugins/<name>/ */
|
|
178
|
+
async function installPlugin(name) {
|
|
179
|
+
assertValidPluginName(name);
|
|
180
|
+
ensurePluginDir();
|
|
181
|
+
|
|
182
|
+
// 检查是否已安装
|
|
183
|
+
const pluginDir = path.join(PLUGIN_DIR, name);
|
|
184
|
+
const manifestPath = path.join(pluginDir, 'manifest.json');
|
|
185
|
+
if (fs.existsSync(manifestPath)) {
|
|
186
|
+
const existing = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
187
|
+
throw new Error(`Plugin '${name}' is already installed (v${existing.version})`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 下载插件包(带重试)
|
|
191
|
+
const url = new URL(REGISTRY_BASE);
|
|
192
|
+
url.searchParams.set('action', 'download');
|
|
193
|
+
url.searchParams.set('name', name);
|
|
194
|
+
|
|
195
|
+
const res = await fetchWithRetry(url);
|
|
196
|
+
const ct = res.headers.get('content-type') || '';
|
|
197
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
198
|
+
|
|
199
|
+
if (buf.length > MAX_ARCHIVE_BYTES) {
|
|
200
|
+
throw new Error(`Plugin archive too large (${buf.length} > ${MAX_ARCHIVE_BYTES} bytes)`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 检查 Content-Type 或 magic bytes
|
|
204
|
+
if (!ct.includes('gzip') && buf[0] !== 0x1f) {
|
|
205
|
+
// 返回了错误 JSON,不是 tgz
|
|
206
|
+
const text = buf.toString('utf8');
|
|
207
|
+
const body = extractJson(text);
|
|
208
|
+
throw new Error(body?.error || `Download failed (HTTP ${res.status})`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const archivePath = path.join(PLUGIN_DIR, `${name}.tgz`);
|
|
212
|
+
fs.writeFileSync(archivePath, buf, { mode: 0o600 });
|
|
213
|
+
|
|
214
|
+
// 解压 tgz:先校验清单,再用 execFile 避免 shell 注入
|
|
215
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
216
|
+
try {
|
|
217
|
+
assertSafeTarEntries(archivePath, pluginDir);
|
|
218
|
+
execFileSync('tar', ['-xzf', archivePath, '-C', pluginDir], {
|
|
219
|
+
stdio: 'pipe',
|
|
220
|
+
});
|
|
221
|
+
} catch (e) {
|
|
222
|
+
fs.rmSync(pluginDir, { recursive: true, force: true });
|
|
223
|
+
fs.rmSync(archivePath, { force: true });
|
|
224
|
+
throw new Error(`Failed to extract plugin archive: ${e.message}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 读取 manifest(兼容两种路径:根级 + plugins/ 子目录)
|
|
228
|
+
let manifestFile = path.join(pluginDir, 'manifest.json');
|
|
229
|
+
if (!fs.existsSync(manifestFile)) {
|
|
230
|
+
manifestFile = path.join(pluginDir, 'plugins', 'manifest.json');
|
|
231
|
+
}
|
|
232
|
+
let manifest;
|
|
233
|
+
if (fs.existsSync(manifestFile)) {
|
|
234
|
+
manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
235
|
+
} else {
|
|
236
|
+
// 创建默认 manifest
|
|
237
|
+
manifest = { name, version: '0.0.0', installedAt: new Date().toISOString() };
|
|
238
|
+
fs.writeFileSync(path.join(pluginDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 清理压缩包
|
|
242
|
+
fs.rmSync(archivePath, { force: true });
|
|
243
|
+
|
|
244
|
+
return manifest;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 卸载插件
|
|
249
|
+
* P0 安全修复:校验 name 不含路径遍历序列,使用 basename 隔离,
|
|
250
|
+
* 防止 `jsql plugin uninstall '../../etc/something'` 删除任意目录。
|
|
251
|
+
*/
|
|
252
|
+
function uninstallPlugin(name) {
|
|
253
|
+
if (typeof name !== 'string' || !name) {
|
|
254
|
+
throw new Error('Plugin name must be a non-empty string');
|
|
255
|
+
}
|
|
256
|
+
// 拒绝包含路径遍历的名称
|
|
257
|
+
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
258
|
+
throw new Error(`Invalid plugin name: '${name}'`);
|
|
259
|
+
}
|
|
260
|
+
// 使用 basename 进一步隔离
|
|
261
|
+
const safeName = path.basename(name);
|
|
262
|
+
if (safeName !== name) {
|
|
263
|
+
throw new Error(`Invalid plugin name: '${name}'`);
|
|
264
|
+
}
|
|
265
|
+
const pluginDir = path.join(PLUGIN_DIR, safeName);
|
|
266
|
+
// 二次校验:确保解析后的路径严格在 PLUGIN_DIR 之下
|
|
267
|
+
const resolved = path.resolve(pluginDir);
|
|
268
|
+
const pluginDirResolved = path.resolve(PLUGIN_DIR);
|
|
269
|
+
if (resolved !== pluginDirResolved && !resolved.startsWith(pluginDirResolved + path.sep)) {
|
|
270
|
+
throw new Error(`Plugin name resolves outside plugin directory: '${name}'`);
|
|
271
|
+
}
|
|
272
|
+
if (!fs.existsSync(pluginDir)) {
|
|
273
|
+
throw new Error(`Plugin '${safeName}' is not installed`);
|
|
274
|
+
}
|
|
275
|
+
fs.rmSync(pluginDir, { recursive: true, force: true });
|
|
276
|
+
return { name: safeName, uninstalled: true };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 列出已安装的插件(兼容根级 + plugins/ 子目录两种布局) */
|
|
280
|
+
function listInstalled() {
|
|
281
|
+
ensurePluginDir();
|
|
282
|
+
const entries = fs.readdirSync(PLUGIN_DIR, { withFileTypes: true });
|
|
283
|
+
const plugins = [];
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
if (!entry.isDirectory()) continue;
|
|
286
|
+
// 兼容两种路径:根级 manifest.json + plugins/ 子目录 manifest.json
|
|
287
|
+
const manifestPaths = [
|
|
288
|
+
path.join(PLUGIN_DIR, entry.name, 'manifest.json'),
|
|
289
|
+
path.join(PLUGIN_DIR, entry.name, 'plugins', 'manifest.json'),
|
|
290
|
+
];
|
|
291
|
+
for (const manifestPath of manifestPaths) {
|
|
292
|
+
if (fs.existsSync(manifestPath)) {
|
|
293
|
+
try {
|
|
294
|
+
plugins.push(JSON.parse(fs.readFileSync(manifestPath, 'utf8')));
|
|
295
|
+
} catch { /* 跳过损坏的 manifest */ }
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return plugins;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── 格式化输出 ────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
/** 格式化插件列表为表格 */
|
|
306
|
+
function formatPluginList(plugins) {
|
|
307
|
+
if (!plugins.length) return 'No plugins found.';
|
|
308
|
+
const rows = plugins.map(p =>
|
|
309
|
+
`${p.name.padEnd(24)} ${(p.version || '?').padEnd(10)} ${(p.description || '').slice(0, 50)}`
|
|
310
|
+
);
|
|
311
|
+
return rows.join('\n');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 格式化插件详情 */
|
|
315
|
+
function formatPluginInfo(p) {
|
|
316
|
+
const lines = [
|
|
317
|
+
`Name: ${p.name}`,
|
|
318
|
+
`Version: ${p.version}`,
|
|
319
|
+
`Author: ${p.author}`,
|
|
320
|
+
`License: ${p.license}`,
|
|
321
|
+
`Category: ${p.category}`,
|
|
322
|
+
`Description: ${p.description}`,
|
|
323
|
+
`Tags: ${(p.tags || []).join(', ')}`,
|
|
324
|
+
`Downloads: ${p.downloads || 0}`,
|
|
325
|
+
];
|
|
326
|
+
if (p.jsql) {
|
|
327
|
+
lines.push(`JSQL: ${p.jsql.minVersion}+${p.jsql.maxVersion ? ' – ' + p.jsql.maxVersion : ''}`);
|
|
328
|
+
}
|
|
329
|
+
return lines.join('\n');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
PLUGIN_DIR,
|
|
334
|
+
ensurePluginDir,
|
|
335
|
+
assertValidPluginName,
|
|
336
|
+
assertSafeTarEntries,
|
|
337
|
+
listPlugins,
|
|
338
|
+
searchPlugins,
|
|
339
|
+
getPluginInfo,
|
|
340
|
+
installPlugin,
|
|
341
|
+
uninstallPlugin,
|
|
342
|
+
listInstalled,
|
|
343
|
+
formatPluginList,
|
|
344
|
+
formatPluginInfo,
|
|
345
|
+
fetchWithRetry,
|
|
346
|
+
};
|
package/test/plugin-xql.test.js
CHANGED
|
@@ -125,10 +125,33 @@ async function testTableName() {
|
|
|
125
125
|
await db.stop();
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
async function testInstalledPluginAutoLoad() {
|
|
129
|
+
console.log('\n===== 注册表插件自动加载(entry 仓库相对路径 + 根级文件)=====');
|
|
130
|
+
const os = require('os');
|
|
131
|
+
const fs = require('fs');
|
|
132
|
+
const home = os.homedir();
|
|
133
|
+
const name = '_xqltest_registry';
|
|
134
|
+
const dir = path.join(home, '.jsql', 'plugins', name);
|
|
135
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
136
|
+
fs.writeFileSync(path.join(dir, 'index.js'),
|
|
137
|
+
"module.exports = { name: '" + name + "', install(engine) { engine.__xqltest = true; } };\n");
|
|
138
|
+
fs.writeFileSync(path.join(dir, 'manifest.json'),
|
|
139
|
+
JSON.stringify({ name, version: '1.0.0', entry: 'plugins/' + name + '/index.js', files: [] }));
|
|
140
|
+
try {
|
|
141
|
+
const db = new Database(':memory:', { modules: true });
|
|
142
|
+
await db.start();
|
|
143
|
+
ok('仓库相对 entry + 根级 index.js 能自动加载', db.__xqltest === true);
|
|
144
|
+
await db.stop();
|
|
145
|
+
} finally {
|
|
146
|
+
try { fs.rmSync(path.join(home, '.jsql', 'plugins', name), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
128
150
|
(async () => {
|
|
129
151
|
await testEngine(Database, 'Database (Pure JS)');
|
|
130
152
|
await testAutoLoadOff();
|
|
131
153
|
await testTableName();
|
|
154
|
+
await testInstalledPluginAutoLoad();
|
|
132
155
|
|
|
133
156
|
// native 引擎(Linux 上可用;Android 等平台无二进制时跳过)
|
|
134
157
|
try {
|