jsql-neo 6.5.26 → 6.5.29
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 +26 -1
- package/bin/jsql +32 -0
- package/index.d.ts +8 -1
- package/index.js +5 -1
- package/lib/ast.js +21 -14
- package/lib/btree.js +14 -8
- package/lib/database.js +5 -1
- package/lib/date-types.js +29 -1
- package/lib/mongo_server.js +21 -5
- package/lib/mysql_compat.js +1 -1
- package/lib/query.js +71 -2
- package/lib/sql.js +248 -13
- package/lib/table.js +139 -38
- package/native/.installed.json +8 -0
- package/native/jsql-neo-native.node +0 -0
- package/package.json +5 -5
- package/postinstall.js +136 -6
- package/test/coverage.js +3 -1
- package/test/finalize-bigint.test.js +55 -0
- package/test/issue-44.test.js +92 -0
- package/test/issue-45.test.js +62 -0
- package/test/issue-46.test.js +42 -0
- package/test/issue-47.test.js +65 -0
- package/test/issue-48.test.js +39 -0
- package/test/issue-50.test.js +83 -0
- package/test/issue-51.test.js +56 -0
- package/test/issue-52.test.js +62 -0
- package/test/issue-53.test.js +50 -0
- package/test/issue-54.test.js +53 -0
- package/test/issue-55.test.js +86 -0
- package/test/issue-56.test.js +70 -0
- package/test/issue-57.test.js +70 -0
- package/test/issue-58.test.js +52 -0
- package/test/native-cli.test.js +122 -0
- package/test/native-install.test.js +93 -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.29** — 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
|

|
|
@@ -171,6 +171,31 @@ await db.start();
|
|
|
171
171
|
|
|
172
172
|
---
|
|
173
173
|
|
|
174
|
+
## Query results
|
|
175
|
+
|
|
176
|
+
`db.query()` / `executeSQL()` return **one result in three shapes**:
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
const r = await db.query('SELECT city, COUNT(*) AS c FROM users GROUP BY city');
|
|
180
|
+
r.columns // ['city', 'c']
|
|
181
|
+
r.rows // [['LA', 1], ['NY', 2]]
|
|
182
|
+
r.raw // [{ city: 'LA', c: 1 }, { city: 'NY', c: 2 }]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`raw` is the object form of `rows`: its keys are exactly `columns`, and that
|
|
186
|
+
includes computed, aggregate and scalar-subquery columns. It is **not** the
|
|
187
|
+
pre-projection source row. For SELECT results `raw.length === rows.length`
|
|
188
|
+
always holds; non-SELECT statements may omit `raw`.
|
|
189
|
+
|
|
190
|
+
To check which native addon this installation is actually using:
|
|
191
|
+
|
|
192
|
+
- `jsql native status` — tag/version match (`0` = OK, `1` = mismatch or unverified)
|
|
193
|
+
- `jsql native verify` — re-hashes the `.node` on disk against the checksum recorded
|
|
194
|
+
at install time, so a swapped or corrupted binary is caught too. `--json` prints
|
|
195
|
+
a structured result for CI. States: `OK` / `MISMATCH` / `CORRUPT` / `MISSING` /
|
|
196
|
+
`UNVERIFIED` / `UNREADABLE`.
|
|
197
|
+
- `jsql native refresh` — re-fetches a checksum-verified addon
|
|
198
|
+
|
|
174
199
|
## ✨ XQL 集成
|
|
175
200
|
|
|
176
201
|
用 [XQL](https://github.com/vexify-org/XQL) 查询 JSQL 表:把 SQL 直接接到文档上,
|
package/bin/jsql
CHANGED
|
@@ -531,6 +531,38 @@ const cli = (yaggs || require('./yaggs-fallback').buildYaggsFallback(_pkg))({
|
|
|
531
531
|
.command('update', 'Update jsql-neo to the latest version', updateOptions, updateHandler)
|
|
532
532
|
.command('uninstall', 'Remove jsql user data and/or the global package', uninstallOptions, uninstallHandler)
|
|
533
533
|
.command('remove', 'Alias of uninstall', uninstallOptions, uninstallHandler)
|
|
534
|
+
.command('native', 'Inspect or refresh the Rust native addon', (sub) => {
|
|
535
|
+
sub.option('json', { type: 'boolean', description: 'Machine-readable output (verify only)' });
|
|
536
|
+
}, async (argv) => {
|
|
537
|
+
const action = argv._[0] || 'status';
|
|
538
|
+
const pi = require('../postinstall.js');
|
|
539
|
+
if (action === 'verify') {
|
|
540
|
+
// 内容级:重算 .node 的 SHA256 与安装记录比对,status 只看 tag 发现不了替换/损坏
|
|
541
|
+
const r = pi.verifyNative();
|
|
542
|
+
if (argv.json) {
|
|
543
|
+
console.log(JSON.stringify(r, null, 2));
|
|
544
|
+
} else {
|
|
545
|
+
console.log(`[jsql-neo] VERIFY : ${r.state}`);
|
|
546
|
+
console.log(`[jsql-neo] tag : ${r.installedTag || 'unknown'} (expect ${r.expectedTag})`);
|
|
547
|
+
console.log(`[jsql-neo] sha256 : ${r.actualSha256 || 'n/a'}`);
|
|
548
|
+
if (r.state !== 'OK') console.log('[jsql-neo] refresh with: jsql native refresh');
|
|
549
|
+
}
|
|
550
|
+
process.exitCode = r.state === 'OK' ? 0 : 1;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (action === 'status') {
|
|
554
|
+
// 0 = 当前 .node 与包版本匹配;1 = 错配 / 未记录(可直接当 CI 门禁)
|
|
555
|
+
process.exitCode = pi.nativeStatus();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (action === 'refresh') {
|
|
559
|
+
await pi.ensureNativeNode();
|
|
560
|
+
process.exitCode = pi.nativeStatus();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
console.error(`Unknown native action: ${action}. Usage: jsql native <status|refresh|verify>`);
|
|
564
|
+
process.exitCode = 1;
|
|
565
|
+
})
|
|
534
566
|
.command('version', 'Show version', null, () => {
|
|
535
567
|
console.log(require('../package.json').version);
|
|
536
568
|
})
|
package/index.d.ts
CHANGED
|
@@ -272,9 +272,16 @@ declare namespace JSQLNeo {
|
|
|
272
272
|
ok: boolean;
|
|
273
273
|
type: string;
|
|
274
274
|
table?: string | null;
|
|
275
|
+
/** 投影列名;rows[i][j] 与 columns[j] 一一对应。 */
|
|
275
276
|
columns?: string[];
|
|
277
|
+
/** 投影后的二维数组形式。 */
|
|
276
278
|
rows?: unknown[][];
|
|
277
|
-
|
|
279
|
+
/**
|
|
280
|
+
* 与 rows 指向**同一份结果**的对象形式:raw[i] 的键恰好是 columns,
|
|
281
|
+
* 值取自 rows[i]。聚合 / 计算列 / 标量子查询都包含在内(issue #43)。
|
|
282
|
+
* 非 SELECT 语句可能没有此字段。
|
|
283
|
+
*/
|
|
284
|
+
raw?: Array<Record<string, unknown>>;
|
|
278
285
|
affectedRows?: number;
|
|
279
286
|
insertId?: number | null;
|
|
280
287
|
ids?: unknown[];
|
package/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* JSQL-NEO v6.5.
|
|
2
|
+
* JSQL-NEO v6.5.29 — Rust-Powered Embedded Database (WASM + HTTP)
|
|
3
3
|
*
|
|
4
4
|
* @example
|
|
5
5
|
* const jsql = require('jsql-neo');
|
|
@@ -153,6 +153,10 @@ module.exports = {
|
|
|
153
153
|
SQL: sql,
|
|
154
154
|
executeSQL: sql.executeSQL,
|
|
155
155
|
parseSQL: sql.parseSQL,
|
|
156
|
+
// Issue #46:index.d.ts 早就声明了这三个顶层导出,index.js 却一直没给
|
|
157
|
+
applyParams: sql.applyParams,
|
|
158
|
+
escapeValue: sql.escapeValue,
|
|
159
|
+
escapeId: sql.escapeId,
|
|
156
160
|
// AST 访问与改写(5.5.0+)
|
|
157
161
|
AST: sql.AST,
|
|
158
162
|
walk: sql.walk,
|
package/lib/ast.js
CHANGED
|
@@ -128,40 +128,47 @@ function transform(ast, fn) {
|
|
|
128
128
|
return visit(ast);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* 原地改写:fn(node) 返回值非 undefined 时替换原节点。
|
|
133
|
+
*
|
|
134
|
+
* 根节点同样可替换(#58):此前 visit 只在 `parent && key` 时才调用 fn 并回写,
|
|
135
|
+
* 根节点 parent===null 一律被跳过,于是 rewrite 无法替换整棵树,而 transform 可以。
|
|
136
|
+
* 现在数组容器本身作为父级传入(数组元素按 index 回写),根节点替换通过
|
|
137
|
+
* 返回值交回调用方。
|
|
138
|
+
*/
|
|
132
139
|
function rewrite(ast, fn) {
|
|
133
140
|
const seen = new Set();
|
|
141
|
+
let root = ast;
|
|
142
|
+
|
|
143
|
+
const assign = (parent, key, value) => {
|
|
144
|
+
if (parent === null || parent === undefined) { root = value; return; }
|
|
145
|
+
parent[key] = value;
|
|
146
|
+
};
|
|
134
147
|
|
|
135
|
-
const visit = (value, parent, key
|
|
148
|
+
const visit = (value, parent, key) => {
|
|
136
149
|
if (value === null || typeof value !== 'object') {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (r !== undefined) {
|
|
140
|
-
if (index === undefined) parent[key] = r; else parent[key][index] = r;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
150
|
+
const r = fn(value);
|
|
151
|
+
if (r !== undefined) assign(parent, key, r);
|
|
143
152
|
return;
|
|
144
153
|
}
|
|
145
154
|
if (seen.has(value)) return;
|
|
146
155
|
seen.add(value);
|
|
147
156
|
|
|
148
157
|
if (Array.isArray(value)) {
|
|
149
|
-
for (let i = 0; i < value.length; i++) visit(value[i],
|
|
158
|
+
for (let i = 0; i < value.length; i++) visit(value[i], value, i);
|
|
150
159
|
return;
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
for (const k of childKeys(value)) visit(value[k], value, k);
|
|
154
163
|
|
|
155
|
-
if (isNode(value)
|
|
164
|
+
if (isNode(value)) {
|
|
156
165
|
const r = fn(value);
|
|
157
|
-
if (r !== undefined)
|
|
158
|
-
if (index === undefined) parent[key] = r; else parent[key][index] = r;
|
|
159
|
-
}
|
|
166
|
+
if (r !== undefined) assign(parent, key, r);
|
|
160
167
|
}
|
|
161
168
|
};
|
|
162
169
|
|
|
163
170
|
visit(ast, null, null);
|
|
164
|
-
return
|
|
171
|
+
return root;
|
|
165
172
|
}
|
|
166
173
|
|
|
167
174
|
/** 语句引用的所有表名(含 JOIN / 子查询)。 */
|
package/lib/btree.js
CHANGED
|
@@ -296,31 +296,37 @@ class BTree {
|
|
|
296
296
|
remove(key, rowIndex) {
|
|
297
297
|
if (!this._root) return false;
|
|
298
298
|
const removed = this._removeFromNode(this._root, key, rowIndex);
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
299
|
+
// 返回值区分三种情况:
|
|
300
|
+
// 0 → 什么都没删(key 或 rowIndex 不存在)
|
|
301
|
+
// 1 / 'key'→ 整个键被删掉(该 key 的最后一个 rowIndex)
|
|
302
|
+
// 2 / 'row'→ 只删掉该 key 下的某一行,键仍在(#53)
|
|
303
|
+
// size 统计的是 distinct keys,所以只有「键消失」时才递减。
|
|
304
|
+
if (removed === 2) {
|
|
305
|
+
// 键仍存在,size 不变;分隔键也无需重建
|
|
306
|
+
} else if (removed) {
|
|
302
307
|
this._size--;
|
|
303
308
|
if (!this._root.leaf) this._fixSeparators(this._root);
|
|
304
309
|
}
|
|
305
310
|
if (this._root.keys.length === 0 && !this._root.leaf && this._root.children.length > 0) {
|
|
306
311
|
this._root = this._root.children[0];
|
|
307
312
|
}
|
|
308
|
-
|
|
313
|
+
// 对外契约是布尔值:删掉了行就是 true(此前重复 key 场景错误地返回 false)
|
|
314
|
+
return !!removed;
|
|
309
315
|
}
|
|
310
316
|
|
|
311
317
|
_removeFromNode(node, key, rowIndex) {
|
|
312
318
|
if (node.leaf) {
|
|
313
319
|
const i = this._lowerBound(node.keys, key);
|
|
314
|
-
if (i >= node.keys.length || node.keys[i] !== key) return
|
|
315
|
-
if (!node.values[i].includes(rowIndex)) return
|
|
320
|
+
if (i >= node.keys.length || node.keys[i] !== key) return 0;
|
|
321
|
+
if (!node.values[i].includes(rowIndex)) return 0;
|
|
316
322
|
const vals = node.values[i].filter(v => v !== rowIndex);
|
|
317
323
|
if (vals.length === 0) {
|
|
318
324
|
node.keys.splice(i, 1);
|
|
319
325
|
node.values.splice(i, 1);
|
|
320
|
-
return
|
|
326
|
+
return 1;
|
|
321
327
|
}
|
|
322
328
|
node.values[i] = vals;
|
|
323
|
-
return
|
|
329
|
+
return 2;
|
|
324
330
|
}
|
|
325
331
|
|
|
326
332
|
let i = this._route(node, key);
|
package/lib/database.js
CHANGED
|
@@ -1837,7 +1837,11 @@ class Database {
|
|
|
1837
1837
|
|
|
1838
1838
|
_resolveId(table, id) {
|
|
1839
1839
|
const pk = this._pkOf(table);
|
|
1840
|
-
if (pk)
|
|
1840
|
+
if (pk) {
|
|
1841
|
+
// Issue #44:主键查询值按列类型归一('5' → 5),与索引/insert 保持一致
|
|
1842
|
+
const key = typeof table._coerceQueryValue === 'function' ? table._coerceQueryValue(pk, id) : id;
|
|
1843
|
+
return table._rows.find(r => r[pk] === key || r[pk] === id) || null;
|
|
1844
|
+
}
|
|
1841
1845
|
// 无主键表:id 是行对象(可能是 find() 返回的浅拷贝)时,用内容匹配代替引用比较。
|
|
1842
1846
|
// 做法:逐行比对所有字段值是否相等;是数字时当 1-based 行号。
|
|
1843
1847
|
if (id && typeof id === 'object') {
|
package/lib/date-types.js
CHANGED
|
@@ -35,6 +35,25 @@ function validateDateType(value, type, fieldName) {
|
|
|
35
35
|
|
|
36
36
|
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* 解析 ISO-8601 字符串尾部残余部分里的时区标记,返回「相对 UTC 的分钟偏移」。
|
|
40
|
+
* 输入形如 'Z'、'z'、'+08:00'、'-0500'、'.500Z'、' +02:00'。
|
|
41
|
+
* 没有时区标记(或无法识别)返回 null —— 调用方按本地时间字面值处理。
|
|
42
|
+
*/
|
|
43
|
+
function parseIsoTimezone(rest) {
|
|
44
|
+
let s = String(rest == null ? '' : rest).trim();
|
|
45
|
+
// 去掉小数秒
|
|
46
|
+
if (s.startsWith('.')) s = s.replace(/^\.\d+/, '');
|
|
47
|
+
if (s === 'Z' || s === 'z') return 0;
|
|
48
|
+
const m = s.match(/^([+-])(\d{2}):?(\d{2})$/);
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const hh = parseInt(m[2], 10);
|
|
51
|
+
const mm = parseInt(m[3], 10);
|
|
52
|
+
if (hh > 23 || mm > 59) return null; // 非法偏移:交给后续校验报错
|
|
53
|
+
const sign = m[1] === '-' ? -1 : 1;
|
|
54
|
+
return sign * (hh * 60 + mm);
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
/**
|
|
39
58
|
* 本地时区的 'YYYY-MM-DD'。
|
|
40
59
|
* 不能用 toISOString().slice(0,10) —— 那是 UTC:东八区 2023-01-01 00:00 本地
|
|
@@ -115,7 +134,7 @@ function validateDateTime(value, fieldName) {
|
|
|
115
134
|
return toLocalDateTimeString(new Date(value * 1000));
|
|
116
135
|
}
|
|
117
136
|
if (typeof value === 'string') {
|
|
118
|
-
// ISO format: 2024-01-15T10:30:00.000Z
|
|
137
|
+
// ISO format: 2024-01-15T10:30:00.000Z / +08:00(可能带时区标记)
|
|
119
138
|
const isoMatch = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
|
|
120
139
|
if (isoMatch) {
|
|
121
140
|
// P2-35: 验证日期和时间有效性
|
|
@@ -129,6 +148,15 @@ function validateDateTime(value, fieldName) {
|
|
|
129
148
|
const dt = new Date(y, mo - 1, d);
|
|
130
149
|
if (dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d &&
|
|
131
150
|
h >= 0 && h < 24 && mi >= 0 && mi < 60 && s >= 0 && s < 60) {
|
|
151
|
+
// #50:带 Z / ±HH:MM 时区标记时必须换算到本地时区。
|
|
152
|
+
// 此前无条件按字面值存储,'2024-01-15T10:30:00Z'(UTC)在 UTC+8
|
|
153
|
+
// 会被存成本地 10:30,整体偏 8 小时。
|
|
154
|
+
const tz = parseIsoTimezone(value.slice(isoMatch[0].length));
|
|
155
|
+
if (tz !== null) {
|
|
156
|
+
const ms = Date.UTC(y, mo - 1, d, h, mi, s) - tz * 60000;
|
|
157
|
+
return toLocalDateTimeString(new Date(ms));
|
|
158
|
+
}
|
|
159
|
+
// 无时区标记:按本地时间字面值(MySQL 语义)
|
|
132
160
|
return datePart + ' ' + timePart;
|
|
133
161
|
}
|
|
134
162
|
}
|
package/lib/mongo_server.js
CHANGED
|
@@ -430,9 +430,16 @@ class MongoServer {
|
|
|
430
430
|
for (const d of docs) {
|
|
431
431
|
const doc2 = { ...d };
|
|
432
432
|
// 检查 _id 唯一性
|
|
433
|
+
// Issue #44:索引查找是严格相等的,空结果不能直接当「不存在」——
|
|
434
|
+
// 查询值与已存值的类型不一致时会漏判。这里再加一道按 schema 归一的直接扫描。
|
|
433
435
|
if (doc2._id !== undefined) {
|
|
434
436
|
const existing = await engine.find(coll, { _id: doc2._id });
|
|
435
437
|
if (existing.length > 0) continue; // 跳过重复 _id
|
|
438
|
+
const table = typeof engine._ensureTable === 'function' ? engine._ensureTable(coll) : null;
|
|
439
|
+
if (table && Array.isArray(table._rows) && typeof table._coerceQueryValue === 'function') {
|
|
440
|
+
const idKey = table._coerceQueryValue('_id', doc2._id);
|
|
441
|
+
if (table._rows.some((r) => r && !r._deleted && r._id === idKey)) continue;
|
|
442
|
+
}
|
|
436
443
|
}
|
|
437
444
|
await engine.insert(coll, doc2);
|
|
438
445
|
ids.push(doc2._id);
|
|
@@ -568,10 +575,13 @@ class MongoServer {
|
|
|
568
575
|
for (const [k, dir] of sortKeys) {
|
|
569
576
|
const av = a[k]; const bv = b[k];
|
|
570
577
|
if (av === bv) continue;
|
|
571
|
-
|
|
572
|
-
|
|
578
|
+
const desc = Number(dir) < 0;
|
|
579
|
+
// Issue #47:NULL 的位置随排序方向走(升序在前、降序在后),
|
|
580
|
+
// 与 MongoDB 一致;此前固定返回 1/-1,升序时 null 恒排最后。
|
|
581
|
+
if (av == null) return desc ? 1 : -1;
|
|
582
|
+
if (bv == null) return desc ? -1 : 1;
|
|
573
583
|
const cmp = av < bv ? -1 : 1;
|
|
574
|
-
return
|
|
584
|
+
return desc ? -cmp : cmp;
|
|
575
585
|
}
|
|
576
586
|
return 0;
|
|
577
587
|
});
|
|
@@ -643,8 +653,14 @@ class MongoServer {
|
|
|
643
653
|
const sum = grow.reduce((s, r) => s + (Number(r[field]) || 0), 0);
|
|
644
654
|
doc[k] = (doc[k] || 0) + sum;
|
|
645
655
|
} else if (v.$avg !== undefined) {
|
|
646
|
-
|
|
647
|
-
|
|
656
|
+
// Issue #45:null/undefined 不计入分子的同时也不计入分母;
|
|
657
|
+
// 全空组返回 null(MongoDB 语义),而不是 0。
|
|
658
|
+
const vals = grow
|
|
659
|
+
.map(r => r[field])
|
|
660
|
+
.filter(x => x !== null && x !== undefined)
|
|
661
|
+
.map(Number)
|
|
662
|
+
.filter(x => !Number.isNaN(x));
|
|
663
|
+
doc[k] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
|
|
648
664
|
} else if (v.$min !== undefined) {
|
|
649
665
|
const vals = grow.map(r => r[field]).filter(v => v != null);
|
|
650
666
|
let best = null;
|
package/lib/mysql_compat.js
CHANGED
|
@@ -20,7 +20,7 @@ function toResultPacket(r, table, rowsAsArray) {
|
|
|
20
20
|
const fields = columns.map(name => {
|
|
21
21
|
let type = 253; // VAR_STRING
|
|
22
22
|
const val = rawRows[0] ? rawRows[0][columns.indexOf(name)] : null;
|
|
23
|
-
if (typeof val === 'number') type = 8; // LONGLONG
|
|
23
|
+
if (typeof val === 'number') type = Number.isInteger(val) ? 8 : 5; // LONGLONG / DOUBLE(Issue #48:浮点列曾被误报为 8)
|
|
24
24
|
else if (typeof val === 'boolean') type = 1; // TINY
|
|
25
25
|
else if (val instanceof Date) type = 12; // DATETIME
|
|
26
26
|
else if (Buffer.isBuffer(val)) type = 252; // BLOB
|
package/lib/query.js
CHANGED
|
@@ -6,6 +6,36 @@
|
|
|
6
6
|
|
|
7
7
|
const { createError } = require('./errors');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* 稳定序列化(#55):把查询参数变成可比较的缓存键。
|
|
11
|
+
* 普通对象的键排序,函数/RegExp/BigInt/Date 保留可辨识信息,循环引用截断。
|
|
12
|
+
*/
|
|
13
|
+
function stableKey(value, seen) {
|
|
14
|
+
seen = seen || new Set();
|
|
15
|
+
if (value === null) return 'null';
|
|
16
|
+
const t = typeof value;
|
|
17
|
+
if (t === 'bigint') return value.toString() + 'n';
|
|
18
|
+
if (t === 'number') return Object.is(value, -0) ? '-0' : String(value);
|
|
19
|
+
if (t === 'string') return JSON.stringify(value);
|
|
20
|
+
if (t === 'boolean') return String(value);
|
|
21
|
+
if (t === 'undefined') return 'undefined';
|
|
22
|
+
if (t === 'function') return 'fn:' + String(value);
|
|
23
|
+
if (t === 'symbol') return 'sym:' + String(value);
|
|
24
|
+
if (value instanceof RegExp) return 're:' + value.source + '/' + value.flags;
|
|
25
|
+
if (value instanceof Date) return 'date:' + value.getTime();
|
|
26
|
+
if (seen.has(value)) return '[circular]';
|
|
27
|
+
seen.add(value);
|
|
28
|
+
let out;
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
out = '[' + value.map(v => stableKey(v, seen)).join(',') + ']';
|
|
31
|
+
} else {
|
|
32
|
+
out = '{' + Object.keys(value).sort()
|
|
33
|
+
.map(k => JSON.stringify(k) + ':' + stableKey(value[k], seen)).join(',') + '}';
|
|
34
|
+
}
|
|
35
|
+
seen.delete(value);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
9
39
|
class Query {
|
|
10
40
|
constructor(table, conditions = {}) {
|
|
11
41
|
this._table = table;
|
|
@@ -210,12 +240,46 @@ class Query {
|
|
|
210
240
|
// 缓存
|
|
211
241
|
// ============================================================
|
|
212
242
|
|
|
243
|
+
/**
|
|
244
|
+
* 缓存键(#55):必须覆盖所有影响结果的参数。
|
|
245
|
+
* 旧实现只按 TTL 判断,同一 Query 实例换 where/orderBy/limit/offset 后
|
|
246
|
+
* 仍命中旧缓存,返回过期结果。
|
|
247
|
+
*/
|
|
248
|
+
_cacheKey(depth = 0) {
|
|
249
|
+
if (depth > 5) return '[deep]';
|
|
250
|
+
const joins = this._joins.map(j => ({
|
|
251
|
+
type: j.type,
|
|
252
|
+
table: j.table && (j.table._name || j.table.name) || j.table,
|
|
253
|
+
localField: j.localField,
|
|
254
|
+
foreignField: j.foreignField,
|
|
255
|
+
as: j.as,
|
|
256
|
+
}));
|
|
257
|
+
return stableKey({
|
|
258
|
+
table: this._table && this._table._name,
|
|
259
|
+
conditions: this._conditions,
|
|
260
|
+
orderBy: this._orderBy,
|
|
261
|
+
limit: this._limitCount,
|
|
262
|
+
offset: this._offsetCount,
|
|
263
|
+
select: this._selectFields,
|
|
264
|
+
groupBy: this._groupBy,
|
|
265
|
+
having: this._having,
|
|
266
|
+
distinct: this._distinct,
|
|
267
|
+
distinctField: this._distinctField,
|
|
268
|
+
joins,
|
|
269
|
+
union: this._union ? this._union._cacheKey(depth + 1) : null,
|
|
270
|
+
caseExpr: this._caseExpr,
|
|
271
|
+
caseAs: this._caseAs,
|
|
272
|
+
window: this._window,
|
|
273
|
+
forUpdate: this._forUpdate,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
213
277
|
/**
|
|
214
278
|
* 缓存查询结果
|
|
215
279
|
* @param {number} ttlMs - 缓存有效期(毫秒),默认 60000
|
|
216
280
|
*/
|
|
217
281
|
cache(ttlMs = 60000) {
|
|
218
|
-
this._cache = { ttl: ttlMs, result: null, timestamp: 0 };
|
|
282
|
+
this._cache = { ttl: ttlMs, result: null, timestamp: 0, key: null };
|
|
219
283
|
return this;
|
|
220
284
|
}
|
|
221
285
|
|
|
@@ -227,8 +291,11 @@ class Query {
|
|
|
227
291
|
const startTime = Date.now();
|
|
228
292
|
if (this._explain) return this._getExplain();
|
|
229
293
|
|
|
294
|
+
// 缓存键要包含 where/orderBy/limit/offset 等全部参数(#55)
|
|
295
|
+
const cacheKey = this._cache ? this._cacheKey() : null;
|
|
230
296
|
// 缓存命中: 返回每行的浅拷贝,与未命中路径一致,避免调用方修改污染缓存
|
|
231
|
-
if (this._cache && this._cache.result &&
|
|
297
|
+
if (this._cache && this._cache.result && this._cache.key === cacheKey &&
|
|
298
|
+
(Date.now() - this._cache.timestamp < this._cache.ttl)) {
|
|
232
299
|
// #37:命中返回副本,而不是内部数组引用——调用方对返回值做变更
|
|
233
300
|
// (push/splice/改字段)会污染缓存;未命中路径返回的是新数组,行为应一致。
|
|
234
301
|
return this._cache.result.map(row => (row && typeof row === 'object' ? { ...row } : row));
|
|
@@ -334,6 +401,7 @@ class Query {
|
|
|
334
401
|
if (this._cache) {
|
|
335
402
|
this._cache.result = rows.map(row => (row && typeof row === 'object' ? { ...row } : row));
|
|
336
403
|
this._cache.timestamp = Date.now();
|
|
404
|
+
this._cache.key = cacheKey;
|
|
337
405
|
}
|
|
338
406
|
|
|
339
407
|
this._table._db._logSlowQuery(`get ${this._table._name}`, Date.now() - startTime, rows.length);
|
|
@@ -428,6 +496,7 @@ class Query {
|
|
|
428
496
|
if (this._cache) {
|
|
429
497
|
this._cache.result = null;
|
|
430
498
|
this._cache.timestamp = 0;
|
|
499
|
+
this._cache.key = null;
|
|
431
500
|
}
|
|
432
501
|
return this;
|
|
433
502
|
}
|