@maiyunnet/kebab 9.19.1 → 9.20.0
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/doc/kebab-rag.md +614 -187
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/lib/sql.d.ts +15 -0
- package/lib/sql.js +60 -1
- package/lib/text.d.ts +13 -0
- package/lib/text.js +36 -0
- package/package.json +1 -1
- package/sys/mod.d.ts +32 -0
- package/sys/mod.js +83 -0
- package/www/example/ctr/test.js +163 -1
package/index.d.ts
CHANGED
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* --- 本文件用来定义每个目录实体地址的常量 ---
|
|
7
7
|
*/
|
|
8
8
|
/** --- 当前系统版本号 --- */
|
|
9
|
-
export const VER = '9.
|
|
9
|
+
export const VER = '9.20.0';
|
|
10
10
|
// --- 服务端用的路径 ---
|
|
11
11
|
const imu = decodeURIComponent(import.meta.url).replace('file://', '').replace(/^\/(\w:)/, '$1');
|
|
12
12
|
/** --- /xxx/xxx --- */
|
package/lib/sql.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare enum ESERVICE {
|
|
|
10
10
|
'MYSQL' = 0,
|
|
11
11
|
'PGSQL' = 1
|
|
12
12
|
}
|
|
13
|
+
/** --- 非空游标值;大整数和日期请使用数据库可比较的字符串 --- */
|
|
14
|
+
export type TCursorValue = string | number;
|
|
13
15
|
/** --- JSON 查询操作符 --- */
|
|
14
16
|
export declare enum EJSON {
|
|
15
17
|
/** --- 包含值 (MySQL: JSON_CONTAINS, PG: @>) --- */
|
|
@@ -209,6 +211,15 @@ export declare class Sql {
|
|
|
209
211
|
* @param d 排序规则
|
|
210
212
|
*/
|
|
211
213
|
by(c: string | Array<string | string[]>, d?: 'DESC' | 'ASC'): this;
|
|
214
|
+
/**
|
|
215
|
+
* --- 按非空、同向排序字段定位游标并设置排序;字段组合须唯一且有匹配索引 ---
|
|
216
|
+
* --- 使用行比较,让 PostgreSQL 可以从复合索引边界开始扫描 ---
|
|
217
|
+
* @param c 排序字段,支持表别名;不支持表达式、GROUP BY 或 UNION
|
|
218
|
+
* @param values 上一页末行的字段值,省略时读取首批
|
|
219
|
+
* @param d 扫描方向;读取前一批时反转方向并在取回后反转列表
|
|
220
|
+
* @returns 当前 SQL 对象
|
|
221
|
+
*/
|
|
222
|
+
seek(c: string | string[], values?: TCursorValue[], d?: 'DESC' | 'ASC'): this;
|
|
212
223
|
/**
|
|
213
224
|
* --- GROUP BY ---
|
|
214
225
|
* @param c 字段字符串或数组
|
|
@@ -230,6 +241,10 @@ export declare class Sql {
|
|
|
230
241
|
*/
|
|
231
242
|
copy(f?: string | string[], opt?: {
|
|
232
243
|
'where'?: string | kebab.Json;
|
|
244
|
+
/** --- false 时移除本查询的排序,不修改子查询 --- */
|
|
245
|
+
'order'?: false;
|
|
246
|
+
/** --- false 时移除本查询的 LIMIT/OFFSET,不修改子查询 --- */
|
|
247
|
+
'limit'?: false;
|
|
233
248
|
}): Sql;
|
|
234
249
|
/**
|
|
235
250
|
* --- 获取 sql 语句 ---
|
package/lib/sql.js
CHANGED
|
@@ -55,6 +55,7 @@ export class Sql {
|
|
|
55
55
|
this._service = opt.service ?? ESERVICE.MYSQL;
|
|
56
56
|
if (opt.data) {
|
|
57
57
|
this._data = opt.data;
|
|
58
|
+
this._placeholderCounter = opt.data.length + 1;
|
|
58
59
|
}
|
|
59
60
|
if (opt.sql) {
|
|
60
61
|
this._sql = opt.sql;
|
|
@@ -770,6 +771,54 @@ export class Sql {
|
|
|
770
771
|
this._sql.push(sql);
|
|
771
772
|
return this;
|
|
772
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* --- 按非空、同向排序字段定位游标并设置排序;字段组合须唯一且有匹配索引 ---
|
|
776
|
+
* --- 使用行比较,让 PostgreSQL 可以从复合索引边界开始扫描 ---
|
|
777
|
+
* @param c 排序字段,支持表别名;不支持表达式、GROUP BY 或 UNION
|
|
778
|
+
* @param values 上一页末行的字段值,省略时读取首批
|
|
779
|
+
* @param d 扫描方向;读取前一批时反转方向并在取回后反转列表
|
|
780
|
+
* @returns 当前 SQL 对象
|
|
781
|
+
*/
|
|
782
|
+
seek(c, values, d = 'DESC') {
|
|
783
|
+
const fields = typeof c === 'string' ? [c] : c;
|
|
784
|
+
if (!fields.length || new Set(fields).size !== fields.length ||
|
|
785
|
+
fields.some(field => !/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/.test(field)) ||
|
|
786
|
+
!['ASC', 'DESC'].includes(d)) {
|
|
787
|
+
throw new Error('Invalid cursor ordering');
|
|
788
|
+
}
|
|
789
|
+
if (this._sql.some(part => /^ (ORDER BY|LIMIT|GROUP BY|HAVING|UNION|FOR UPDATE)\b/i.test(part))) {
|
|
790
|
+
throw new Error('seek requires an ungrouped query without ordering or pagination');
|
|
791
|
+
}
|
|
792
|
+
if (values !== undefined) {
|
|
793
|
+
if (values.length !== fields.length || values.some(value => (typeof value !== 'string' && typeof value !== 'number') ||
|
|
794
|
+
(typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value)))))) {
|
|
795
|
+
throw new Error('Invalid cursor values; use strings for large integers');
|
|
796
|
+
}
|
|
797
|
+
const columns = fields.map(field => this.field(field));
|
|
798
|
+
const placeholders = values.map(() => this._placeholder());
|
|
799
|
+
const left = columns.length === 1 ? columns[0] : `(${columns.join(', ')})`;
|
|
800
|
+
const right = placeholders.length === 1 ? placeholders[0] : `(${placeholders.join(', ')})`;
|
|
801
|
+
let condition = `${left} ${d === 'DESC' ? '<' : '>'} ${right}`;
|
|
802
|
+
if (columns.length > 1) {
|
|
803
|
+
// --- 显式收紧首列边界,避免已有较宽范围条件让 PG 在索引内遍历到行比较边界 ---
|
|
804
|
+
condition += ` AND ${columns[0]} ${d === 'DESC' ? '<=' : '>='} ${this._placeholder()}`;
|
|
805
|
+
}
|
|
806
|
+
const index = this._sql.findIndex(part => part.startsWith(' WHERE '));
|
|
807
|
+
if (index === -1) {
|
|
808
|
+
this._whereDataPosition[0] = this._data.length;
|
|
809
|
+
this._sql.push(' WHERE ' + condition);
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
this._sql[index] = ` WHERE (${this._sql[index].slice(7)}) AND ${condition}`;
|
|
813
|
+
}
|
|
814
|
+
this._data.push(...values);
|
|
815
|
+
if (columns.length > 1) {
|
|
816
|
+
this._data.push(values[0]);
|
|
817
|
+
}
|
|
818
|
+
this._whereDataPosition[1] = this._data.length;
|
|
819
|
+
}
|
|
820
|
+
return this.by(fields, d);
|
|
821
|
+
}
|
|
773
822
|
/**
|
|
774
823
|
* --- GROUP BY ---
|
|
775
824
|
* @param c 字段字符串或数组
|
|
@@ -918,7 +967,13 @@ export class Sql {
|
|
|
918
967
|
sql[i] = sql[i].replace(/\$\d+/g, () => `$${counter++}`);
|
|
919
968
|
}
|
|
920
969
|
}
|
|
921
|
-
|
|
970
|
+
for (let i = sql.length - 1; i >= 0; --i) {
|
|
971
|
+
if ((opt.order === false && sql[i].startsWith(' ORDER BY ')) ||
|
|
972
|
+
(opt.limit === false && sql[i].startsWith(' LIMIT '))) {
|
|
973
|
+
sql.splice(i, 1);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const result = get({
|
|
922
977
|
'service': this._service,
|
|
923
978
|
'ctr': this._ctr,
|
|
924
979
|
'pre': this._pre,
|
|
@@ -926,6 +981,10 @@ export class Sql {
|
|
|
926
981
|
'sql': sql,
|
|
927
982
|
'alias': lCore.clone(this._alias),
|
|
928
983
|
}).hint(this._hint);
|
|
984
|
+
if (opt.where === undefined) {
|
|
985
|
+
result._whereDataPosition = [...this._whereDataPosition];
|
|
986
|
+
}
|
|
987
|
+
return result;
|
|
929
988
|
}
|
|
930
989
|
// --- 操作 ---
|
|
931
990
|
/**
|
package/lib/text.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import * as kebab from '#kebab/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* --- 解析三段正式发行版本 X.Y.Z,不接受前导零、预发行和构建后缀 ---
|
|
4
|
+
* @param value 待解析的版本
|
|
5
|
+
* @returns 三段安全整数,无效时返回 null
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseVersion(value: unknown): [number, number, number] | null;
|
|
8
|
+
/**
|
|
9
|
+
* --- 逐段比较三段正式发行版本 X.Y.Z,不按字符串或浮点数比较 ---
|
|
10
|
+
* @param version 待比较的版本
|
|
11
|
+
* @param target 目标版本
|
|
12
|
+
* @returns 小于返回 -1,等于返回 0,大于返回 1;任一版本无效时返回 null
|
|
13
|
+
*/
|
|
14
|
+
export declare function compareVersion(version: unknown, target: unknown): -1 | 0 | 1 | null;
|
|
2
15
|
/**
|
|
3
16
|
* --- 将文件大小格式化为带单位的字符串 ---
|
|
4
17
|
* @param size 文件大小
|
package/lib/text.js
CHANGED
|
@@ -7,6 +7,42 @@ import * as net from 'net';
|
|
|
7
7
|
import * as kebab from '#kebab/index.js';
|
|
8
8
|
import * as lFs from './fs.js';
|
|
9
9
|
import * as lCore from './core.js';
|
|
10
|
+
/**
|
|
11
|
+
* --- 解析三段正式发行版本 X.Y.Z,不接受前导零、预发行和构建后缀 ---
|
|
12
|
+
* @param value 待解析的版本
|
|
13
|
+
* @returns 三段安全整数,无效时返回 null
|
|
14
|
+
*/
|
|
15
|
+
export function parseVersion(value) {
|
|
16
|
+
if ((typeof value !== 'string') || (value.length > 64)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value);
|
|
20
|
+
// --- $ 可匹配末尾换行前的位置,必须额外确认匹配完整输入 ---
|
|
21
|
+
if (match?.[0] !== value) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
25
|
+
return parts.every(Number.isSafeInteger) ? parts : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* --- 逐段比较三段正式发行版本 X.Y.Z,不按字符串或浮点数比较 ---
|
|
29
|
+
* @param version 待比较的版本
|
|
30
|
+
* @param target 目标版本
|
|
31
|
+
* @returns 小于返回 -1,等于返回 0,大于返回 1;任一版本无效时返回 null
|
|
32
|
+
*/
|
|
33
|
+
export function compareVersion(version, target) {
|
|
34
|
+
const current = parseVersion(version);
|
|
35
|
+
const other = parseVersion(target);
|
|
36
|
+
if (!current || !other) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
for (let index = 0; index < current.length; index++) {
|
|
40
|
+
if (current[index] !== other[index]) {
|
|
41
|
+
return current[index] < other[index] ? -1 : 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
10
46
|
/**
|
|
11
47
|
* --- 将文件大小格式化为带单位的字符串 ---
|
|
12
48
|
* @param size 文件大小
|
package/package.json
CHANGED
package/sys/mod.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ import * as kebab from '#kebab/index.js';
|
|
|
11
11
|
export type TOnlyProperties<T> = {
|
|
12
12
|
[K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
|
|
13
13
|
};
|
|
14
|
+
/** --- 一批游标查询结果,hasMore 表示扫描方向上还有记录 --- */
|
|
15
|
+
export interface ICursorPage<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
16
|
+
'list': T[];
|
|
17
|
+
'hasMore': boolean;
|
|
18
|
+
'next': lSql.TCursorValue[] | null;
|
|
19
|
+
'previous': lSql.TCursorValue[] | null;
|
|
20
|
+
}
|
|
14
21
|
/** --- 条数列表 --- */
|
|
15
22
|
export declare class Rows<T extends Mod> implements IRows<T> {
|
|
16
23
|
private readonly _items;
|
|
@@ -375,6 +382,31 @@ export default class Mod {
|
|
|
375
382
|
* --- 获取总条数,自动抛弃 LIMIT,仅用于获取数据的情况(select) ---
|
|
376
383
|
*/
|
|
377
384
|
total(f?: string): Promise<number>;
|
|
385
|
+
/**
|
|
386
|
+
* --- 从 PostgreSQL 执行计划估算查询总行数,移除外层排序和分页,不执行数据扫描 ---
|
|
387
|
+
* --- 请在应用游标条件前调用;非 PostgreSQL、框架分表联查或查询失败返回 false,绝不回退到 COUNT ---
|
|
388
|
+
* @returns 近似行数或 false;估算可能偏差很大,不能用于判断有无下一批
|
|
389
|
+
*/
|
|
390
|
+
totalEstimate(): Promise<number | false>;
|
|
391
|
+
/**
|
|
392
|
+
* --- 读取一批游标数据,多取一条判断后续记录,不使用 OFFSET,不修改原查询 ---
|
|
393
|
+
* --- 排序字段必须全部非空、同向,且组合唯一;SELECT 中须包含对应字段(同名或指定 keys) ---
|
|
394
|
+
* @param count 本批数量
|
|
395
|
+
* @param opt 排序、边界和方向;before 为 true 时读取前一批并恢复展示顺序
|
|
396
|
+
* @param opt.by 排序字段,支持表别名
|
|
397
|
+
* @param opt.keys SELECT 结果中对应的字段名,默认去掉 by 的表别名
|
|
398
|
+
* @param opt.order 展示顺序,默认 DESC
|
|
399
|
+
* @param opt.cursor 边界行的字段值,首批省略
|
|
400
|
+
* @param opt.before 是否读取边界前一批,须提供 cursor
|
|
401
|
+
* @returns 列表、首末行游标和扫描方向上的 hasMore,数据库错误返回 false
|
|
402
|
+
*/
|
|
403
|
+
allCursor<T extends Record<string, unknown> = Record<string, unknown>>(count: number, opt: {
|
|
404
|
+
'by': string | string[];
|
|
405
|
+
'keys'?: string[];
|
|
406
|
+
'order'?: 'DESC' | 'ASC';
|
|
407
|
+
'cursor'?: lSql.TCursorValue[];
|
|
408
|
+
'before'?: boolean;
|
|
409
|
+
}): Promise<ICursorPage<T> | false>;
|
|
378
410
|
/**
|
|
379
411
|
* --- 根据当前条件,筛选出当前条目该有的数据条数 ---
|
|
380
412
|
*/
|
package/sys/mod.js
CHANGED
|
@@ -1262,6 +1262,89 @@ export default class Mod {
|
|
|
1262
1262
|
}
|
|
1263
1263
|
return count;
|
|
1264
1264
|
}
|
|
1265
|
+
/**
|
|
1266
|
+
* --- 从 PostgreSQL 执行计划估算查询总行数,移除外层排序和分页,不执行数据扫描 ---
|
|
1267
|
+
* --- 请在应用游标条件前调用;非 PostgreSQL、框架分表联查或查询失败返回 false,绝不回退到 COUNT ---
|
|
1268
|
+
* @returns 近似行数或 false;估算可能偏差很大,不能用于判断有无下一批
|
|
1269
|
+
*/
|
|
1270
|
+
async totalEstimate() {
|
|
1271
|
+
if (this._db.getService() !== lDb.ESERVICE.PGSQL || this._index !== null || this._contain) {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
const sql = this._sql.copy(undefined, { 'order': false, 'limit': false });
|
|
1275
|
+
const result = await this._db.query('EXPLAIN (FORMAT JSON) ' + sql.getSql(), sql.getData());
|
|
1276
|
+
if (result.rows === null) {
|
|
1277
|
+
lCore.log(this._ctr ?? {}, '[MOD][totalEstimate] Query failed', '-error');
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
const plan = result.rows[0]?.['QUERY PLAN'];
|
|
1281
|
+
const parsed = typeof plan === 'string' ? lText.parseJson(plan) : plan;
|
|
1282
|
+
if (!Array.isArray(parsed)) {
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
const root = parsed[0];
|
|
1286
|
+
if (!root || typeof root !== 'object') {
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
const node = root['Plan'];
|
|
1290
|
+
if (!node || typeof node !== 'object') {
|
|
1291
|
+
return false;
|
|
1292
|
+
}
|
|
1293
|
+
const rows = node['Plan Rows'];
|
|
1294
|
+
return typeof rows === 'number' && Number.isFinite(rows) && rows >= 0 ? Math.round(rows) : false;
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* --- 读取一批游标数据,多取一条判断后续记录,不使用 OFFSET,不修改原查询 ---
|
|
1298
|
+
* --- 排序字段必须全部非空、同向,且组合唯一;SELECT 中须包含对应字段(同名或指定 keys) ---
|
|
1299
|
+
* @param count 本批数量
|
|
1300
|
+
* @param opt 排序、边界和方向;before 为 true 时读取前一批并恢复展示顺序
|
|
1301
|
+
* @param opt.by 排序字段,支持表别名
|
|
1302
|
+
* @param opt.keys SELECT 结果中对应的字段名,默认去掉 by 的表别名
|
|
1303
|
+
* @param opt.order 展示顺序,默认 DESC
|
|
1304
|
+
* @param opt.cursor 边界行的字段值,首批省略
|
|
1305
|
+
* @param opt.before 是否读取边界前一批,须提供 cursor
|
|
1306
|
+
* @returns 列表、首末行游标和扫描方向上的 hasMore,数据库错误返回 false
|
|
1307
|
+
*/
|
|
1308
|
+
async allCursor(count, opt) {
|
|
1309
|
+
if (!Number.isSafeInteger(count) || count < 1 || count >= Number.MAX_SAFE_INTEGER ||
|
|
1310
|
+
this._index !== null || this._contain || (opt.before && !opt.cursor) ||
|
|
1311
|
+
(opt.order !== undefined && !['ASC', 'DESC'].includes(opt.order))) {
|
|
1312
|
+
throw new Error('Invalid cursor page options; framework sharding and contain are unsupported');
|
|
1313
|
+
}
|
|
1314
|
+
const fields = typeof opt.by === 'string' ? [opt.by] : opt.by;
|
|
1315
|
+
const keys = opt.keys ?? fields.map(field => field.split('.').pop());
|
|
1316
|
+
if (keys.length !== fields.length || new Set(keys).size !== keys.length) {
|
|
1317
|
+
throw new Error('Cursor result keys must match ordering fields');
|
|
1318
|
+
}
|
|
1319
|
+
const order = opt.order ?? 'DESC';
|
|
1320
|
+
const direction = opt.before ? (order === 'DESC' ? 'ASC' : 'DESC') : order;
|
|
1321
|
+
const sql = this._sql.copy(undefined, { 'order': false, 'limit': false });
|
|
1322
|
+
sql.seek(fields, opt.cursor, direction).limit(count + 1);
|
|
1323
|
+
const result = await this._db.query(sql.getSql(), sql.getData());
|
|
1324
|
+
if (result.rows === null) {
|
|
1325
|
+
lCore.log(this._ctr ?? {}, '[MOD][allCursor] Query failed', '-error');
|
|
1326
|
+
return false;
|
|
1327
|
+
}
|
|
1328
|
+
const hasMore = result.rows.length > count;
|
|
1329
|
+
const list = result.rows.slice(0, count);
|
|
1330
|
+
if (opt.before) {
|
|
1331
|
+
list.reverse();
|
|
1332
|
+
}
|
|
1333
|
+
const boundary = (row) => keys.map(key => {
|
|
1334
|
+
const value = row[key];
|
|
1335
|
+
if ((typeof value !== 'string' && typeof value !== 'number') ||
|
|
1336
|
+
(typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))) {
|
|
1337
|
+
throw new Error('Cursor columns must be non-null strings or safe numbers');
|
|
1338
|
+
}
|
|
1339
|
+
return value;
|
|
1340
|
+
});
|
|
1341
|
+
return {
|
|
1342
|
+
'list': list,
|
|
1343
|
+
'hasMore': hasMore,
|
|
1344
|
+
'next': list.length ? boundary(list[list.length - 1]) : null,
|
|
1345
|
+
'previous': list.length ? boundary(list[0]) : null,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1265
1348
|
/**
|
|
1266
1349
|
* --- 根据当前条件,筛选出当前条目该有的数据条数 ---
|
|
1267
1350
|
*/
|
package/www/example/ctr/test.js
CHANGED
|
@@ -226,6 +226,7 @@ export default class extends sCtr.Ctr {
|
|
|
226
226
|
`<br><a href="${this._config.const.urlBase}test/sql?type=json">View "test/sql?type=json"</a> <a href="${this._config.const.urlBase}test/sql?type=json&s=pgsql">pgsql</a>`,
|
|
227
227
|
`<br><a href="${this._config.const.urlBase}test/sql?type=having">View "test/sql?type=having"</a> <a href="${this._config.const.urlBase}test/sql?type=having&s=pgsql">pgsql</a>`,
|
|
228
228
|
`<br><a href="${this._config.const.urlBase}test/sql?type=by">View "test/sql?type=by"</a> <a href="${this._config.const.urlBase}test/sql?type=by&s=pgsql">pgsql</a>`,
|
|
229
|
+
`<br><a href="${this._config.const.urlBase}test/sql?type=seek">View "test/sql?type=seek"</a> <a href="${this._config.const.urlBase}test/sql?type=seek&s=pgsql">pgsql</a>`,
|
|
229
230
|
`<br><a href="${this._config.const.urlBase}test/sql?type=field">View "test/sql?type=field"</a> <a href="${this._config.const.urlBase}test/sql?type=field&s=pgsql">pgsql</a>`,
|
|
230
231
|
`<br><a href="${this._config.const.urlBase}test/sql?type=hint">View "test/sql?type=hint"</a>`,
|
|
231
232
|
'<br><br><b>Consistent:</b>',
|
|
@@ -803,6 +804,10 @@ test.set({
|
|
|
803
804
|
});
|
|
804
805
|
const result = await test.create();
|
|
805
806
|
JSON.stringify(result));</pre>` + JSON.stringify(result));
|
|
807
|
+
if (result !== true) {
|
|
808
|
+
echo.push(`<p>The new test row was not inserted. The fixed test names and tokens may already be in use;
|
|
809
|
+
the cursor demo below reads existing test rows.</p>`);
|
|
810
|
+
}
|
|
806
811
|
echo.push('<pre>JSON.stringify(test.toArray());</pre>' + lText.htmlescape(JSON.stringify(test.toArray())));
|
|
807
812
|
echo.push('<br><br>Test table:');
|
|
808
813
|
stmt = await db.query(this._get['s'] === 'pgsql' ?
|
|
@@ -836,6 +841,90 @@ const r = await ls.explain();</pre>` + lText.htmlescape(JSON.stringify(r)));
|
|
|
836
841
|
else {
|
|
837
842
|
echo.push('<div>false</div>');
|
|
838
843
|
}
|
|
844
|
+
// --- 近似总数与游标分页,复用原始筛选,翻页不重复估算 ---
|
|
845
|
+
// --- 固定名称和 token 的测试数据可能已占满,不能依赖本次新增成功或只取最近五分钟 ---
|
|
846
|
+
const isPgsql = db.getService() === lDb.ESERVICE.PGSQL;
|
|
847
|
+
const query = mTest.select(db, [
|
|
848
|
+
// --- PG int8 可能超出 Number 精度,投影成文本,排序仍使用原表字段 ---
|
|
849
|
+
isPgsql ? 't.id::text id' : 't.id', 't.name', 't.time_add',
|
|
850
|
+
], {
|
|
851
|
+
'ctr': this,
|
|
852
|
+
'pre': isPgsql ? 'm' : undefined,
|
|
853
|
+
'alias': 't',
|
|
854
|
+
}).filter([
|
|
855
|
+
['t.token', 'LIKE', 'test_%'],
|
|
856
|
+
['t.time_add', '<=', time],
|
|
857
|
+
]);
|
|
858
|
+
const estimated = await query.totalEstimate();
|
|
859
|
+
echo.push(`<br><b>Approximate total / cursor pagination:</b>
|
|
860
|
+
<p>totalEstimate() is PostgreSQL only; false means unavailable, not zero.
|
|
861
|
+
The estimate can be inaccurate; use hasMore to decide whether to fetch the next batch.
|
|
862
|
+
Keep filters and the ending time fixed while paging. Cursor fields must be non-null and jointly unique;
|
|
863
|
+
use a (time_add, id) index for large tables.
|
|
864
|
+
This demo includes existing test rows up to the fixed ending time, without a five-minute restriction.</p>
|
|
865
|
+
<pre>const isPgsql = db.getService() === lDb.ESERVICE.PGSQL;
|
|
866
|
+
const query = mTest.select<mTest>(db, [
|
|
867
|
+
isPgsql ? 't.id::text id' : 't.id', 't.name', 't.time_add',
|
|
868
|
+
], {
|
|
869
|
+
'ctr': this,
|
|
870
|
+
'pre': isPgsql ? 'm' : undefined,
|
|
871
|
+
'alias': 't',
|
|
872
|
+
}).filter([
|
|
873
|
+
['t.token', 'LIKE', 'test_%'],
|
|
874
|
+
['t.time_add', '<=', time],
|
|
875
|
+
]);
|
|
876
|
+
const estimated = await query.totalEstimate();</pre>
|
|
877
|
+
<b>estimated:</b> ${lText.htmlescape(lText.stringifyJson(estimated))}`);
|
|
878
|
+
const first = await query.allCursor(2, {
|
|
879
|
+
'by': ['t.time_add', 't.id'],
|
|
880
|
+
'order': 'DESC',
|
|
881
|
+
});
|
|
882
|
+
echo.push(`<pre>const first = await query.allCursor(2, {
|
|
883
|
+
'by': ['t.time_add', 't.id'],
|
|
884
|
+
'order': 'DESC',
|
|
885
|
+
});</pre><b>first:</b><pre>${lText.htmlescape(lText.stringifyJson(first, 4))}</pre>`);
|
|
886
|
+
if (first === false) {
|
|
887
|
+
echo.push('<p>First batch query failed.</p>');
|
|
888
|
+
}
|
|
889
|
+
else if (first.hasMore && first.next) {
|
|
890
|
+
const second = await query.allCursor(2, {
|
|
891
|
+
'by': ['t.time_add', 't.id'],
|
|
892
|
+
'order': 'DESC',
|
|
893
|
+
'cursor': first.next,
|
|
894
|
+
});
|
|
895
|
+
echo.push(`<pre>if (first !== false && first.hasMore && first.next) {
|
|
896
|
+
const second = await query.allCursor(2, {
|
|
897
|
+
'by': ['t.time_add', 't.id'],
|
|
898
|
+
'order': 'DESC',
|
|
899
|
+
'cursor': first.next,
|
|
900
|
+
});
|
|
901
|
+
}</pre><b>second:</b><pre>${lText.htmlescape(lText.stringifyJson(second, 4))}</pre>`);
|
|
902
|
+
if (second === false) {
|
|
903
|
+
echo.push('<p>Next batch query failed.</p>');
|
|
904
|
+
}
|
|
905
|
+
else if (second.previous) {
|
|
906
|
+
const previous = await query.allCursor(2, {
|
|
907
|
+
'by': ['t.time_add', 't.id'],
|
|
908
|
+
'order': 'DESC',
|
|
909
|
+
'cursor': second.previous,
|
|
910
|
+
'before': true,
|
|
911
|
+
});
|
|
912
|
+
echo.push(`<pre>if (second !== false && second.previous) {
|
|
913
|
+
const previous = await query.allCursor(2, {
|
|
914
|
+
'by': ['t.time_add', 't.id'],
|
|
915
|
+
'order': 'DESC',
|
|
916
|
+
'cursor': second.previous,
|
|
917
|
+
'before': true,
|
|
918
|
+
});
|
|
919
|
+
}</pre><b>previous:</b><pre>${lText.htmlescape(lText.stringifyJson(previous, 4))}</pre>
|
|
920
|
+
<p>With before: true, rows stay in DESC display order;
|
|
921
|
+
hasMore now indicates whether there are more rows before this batch.</p>`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
echo.push(`<p>No next batch. The first list already contains the complete result;
|
|
926
|
+
its length is the exact total for this query.</p>`);
|
|
927
|
+
}
|
|
839
928
|
let ft = await mTest.one(db, [
|
|
840
929
|
['time_add', '>', '0']
|
|
841
930
|
], {
|
|
@@ -3233,6 +3322,69 @@ Result:<pre id="result">Nothing.</pre>`);
|
|
|
3233
3322
|
<b>format() :</b> ${sql.format(s, sd)}`);
|
|
3234
3323
|
break;
|
|
3235
3324
|
}
|
|
3325
|
+
case 'seek': {
|
|
3326
|
+
echo.push(`<p><b>Without cursor values, seek() and by() generate the same first-batch SQL.</b>
|
|
3327
|
+
With cursor values, seek() adds a WHERE boundary and sets ORDER BY. Compare the two next-batch examples below:
|
|
3328
|
+
by() + OFFSET skips rows by position, while seek() continues after the preceding batch's last row.</p>
|
|
3329
|
+
<p>by() only sets ORDER BY;
|
|
3330
|
+
do not call by() again after seek(). Call seek() before limit().
|
|
3331
|
+
Cursor fields must be non-null and jointly unique. Pass large integer IDs as strings.</p>`);
|
|
3332
|
+
sql.select(['t.id', 't.time_add'], 'test t').where([
|
|
3333
|
+
['t.time_add', '>=', 1700000000],
|
|
3334
|
+
['t.time_add', '<=', 1700600000],
|
|
3335
|
+
]);
|
|
3336
|
+
echo.push(`<pre>sql.select(['t.id', 't.time_add'], 'test t').where([
|
|
3337
|
+
['t.time_add', '>=', 1700000000],
|
|
3338
|
+
['t.time_add', '<=', 1700600000],
|
|
3339
|
+
]);</pre>`);
|
|
3340
|
+
// --- 同一筛选条件,对比普通排序与游标定位 ---
|
|
3341
|
+
const sorted = sql.copy().by(['t.time_add', 't.id'], 'DESC').limit(2);
|
|
3342
|
+
echo.push(`<b>by(): sort only</b>
|
|
3343
|
+
<pre>const sorted = sql.copy().by(['t.time_add', 't.id'], 'DESC').limit(2);</pre>
|
|
3344
|
+
<b>getSql():</b> ${lText.htmlescape(sorted.getSql())}<br>
|
|
3345
|
+
<b>getData():</b><pre>${lText.htmlescape(lText.stringifyJson(sorted.getData(), 4))}</pre>
|
|
3346
|
+
<p>This always reads the first two rows of the current matching result.</p><hr>`);
|
|
3347
|
+
const offsetNext = sql.copy().by(['t.time_add', 't.id'], 'DESC').limit(2, 2);
|
|
3348
|
+
echo.push(`<b>by() + OFFSET: next batch by position</b>
|
|
3349
|
+
<pre>const offsetNext = sql.copy().by(['t.time_add', 't.id'], 'DESC').limit(2, 2);</pre>
|
|
3350
|
+
<b>getSql():</b> ${lText.htmlescape(offsetNext.getSql())}<br>
|
|
3351
|
+
<b>getData():</b><pre>${lText.htmlescape(lText.stringifyJson(offsetNext.getData(), 4))}</pre>
|
|
3352
|
+
<p>limit(offset, count) skips the first two matching rows. Larger offsets require skipping more rows;
|
|
3353
|
+
new rows inserted before that position can shift subsequent pages.</p><hr>`);
|
|
3354
|
+
const next = sql.copy().seek(['t.time_add', 't.id'], [1700500000, '9007199254740993']).limit(2);
|
|
3355
|
+
echo.push(`<b>seek() with a cursor: next batch after a row</b>
|
|
3356
|
+
<pre>const next = sql.copy().seek(
|
|
3357
|
+
['t.time_add', 't.id'], [1700500000, '9007199254740993']
|
|
3358
|
+
).limit(2);</pre>
|
|
3359
|
+
<b>getSql():</b> ${lText.htmlescape(next.getSql())}<br>
|
|
3360
|
+
<b>getData():</b><pre>${lText.htmlescape(lText.stringifyJson(next.getData(), 4))}</pre>
|
|
3361
|
+
<p>This adds a WHERE boundary based on the last row's time_add and id, with no OFFSET.
|
|
3362
|
+
Use the actual last row from the preceding batch as the cursor; the values here are illustrative.
|
|
3363
|
+
A matching composite index lets the database locate that boundary.</p><hr>`);
|
|
3364
|
+
const first = sql.copy().seek(['t.time_add', 't.id']).limit(2);
|
|
3365
|
+
echo.push(`<b>seek() without a cursor: same SQL as by() for the first batch</b>
|
|
3366
|
+
<pre>const first = sql.copy().seek(['t.time_add', 't.id']).limit(2);</pre>
|
|
3367
|
+
<b>getSql():</b> ${lText.htmlescape(first.getSql())}<br>
|
|
3368
|
+
<b>getData():</b><pre>${lText.htmlescape(lText.stringifyJson(first.getData(), 4))}</pre>
|
|
3369
|
+
<p>Without a cursor, seek() generates the same SQL as the by() first-batch example above.
|
|
3370
|
+
It is the cursor values in the preceding seek() example that add the WHERE boundary.</p><hr>`);
|
|
3371
|
+
const previous = sql.copy().seek(['t.time_add', 't.id'], [1700500000, '9007199254740993'], 'ASC').limit(2);
|
|
3372
|
+
echo.push(`<pre>const previous = sql.copy().seek(
|
|
3373
|
+
['t.time_add', 't.id'], [1700500000, '9007199254740993'], 'ASC'
|
|
3374
|
+
).limit(2);</pre>
|
|
3375
|
+
<b>getSql():</b> ${lText.htmlescape(previous.getSql())}<br>
|
|
3376
|
+
<b>getData():</b><pre>${lText.htmlescape(lText.stringifyJson(previous.getData(), 4))}</pre>
|
|
3377
|
+
<p>SQL seek() scans the previous batch in ASC order; reverse the rows for DESC display.
|
|
3378
|
+
Model allCursor(..., { before: true }) handles that reversal automatically.</p><hr>`);
|
|
3379
|
+
const unpaged = first.copy(undefined, { 'order': false, 'limit': false });
|
|
3380
|
+
echo.push(`<pre>const unpaged = first.copy(undefined, { 'order': false, 'limit': false });</pre>
|
|
3381
|
+
<b>getSql():</b> ${lText.htmlescape(unpaged.getSql())}<br>
|
|
3382
|
+
<b>getData():</b><pre>${lText.htmlescape(lText.stringifyJson(unpaged.getData(), 4))}</pre>
|
|
3383
|
+
<b>first remains:</b> ${lText.htmlescape(first.getSql())}<br><br>
|
|
3384
|
+
copy() removes the outer ordering and pagination without changing the original.<br>
|
|
3385
|
+
It keeps WHERE conditions, including any cursor boundary; estimate the base filter before adding a cursor.`);
|
|
3386
|
+
break;
|
|
3387
|
+
}
|
|
3236
3388
|
case 'field': {
|
|
3237
3389
|
echo.push(`<pre>sql.field('abc');</pre>` + sql.field('abc'));
|
|
3238
3390
|
echo.push(`<pre>sql.field('abc', 'a_');</pre>` + sql.field('abc', 'a_'));
|
|
@@ -4512,7 +4664,17 @@ function doUpload() {
|
|
|
4512
4664
|
*/
|
|
4513
4665
|
_getEnd() {
|
|
4514
4666
|
const rt = this._getRunTime();
|
|
4515
|
-
return 'Processed in ' + rt.toString() + ' second(s), ' + (Math.round(rt * 10000000) / 10000).toString() + 'ms, ' + (Math.round(this._getMemoryUsage() / 1024 * 100) / 100).toString() +
|
|
4667
|
+
return 'Processed in ' + rt.toString() + ' second(s), ' + (Math.round(rt * 10000000) / 10000).toString() + 'ms, ' + (Math.round(this._getMemoryUsage() / 1024 * 100) / 100).toString() + ` K.
|
|
4668
|
+
<style>
|
|
4669
|
+
body{margin:20px;font-family:system-ui,-apple-system,"Segoe UI","Noto Sans",sans-serif;font-size:16px;line-height:1.65;color:#1f2937;background:#fff;overflow-wrap:anywhere;}
|
|
4670
|
+
pre,code,kbd,samp{font-family:ui-monospace,"Cascadia Code","SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:15px;line-height:1.6;}
|
|
4671
|
+
pre{margin:16px 0;padding:14px 16px;border:1px solid #e5e7eb;border-radius:6px;background:#f3f4f6;white-space:pre-wrap;word-break:normal;}
|
|
4672
|
+
b,strong,th{font-weight:600;}
|
|
4673
|
+
input,button,select,textarea{font:inherit;}
|
|
4674
|
+
hr{margin:24px 0;border:0;border-top:1px solid #d1d5db;}
|
|
4675
|
+
td,th{padding:8px 10px;border:1px solid #d1d5db;}
|
|
4676
|
+
@media(max-width:600px){body{margin:12px;}pre{padding:12px;}}
|
|
4677
|
+
</style><meta name="viewport" content="width=device-width,initial-scale=1">`;
|
|
4516
4678
|
}
|
|
4517
4679
|
}
|
|
4518
4680
|
// --- 下面是测试代码 ---
|