jsql-neo 6.5.11 → 6.5.13
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 +39 -1
- package/index.d.ts +16 -0
- package/index.js +1 -1
- package/lib/database.js +8 -1
- package/lib/plugins/index.js +5 -1
- package/lib/plugins/xql.js +163 -0
- package/lib/xql/NOTICE +16 -0
- package/lib/xql/index.js +0 -0
- package/lib/xql/src/compiler.js +497 -0
- package/lib/xql/src/index-store.js +186 -0
- package/lib/xql/src/jsql.js +46 -0
- package/lib/xql/src/parser.js +287 -0
- package/lib/xql/src/tokenizer.js +129 -0
- package/package.json +3 -2
- package/src/cli/index.js +12 -0
- package/src/cli/plugin.js +346 -0
- package/test/plugin-xql.test.js +170 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* xql 插件测试:把 JSQL 表加载成 XQL 集合,写入自动失效重建。
|
|
3
|
+
*
|
|
4
|
+
* 验证点:
|
|
5
|
+
* 1. 标准 SQL 与管道式语法都能查询 JSQL 表
|
|
6
|
+
* 2. 索引(hash / range)在查询前生效且结果不变
|
|
7
|
+
* 3. 插入 / 更新 / 删除后缓存失效,查询反映最新数据
|
|
8
|
+
* 4. 便捷 API(load / refresh / tables / collection / clear / index)
|
|
9
|
+
* 5. 未加载且 autoLoad=false 时报错
|
|
10
|
+
* 6. native 引擎(可用时)同样工作
|
|
11
|
+
*/
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const ROOT = path.join(__dirname, '..');
|
|
14
|
+
const { Database } = require(path.join(ROOT, 'index.js'));
|
|
15
|
+
const plugins = require(path.join(ROOT, 'lib/plugins'));
|
|
16
|
+
const xqlPlugin = plugins.xql;
|
|
17
|
+
|
|
18
|
+
let pass = 0, fail = 0;
|
|
19
|
+
function ok(name, cond, extra) {
|
|
20
|
+
if (cond) { pass++; console.log('[OK] ', name); }
|
|
21
|
+
else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 140) : ''); }
|
|
22
|
+
}
|
|
23
|
+
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
24
|
+
|
|
25
|
+
const USER_SCHEMA = {
|
|
26
|
+
id: { type: 'integer' },
|
|
27
|
+
name: { type: 'string' },
|
|
28
|
+
age: { type: 'integer' },
|
|
29
|
+
score: { type: 'integer' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function makeDb(Engine, opts) {
|
|
33
|
+
const db = new Engine(':memory:', { modules: false });
|
|
34
|
+
await db.start();
|
|
35
|
+
db.use(xqlPlugin(opts || {}));
|
|
36
|
+
await db.createTable('users', USER_SCHEMA);
|
|
37
|
+
await db.insert('users', [
|
|
38
|
+
{ id: 1, name: 'ann', age: 25, score: 90 },
|
|
39
|
+
{ id: 2, name: 'bob', age: 17, score: 70 },
|
|
40
|
+
{ id: 3, name: 'cat', age: 34, score: 55 },
|
|
41
|
+
{ id: 4, name: 'dan', age: 17, score: null },
|
|
42
|
+
]);
|
|
43
|
+
return db;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function testEngine(Engine, label) {
|
|
47
|
+
console.log(`\n===== ${label} =====`);
|
|
48
|
+
|
|
49
|
+
const db = await makeDb(Engine);
|
|
50
|
+
|
|
51
|
+
console.log('\n--- 查询(标准 SQL)---');
|
|
52
|
+
const rows = await db.xql('SELECT name FROM users WHERE age > 18 ORDER BY name');
|
|
53
|
+
ok('age > 18 排序返回', eq(rows, [{ name: 'ann' }, { name: 'cat' }]), rows);
|
|
54
|
+
|
|
55
|
+
console.log('\n--- 查询(管道式)---');
|
|
56
|
+
const pipe = await db.xql('FROM users |> WHERE age > 18 |> SELECT name |> ORDER BY name');
|
|
57
|
+
ok('管道式与标准 SQL 等价', eq(pipe, rows), pipe);
|
|
58
|
+
|
|
59
|
+
console.log('\n--- 索引 ---');
|
|
60
|
+
db.xqlIndex('users', 'id');
|
|
61
|
+
db.xqlIndex('users', 'score', 'range');
|
|
62
|
+
const coll = db.xqlCollection('users');
|
|
63
|
+
ok('集合已建 id hash 索引', !!(coll && coll._indexes.has('id')));
|
|
64
|
+
ok('集合已建 score range 索引', !!(coll && coll._indexes.has('score')));
|
|
65
|
+
const eqById = await db.xql('SELECT id FROM users WHERE id = 2');
|
|
66
|
+
ok('hash 索引点查', eq(eqById, [{ id: 2 }]), eqById);
|
|
67
|
+
const rng = await db.xql('SELECT id FROM users WHERE score >= 70 ORDER BY id');
|
|
68
|
+
ok('range 索引范围查', eq(rng, [{ id: 1 }, { id: 2 }]), rng);
|
|
69
|
+
ok('index 记录在 tables 里', db.xqlTables().includes('users'));
|
|
70
|
+
|
|
71
|
+
console.log('\n--- 写入失效 ---');
|
|
72
|
+
await db.insert('users', [{ id: 5, name: 'eve', age: 40, score: 88 }]);
|
|
73
|
+
const afterIns = await db.xql('SELECT name FROM users WHERE id = 5');
|
|
74
|
+
ok('插入后立即可见(缓存重建)', eq(afterIns, [{ name: 'eve' }]), afterIns);
|
|
75
|
+
|
|
76
|
+
await db.updateById('users', 2, { age: 99 });
|
|
77
|
+
const afterUpd = await db.xql('SELECT name FROM users WHERE age > 98');
|
|
78
|
+
ok('更新后立即可见', eq(afterUpd, [{ name: 'bob' }]), afterUpd);
|
|
79
|
+
|
|
80
|
+
await db.removeById('users', 1);
|
|
81
|
+
const afterDel = await db.xql('SELECT name FROM users WHERE id = 1');
|
|
82
|
+
ok('删除后不可见', eq(afterDel, []), afterDel);
|
|
83
|
+
|
|
84
|
+
console.log('\n--- 便捷 API ---');
|
|
85
|
+
const t = await db.xqlLoad('users');
|
|
86
|
+
ok('load 返回集合', t && t.size === 4, t && t.size);
|
|
87
|
+
const r = await db.xqlRefresh('users');
|
|
88
|
+
ok('refresh 重建', r && r.size === 4);
|
|
89
|
+
ok('tables() 有 users', db.xqlTables().includes('users'));
|
|
90
|
+
ok('collection() 取到', db.xqlCollection('users') === r);
|
|
91
|
+
db.xqlClear('users');
|
|
92
|
+
ok('clear 后 collection 为空', db.xqlCollection('users') === null);
|
|
93
|
+
ok('clear 后 tables 为空', !db.xqlTables().includes('users'));
|
|
94
|
+
const auto = await db.xql('SELECT name FROM users WHERE age > 18 ORDER BY name');
|
|
95
|
+
ok('clear 后再查自动重载', eq(auto, [{ name: 'bob' }, { name: 'cat' }, { name: 'eve' }]), auto);
|
|
96
|
+
|
|
97
|
+
await db.stop();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function testAutoLoadOff() {
|
|
101
|
+
console.log('\n===== autoLoad=false =====');
|
|
102
|
+
const db = new Database(':memory:', { modules: false });
|
|
103
|
+
await db.start();
|
|
104
|
+
db.use(xqlPlugin({ autoLoad: false }));
|
|
105
|
+
await db.createTable('users', USER_SCHEMA);
|
|
106
|
+
await db.insert('users', [{ id: 1, name: 'ann', age: 25, score: 90 }]);
|
|
107
|
+
let threw = false;
|
|
108
|
+
try { await db.xql('SELECT name FROM users'); } catch (e) { threw = /未加载/.test(e.message); }
|
|
109
|
+
ok('autoLoad=false 未加载报错', threw);
|
|
110
|
+
await db.xqlLoad('users');
|
|
111
|
+
const r = await db.xql('SELECT name FROM users');
|
|
112
|
+
ok('显式 load 后可查', eq(r, [{ name: 'ann' }]), r);
|
|
113
|
+
await db.stop();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function testTableName() {
|
|
117
|
+
console.log('\n===== 显式数据源 opts.table =====');
|
|
118
|
+
const db = new Database(':memory:', { modules: false });
|
|
119
|
+
await db.start();
|
|
120
|
+
db.use(xqlPlugin());
|
|
121
|
+
await db.createTable('users', USER_SCHEMA);
|
|
122
|
+
await db.insert('users', [{ id: 1, name: 'ann', age: 25, score: 90 }]);
|
|
123
|
+
const r = await db.xql('SELECT name FROM users WHERE age > 18', { table: 'users' });
|
|
124
|
+
ok('opts.table 指定源', eq(r, [{ name: 'ann' }]), r);
|
|
125
|
+
await db.stop();
|
|
126
|
+
}
|
|
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
|
+
|
|
150
|
+
(async () => {
|
|
151
|
+
await testEngine(Database, 'Database (Pure JS)');
|
|
152
|
+
await testAutoLoadOff();
|
|
153
|
+
await testTableName();
|
|
154
|
+
await testInstalledPluginAutoLoad();
|
|
155
|
+
|
|
156
|
+
// native 引擎(Linux 上可用;Android 等平台无二进制时跳过)
|
|
157
|
+
try {
|
|
158
|
+
const Native = require(path.join(ROOT, 'lib/native_client')).JSQL;
|
|
159
|
+
if (typeof Native === 'function') {
|
|
160
|
+
await testEngine(Native, 'NativeJSQL (Rust)');
|
|
161
|
+
} else {
|
|
162
|
+
console.log('\n[native] JSQL export not a constructor, skip');
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.log('\n[native] 不可用,跳过:', e.message.slice(0, 80));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(`\n=== pass ${pass} fail ${fail} ===`);
|
|
169
|
+
process.exit(fail ? 1 : 0);
|
|
170
|
+
})();
|