@ai0x0/utils 0.6.1 → 0.7.1
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/.agents/skills/ai0x0-utils/backend/SKILL.md +8 -5
- package/SKILL.md +8 -5
- package/es/backend/actions/create-get-action.js +11 -7
- package/es/backend/actions/create-get-list-action.js +11 -6
- package/es/backend/actions/get-list-data.d.ts +1 -1
- package/es/backend/actions/get-list-data.js +12 -5
- package/es/backend/actions/get-list-query-core.d.ts +26 -5
- package/es/backend/actions/get-list-query-core.js +92 -22
- package/es/backend/actions/get-list-query.d.ts +2 -0
- package/es/backend/actions/get-list-query.js +7 -4
- package/es/backend/actions/index.d.ts +1 -0
- package/es/backend/actions/index.js +2 -1
- package/es/backend/actions/paging.d.ts +11 -0
- package/es/backend/actions/paging.js +32 -0
- package/es/backend/route-operation/write-operation-core.js +12 -5
- package/es/backend/schemas/common.d.ts +2 -2
- package/es/backend/schemas/common.js +1 -1
- package/es/backend/sqlite/actions/create-get-action.js +9 -6
- package/es/backend/sqlite/actions/create-get-list-action.js +7 -5
- package/es/backend/sqlite/actions/get-list-data.d.ts +1 -1
- package/es/backend/sqlite/actions/get-list-data.js +11 -5
- package/es/backend/sqlite/actions/get-list-query.d.ts +2 -0
- package/es/backend/sqlite/actions/get-list-query.js +10 -6
- package/eslint-rules/api-route-via-nrf.js +83 -0
- package/eslint-rules/index.js +14 -1
- package/eslint-rules/no-direct-api-url.js +55 -0
- package/lib/backend/actions/create-get-action.js +3 -1
- package/lib/backend/actions/create-get-list-action.js +3 -1
- package/lib/backend/actions/get-list-data.d.ts +1 -1
- package/lib/backend/actions/get-list-data.js +8 -3
- package/lib/backend/actions/get-list-query-core.d.ts +26 -5
- package/lib/backend/actions/get-list-query-core.js +73 -18
- package/lib/backend/actions/get-list-query.d.ts +2 -0
- package/lib/backend/actions/get-list-query.js +3 -3
- package/lib/backend/actions/index.d.ts +1 -0
- package/lib/backend/actions/index.js +7 -2
- package/lib/backend/actions/paging.d.ts +11 -0
- package/lib/backend/actions/paging.js +50 -0
- package/lib/backend/schemas/common.d.ts +2 -2
- package/lib/backend/schemas/common.js +1 -1
- package/lib/backend/sqlite/actions/create-get-action.js +3 -1
- package/lib/backend/sqlite/actions/create-get-list-action.js +3 -1
- package/lib/backend/sqlite/actions/get-list-data.d.ts +1 -1
- package/lib/backend/sqlite/actions/get-list-data.js +8 -3
- package/lib/backend/sqlite/actions/get-list-query.d.ts +2 -0
- package/lib/backend/sqlite/actions/get-list-query.js +3 -3
- package/package.json +1 -1
|
@@ -18,14 +18,16 @@ export function createGetAction(_ref) {
|
|
|
18
18
|
table = _ref.table;
|
|
19
19
|
return /*#__PURE__*/function () {
|
|
20
20
|
var _ref3 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(params, _ref2) {
|
|
21
|
-
var scope, fields, _i,
|
|
21
|
+
var scope, exposed, fields, _i, _exposed, key, field, _getListQuery, query, countQuery, _yield$getListData, _yield$getListData$da, result;
|
|
22
22
|
return _regeneratorRuntime().wrap(function _callee$(_context) {
|
|
23
23
|
while (1) switch (_context.prev = _context.next) {
|
|
24
24
|
case 0:
|
|
25
25
|
scope = _ref2.scope;
|
|
26
|
+
// 筛选面与可见面同源,理由见 backend/actions/create-get-list-action 里的同一段。
|
|
27
|
+
exposed = Object.keys(bodySchema.shape);
|
|
26
28
|
fields = {};
|
|
27
|
-
for (_i = 0,
|
|
28
|
-
key =
|
|
29
|
+
for (_i = 0, _exposed = exposed; _i < _exposed.length; _i++) {
|
|
30
|
+
key = _exposed[_i];
|
|
29
31
|
field = table[key];
|
|
30
32
|
if (field) {
|
|
31
33
|
fields[key] = field;
|
|
@@ -34,13 +36,14 @@ export function createGetAction(_ref) {
|
|
|
34
36
|
_getListQuery = getListQuery({
|
|
35
37
|
db: db,
|
|
36
38
|
fields: fields,
|
|
39
|
+
filterable: exposed,
|
|
37
40
|
jsonArrayFields: jsonArrayFields,
|
|
38
41
|
params: params,
|
|
39
42
|
scope: scope,
|
|
40
43
|
relations: relations,
|
|
41
44
|
table: table
|
|
42
45
|
}), query = _getListQuery.query, countQuery = _getListQuery.countQuery;
|
|
43
|
-
_context.next =
|
|
46
|
+
_context.next = 7;
|
|
44
47
|
return getListData({
|
|
45
48
|
bodySchema: bodySchema,
|
|
46
49
|
countQuery: countQuery,
|
|
@@ -48,12 +51,12 @@ export function createGetAction(_ref) {
|
|
|
48
51
|
pageSize: 1,
|
|
49
52
|
query: query
|
|
50
53
|
})();
|
|
51
|
-
case
|
|
54
|
+
case 7:
|
|
52
55
|
_yield$getListData = _context.sent;
|
|
53
56
|
_yield$getListData$da = _slicedToArray(_yield$getListData.data, 1);
|
|
54
57
|
result = _yield$getListData$da[0];
|
|
55
58
|
return _context.abrupt("return", result);
|
|
56
|
-
case
|
|
59
|
+
case 11:
|
|
57
60
|
case "end":
|
|
58
61
|
return _context.stop();
|
|
59
62
|
}
|
|
@@ -15,15 +15,16 @@ export function createGetListAction(_ref) {
|
|
|
15
15
|
table = _ref.table;
|
|
16
16
|
return /*#__PURE__*/function () {
|
|
17
17
|
var _ref3 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee(params, _ref2) {
|
|
18
|
-
var scope, _ref4, current, pageSize, other, fields, _i,
|
|
18
|
+
var scope, _ref4, current, pageSize, other, exposed, fields, _i, _exposed, key, field, _getListQuery, query, countQuery;
|
|
19
19
|
return _regeneratorRuntime().wrap(function _callee$(_context) {
|
|
20
20
|
while (1) switch (_context.prev = _context.next) {
|
|
21
21
|
case 0:
|
|
22
22
|
scope = _ref2.scope;
|
|
23
|
-
_ref4 = params, current = _ref4.current, pageSize = _ref4.pageSize, other = _objectWithoutProperties(_ref4, _excluded);
|
|
23
|
+
_ref4 = params, current = _ref4.current, pageSize = _ref4.pageSize, other = _objectWithoutProperties(_ref4, _excluded); // 筛选面与可见面同源,理由见 backend/actions/create-get-list-action 里的同一段。
|
|
24
|
+
exposed = Object.keys(bodySchema.shape);
|
|
24
25
|
fields = {};
|
|
25
|
-
for (_i = 0,
|
|
26
|
-
key =
|
|
26
|
+
for (_i = 0, _exposed = exposed; _i < _exposed.length; _i++) {
|
|
27
|
+
key = _exposed[_i];
|
|
27
28
|
field = table[key];
|
|
28
29
|
if (field) {
|
|
29
30
|
fields[key] = field;
|
|
@@ -32,6 +33,7 @@ export function createGetListAction(_ref) {
|
|
|
32
33
|
_getListQuery = getListQuery({
|
|
33
34
|
db: db,
|
|
34
35
|
fields: fields,
|
|
36
|
+
filterable: exposed,
|
|
35
37
|
jsonArrayFields: jsonArrayFields,
|
|
36
38
|
params: other,
|
|
37
39
|
scope: scope,
|
|
@@ -45,7 +47,7 @@ export function createGetListAction(_ref) {
|
|
|
45
47
|
pageSize: pageSize,
|
|
46
48
|
query: query
|
|
47
49
|
})());
|
|
48
|
-
case
|
|
50
|
+
case 7:
|
|
49
51
|
case "end":
|
|
50
52
|
return _context.stop();
|
|
51
53
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getListQuery } from "./get-list-query.js";
|
|
2
|
-
export declare function getListData({ query, countQuery, pageSize, current, }: {
|
|
2
|
+
export declare function getListData({ query, countQuery, pageSize: rawPageSize, current: rawCurrent, }: {
|
|
3
3
|
bodySchema?: any;
|
|
4
4
|
current?: number;
|
|
5
5
|
pageSize?: number;
|
|
@@ -8,6 +8,7 @@ function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" !=
|
|
|
8
8
|
function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
|
|
9
9
|
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
|
|
10
10
|
function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
|
|
11
|
+
import { normalizePaging } from "../../actions/paging.js";
|
|
11
12
|
function runSelect(_x) {
|
|
12
13
|
return _runSelect.apply(this, arguments);
|
|
13
14
|
}
|
|
@@ -42,16 +43,21 @@ function _runSelect() {
|
|
|
42
43
|
export function getListData(_ref) {
|
|
43
44
|
var query = _ref.query,
|
|
44
45
|
countQuery = _ref.countQuery,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
rawPageSize = _ref.pageSize,
|
|
47
|
+
rawCurrent = _ref.current;
|
|
48
|
+
// 归一化的理由见 backend/actions/paging。
|
|
49
|
+
var _normalizePaging = normalizePaging({
|
|
50
|
+
current: rawCurrent,
|
|
51
|
+
pageSize: rawPageSize
|
|
52
|
+
}),
|
|
53
|
+
current = _normalizePaging.current,
|
|
54
|
+
pageSize = _normalizePaging.pageSize;
|
|
49
55
|
return /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee() {
|
|
50
56
|
var dataQuery, data, _yield$runSelect, _yield$runSelect2, count;
|
|
51
57
|
return _regeneratorRuntime().wrap(function _callee$(_context) {
|
|
52
58
|
while (1) switch (_context.prev = _context.next) {
|
|
53
59
|
case 0:
|
|
54
|
-
dataQuery = query.limit(
|
|
60
|
+
dataQuery = query.limit(pageSize).offset((current - 1) * pageSize);
|
|
55
61
|
_context.next = 3;
|
|
56
62
|
return runSelect(dataQuery);
|
|
57
63
|
case 3:
|
|
@@ -5,6 +5,8 @@ import { type ListQueryParams } from "../../actions/get-list-query-core.js";
|
|
|
5
5
|
export declare function getListQuery<TTable extends BaseTable, TSelection extends SelectedFields>(args: {
|
|
6
6
|
db: any;
|
|
7
7
|
fields: TSelection;
|
|
8
|
+
/** 允许被当成筛选条件的键。不传等于不限制,见核心里的说明。 */
|
|
9
|
+
filterable?: readonly string[];
|
|
8
10
|
jsonArrayFields?: string[];
|
|
9
11
|
/** 必填。与 pg 那套同义,见 backend/scope.ts。 */
|
|
10
12
|
scope: ScopeArg;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var _templateObject;
|
|
1
|
+
var _templateObject, _templateObject2;
|
|
2
2
|
function _taggedTemplateLiteral(strings, raw) { if (!raw) { raw = strings.slice(0); } return Object.freeze(Object.defineProperties(strings, { raw: { value: Object.freeze(raw) } })); }
|
|
3
3
|
// ==============================================================================
|
|
4
4
|
// 列表查询(SQLite)
|
|
@@ -6,16 +6,20 @@ function _taggedTemplateLiteral(strings, raw) { if (!raw) { raw = strings.slice(
|
|
|
6
6
|
// 实现在 backend/actions/get-list-query-core,与 pg 那套共用。这里只给它两条 sqlite 专属的
|
|
7
7
|
// 原语:sqlite 的 like 本来就不区分大小写(没有 ilike),JSON 数组用 json_each 展开。
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { sql } from "drizzle-orm";
|
|
10
10
|
import { createGetListQuery } from "../../actions/get-list-query-core.js";
|
|
11
|
+
|
|
12
|
+
// pattern 由核心转义好再传进来(见 escapeLikePattern),但**转义符要自己声明**:sqlite 的 LIKE
|
|
13
|
+
// 不带 ESCAPE 子句时一个转义符都没有,那样转义等于没做,`%` 照旧是通配符。这也是这里不能用
|
|
14
|
+
// drizzle 的 like() 助手的原因 —— 它拼不出 ESCAPE。
|
|
11
15
|
var run = createGetListQuery({
|
|
12
|
-
contains: function contains(column,
|
|
13
|
-
return
|
|
16
|
+
contains: function contains(column, pattern) {
|
|
17
|
+
return sql(_templateObject || (_templateObject = _taggedTemplateLiteral(["", " LIKE ", " ESCAPE '\\'"], ["", " LIKE ", " ESCAPE '\\\\'"])), column, pattern);
|
|
14
18
|
},
|
|
15
19
|
// json_each 展开出来的行里,值在 tag.value 上(pg 那个 _text 变体是直接给字符串的)——
|
|
16
20
|
// 这是两边唯一一处连字段名都不同的地方。
|
|
17
|
-
jsonArrayContains: function jsonArrayContains(column,
|
|
18
|
-
return sql(
|
|
21
|
+
jsonArrayContains: function jsonArrayContains(column, pattern) {
|
|
22
|
+
return sql(_templateObject2 || (_templateObject2 = _taggedTemplateLiteral(["\n EXISTS (\n SELECT 1 FROM json_each(", ") tag\n WHERE tag.value LIKE ", " ESCAPE '\\'\n )\n "], ["\n EXISTS (\n SELECT 1 FROM json_each(", ") tag\n WHERE tag.value LIKE ", " ESCAPE '\\\\'\n )\n "])), column, pattern);
|
|
19
23
|
}
|
|
20
24
|
});
|
|
21
25
|
export function getListQuery(args) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api-route-via-nrf
|
|
3
|
+
* -----------------
|
|
4
|
+
* API 路由一律走 next-rest-framework(route / routeOperation),不许裸导 HTTP 方法。
|
|
5
|
+
*
|
|
6
|
+
* 裸写 `export async function POST(req)` 的代价不是风格不统一,是这条端点从此不存在于
|
|
7
|
+
* openapi.json:生成器不会给它出 client,CLI / agent 读不到它,前端只能手写 fetch 直连
|
|
8
|
+
* (那是 no-direct-api-url 拦的另一头)。框架统一做的鉴权预热、错误翻译、响应校验,
|
|
9
|
+
* 它也都得自己记得补。
|
|
10
|
+
*
|
|
11
|
+
* 判的是「导出形状」,不是「有没有 import route」:
|
|
12
|
+
* 报:`export async function POST(...)`、`export const GET = handler`;
|
|
13
|
+
* 放:`export const { POST } = route({...})`(从 route() 解构,id 是 ObjectPattern)。
|
|
14
|
+
*
|
|
15
|
+
* 作用范围由调用方在 flat config 里用 files 圈定(比如只在 api 目录的 route.ts 上开);
|
|
16
|
+
* 确有理由裸导的文件(第三方库的整体转发,如 better-auth 的 catch-all)用 files override
|
|
17
|
+
* 单独关掉,并在那个文件里写明理由。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const HTTP_METHODS = new Set([
|
|
21
|
+
"GET",
|
|
22
|
+
"POST",
|
|
23
|
+
"PUT",
|
|
24
|
+
"DELETE",
|
|
25
|
+
"PATCH",
|
|
26
|
+
"HEAD",
|
|
27
|
+
"OPTIONS",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
31
|
+
const rule = {
|
|
32
|
+
meta: {
|
|
33
|
+
type: "problem",
|
|
34
|
+
docs: {
|
|
35
|
+
description:
|
|
36
|
+
"API 路由必须用 next-rest-framework 的 route()/routeOperation() 构建,禁止裸导 HTTP 方法。",
|
|
37
|
+
},
|
|
38
|
+
schema: [],
|
|
39
|
+
messages: {
|
|
40
|
+
rawMethodExport:
|
|
41
|
+
"API 路由不许裸导 HTTP 方法,改用 next-rest-framework 的 route()/routeOperation" +
|
|
42
|
+
"(export const { POST } = route({...}))。裸导的端点进不了 openapi.json," +
|
|
43
|
+
"前端与 CLI 都拿不到它的 client。",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
create(context) {
|
|
47
|
+
return {
|
|
48
|
+
ExportNamedDeclaration(node) {
|
|
49
|
+
const declaration = node.declaration;
|
|
50
|
+
if (!declaration) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
declaration.type === "FunctionDeclaration" &&
|
|
55
|
+
declaration.id &&
|
|
56
|
+
HTTP_METHODS.has(declaration.id.name)
|
|
57
|
+
) {
|
|
58
|
+
context.report({
|
|
59
|
+
node: declaration.id,
|
|
60
|
+
messageId: "rawMethodExport",
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (declaration.type === "VariableDeclaration") {
|
|
65
|
+
for (const declarator of declaration.declarations) {
|
|
66
|
+
// 从 route() 解构出来的是 ObjectPattern,那才叫走框架。
|
|
67
|
+
if (
|
|
68
|
+
declarator.id.type === "Identifier" &&
|
|
69
|
+
HTTP_METHODS.has(declarator.id.name)
|
|
70
|
+
) {
|
|
71
|
+
context.report({
|
|
72
|
+
node: declarator.id,
|
|
73
|
+
messageId: "rawMethodExport",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default rule;
|
package/eslint-rules/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import noThen from "./no-then.js";
|
|
|
12
12
|
import noOneLetterVars from "./no-one-letter-vars.js";
|
|
13
13
|
import noConsecutiveSetState from "./no-consecutive-setstate.js";
|
|
14
14
|
import maxLines from "./max-lines.js";
|
|
15
|
+
import apiRouteViaNrf from "./api-route-via-nrf.js";
|
|
16
|
+
import noDirectApiUrl from "./no-direct-api-url.js";
|
|
15
17
|
|
|
16
18
|
const rules = {
|
|
17
19
|
"require-section-divider": requireSectionDivider,
|
|
@@ -25,8 +27,17 @@ const rules = {
|
|
|
25
27
|
"no-one-letter-vars": noOneLetterVars,
|
|
26
28
|
"no-consecutive-setstate": noConsecutiveSetState,
|
|
27
29
|
"max-lines": maxLines,
|
|
30
|
+
"api-route-via-nrf": apiRouteViaNrf,
|
|
31
|
+
"no-direct-api-url": noDirectApiUrl,
|
|
28
32
|
};
|
|
29
33
|
|
|
34
|
+
// 这两条只管「特定位置」的代码,不能跟着 recommended 全局开:
|
|
35
|
+
// · api-route-via-nrf 只该挂在 API 路由文件上(别的文件 export 一个叫 GET 的函数是自由的);
|
|
36
|
+
// · no-direct-api-url 只该挂在前端目录上(后端与中间件里出现 "/api/..." 字面量是本分)。
|
|
37
|
+
// 调用方在 flat config 里用 files 圈定范围自行开启,豁免文件用 files override 关掉,
|
|
38
|
+
// 用法写在两条规则各自的文件头。
|
|
39
|
+
const SCOPED_RULES = new Set(["api-route-via-nrf", "no-direct-api-url"]);
|
|
40
|
+
|
|
30
41
|
const plugin = {
|
|
31
42
|
rules,
|
|
32
43
|
configs: {
|
|
@@ -34,7 +45,9 @@ const plugin = {
|
|
|
34
45
|
plugins: { ai0x0: { rules } },
|
|
35
46
|
rules: {
|
|
36
47
|
...Object.fromEntries(
|
|
37
|
-
Object.keys(rules)
|
|
48
|
+
Object.keys(rules)
|
|
49
|
+
.filter((name) => !SCOPED_RULES.has(name))
|
|
50
|
+
.map((name) => [`ai0x0/${name}`, "error"]),
|
|
38
51
|
),
|
|
39
52
|
curly: "error",
|
|
40
53
|
"ai0x0/max-lines": [
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* no-direct-api-url
|
|
3
|
+
* -----------------
|
|
4
|
+
* 前端不许手写 "/api/..." 地址直连后端,一律走生成的 client。
|
|
5
|
+
*
|
|
6
|
+
* 手写 `fetch("/api/xxx")` 绕掉的不是一层方便,是一整套集中处理(401 重登、错误提示、
|
|
7
|
+
* 作用域头、重复请求去重),而且 TypeScript 对它是瞎的 —— 路由改了形状,编译不红。
|
|
8
|
+
* 所以要调一个端点:先让它进 openapi.json(后端路由走 nrf,见 api-route-via-nrf),
|
|
9
|
+
* 重新生成 client,再调生成出来的方法。
|
|
10
|
+
*
|
|
11
|
+
* 判的是字符串字面量与模板串的开头:`/api/`。注释与 JSDoc 不是 AST 节点,天然不报。
|
|
12
|
+
* 作用范围由调用方在 flat config 里用 files 圈定(比如只挂前端目录),client 封装层
|
|
13
|
+
* 自己(生成文件与拦截器)用 files override 关掉 —— 地址本来就该只写在那儿。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const API_PREFIX = "/api/";
|
|
17
|
+
|
|
18
|
+
function startsWithApi(value) {
|
|
19
|
+
return typeof value === "string" && value.startsWith(API_PREFIX);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
23
|
+
const rule = {
|
|
24
|
+
meta: {
|
|
25
|
+
type: "problem",
|
|
26
|
+
docs: {
|
|
27
|
+
description:
|
|
28
|
+
'前端禁止手写 "/api/..." 地址直连后端,必须走生成的 client。',
|
|
29
|
+
},
|
|
30
|
+
schema: [],
|
|
31
|
+
messages: {
|
|
32
|
+
directApiUrl:
|
|
33
|
+
'前端不许手写 "/api/..." 直连后端:走生成的 client。端点先进 openapi.json' +
|
|
34
|
+
"(后端路由走 nrf),重新生成 client 之后调生成出来的方法;401 重登、错误提示、" +
|
|
35
|
+
"作用域头都在那一层集中处理,手写 fetch 全部绕开。",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
create(context) {
|
|
39
|
+
return {
|
|
40
|
+
Literal(node) {
|
|
41
|
+
if (startsWithApi(node.value)) {
|
|
42
|
+
context.report({ node, messageId: "directApiUrl" });
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
TemplateLiteral(node) {
|
|
46
|
+
// 只看第一段静态文本:`${base}/api/x` 的地址不归这条管(那是封装层的拼法)。
|
|
47
|
+
if (startsWithApi(node.quasis[0]?.value.cooked)) {
|
|
48
|
+
context.report({ node, messageId: "directApiUrl" });
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default rule;
|
|
@@ -32,8 +32,9 @@ function createGetAction({
|
|
|
32
32
|
table
|
|
33
33
|
}) {
|
|
34
34
|
return async (params, { scope }) => {
|
|
35
|
+
const exposed = Object.keys(bodySchema.shape);
|
|
35
36
|
const fields = {};
|
|
36
|
-
for (const key of
|
|
37
|
+
for (const key of exposed) {
|
|
37
38
|
const field = table[key];
|
|
38
39
|
if (field) {
|
|
39
40
|
fields[key] = field;
|
|
@@ -42,6 +43,7 @@ function createGetAction({
|
|
|
42
43
|
const { query, countQuery } = (0, import_get_list_query.getListQuery)({
|
|
43
44
|
db,
|
|
44
45
|
fields,
|
|
46
|
+
filterable: exposed,
|
|
45
47
|
jsonArrayFields,
|
|
46
48
|
params,
|
|
47
49
|
relations,
|
|
@@ -33,8 +33,9 @@ function createGetListAction({
|
|
|
33
33
|
}) {
|
|
34
34
|
return async (params, { scope }) => {
|
|
35
35
|
const { current, pageSize, ...other } = params;
|
|
36
|
+
const exposed = Object.keys(bodySchema.shape);
|
|
36
37
|
const fields = {};
|
|
37
|
-
for (const key of
|
|
38
|
+
for (const key of exposed) {
|
|
38
39
|
const field = table[key];
|
|
39
40
|
if (field) {
|
|
40
41
|
fields[key] = field;
|
|
@@ -43,6 +44,7 @@ function createGetListAction({
|
|
|
43
44
|
const { query, countQuery } = (0, import_get_list_query.getListQuery)({
|
|
44
45
|
db,
|
|
45
46
|
fields,
|
|
47
|
+
filterable: exposed,
|
|
46
48
|
jsonArrayFields,
|
|
47
49
|
params: other,
|
|
48
50
|
relations,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ZodSchema } from "zod";
|
|
2
2
|
import { getListQuery } from "./get-list-query.js";
|
|
3
|
-
export declare function getListData<B extends ZodSchema>({ query, countQuery, pageSize, current, bodySchema, }: {
|
|
3
|
+
export declare function getListData<B extends ZodSchema>({ query, countQuery, pageSize: rawPageSize, current: rawCurrent, bodySchema, }: {
|
|
4
4
|
bodySchema: B;
|
|
5
5
|
current?: number;
|
|
6
6
|
pageSize?: number;
|
|
@@ -24,20 +24,25 @@ __export(get_list_data_exports, {
|
|
|
24
24
|
module.exports = __toCommonJS(get_list_data_exports);
|
|
25
25
|
var import_next_rest_framework = require("next-rest-framework");
|
|
26
26
|
var import_schemas = require("../schemas");
|
|
27
|
+
var import_paging = require("./paging");
|
|
27
28
|
function getListData({
|
|
28
29
|
query,
|
|
29
30
|
countQuery,
|
|
30
|
-
pageSize
|
|
31
|
-
current
|
|
31
|
+
pageSize: rawPageSize,
|
|
32
|
+
current: rawCurrent,
|
|
32
33
|
bodySchema
|
|
33
34
|
}) {
|
|
35
|
+
const { current, pageSize } = (0, import_paging.normalizePaging)({
|
|
36
|
+
current: rawCurrent,
|
|
37
|
+
pageSize: rawPageSize
|
|
38
|
+
});
|
|
34
39
|
return (0, import_next_rest_framework.rpcOperation)().outputs([
|
|
35
40
|
{
|
|
36
41
|
body: (0, import_schemas.listBodySchema)(bodySchema),
|
|
37
42
|
contentType: "application/json"
|
|
38
43
|
}
|
|
39
44
|
]).handler(async () => {
|
|
40
|
-
const data = await query.limit(
|
|
45
|
+
const data = await query.limit(pageSize).offset((current - 1) * pageSize).execute();
|
|
41
46
|
const [{ count }] = await countQuery.execute();
|
|
42
47
|
return { data, total: Number(count) };
|
|
43
48
|
});
|
|
@@ -15,10 +15,14 @@ export interface ListQueryRelation {
|
|
|
15
15
|
}
|
|
16
16
|
/** 两套方言之间**全部**的差异。加第三种数据库时要写的也只有这两条。 */
|
|
17
17
|
export interface ListQueryDialect {
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
/**
|
|
19
|
+
* 大小写不敏感的「包含」。收到的 pattern 是**已经转义、已经带好 `%` 的完整模式串** ——
|
|
20
|
+
* 转义收在核心里做(见 escapeLikePattern),免得加第三种方言时漏掉那一步:漏掉不会报错,
|
|
21
|
+
* 只会让 `%` 重新变回通配符。
|
|
22
|
+
*/
|
|
23
|
+
contains(_column: Column, _pattern: string): SQL;
|
|
24
|
+
/** 「这个 JSON 数组列里有没有元素匹配这个模式」。pattern 同上,已转义。 */
|
|
25
|
+
jsonArrayContains(_column: Column, _pattern: string): SQL;
|
|
22
26
|
}
|
|
23
27
|
export interface ListQueryParams {
|
|
24
28
|
[field: string]: unknown;
|
|
@@ -27,9 +31,26 @@ export interface ListQueryParams {
|
|
|
27
31
|
page?: number;
|
|
28
32
|
pageSize?: number;
|
|
29
33
|
}
|
|
30
|
-
|
|
34
|
+
/**
|
|
35
|
+
* 把用户输入变成 LIKE 模式里的**字面量**。
|
|
36
|
+
*
|
|
37
|
+
* 不转义的话 `?name=%` 就是「匹配全部」、`?name=a_c` 里的下划线是「任意一个字符」—— 前者只是
|
|
38
|
+
* 筛不准,后者配合「能按某一列筛」就是一个逐字符试探值的通道。反斜杠自己也要转,否则
|
|
39
|
+
* `?name=\` 会把后面那个 `%` 吃掉。
|
|
40
|
+
*/
|
|
41
|
+
export declare function escapeLikePattern(keyword: string): string;
|
|
42
|
+
export declare function createGetListQuery(dialect: ListQueryDialect): <TTable extends ListQueryTable, TSelection extends Record<string, unknown>>({ db, fields, filterable, jsonArrayFields, params, relations, scope, table, }: {
|
|
31
43
|
db: any;
|
|
32
44
|
fields: TSelection;
|
|
45
|
+
/**
|
|
46
|
+
* 允许被当成筛选条件的键。**不传等于不限制**(直接调 getListQuery 的老调用方行为不变)。
|
|
47
|
+
*
|
|
48
|
+
* 为什么需要它:筛选是按列名在表上找列的,而那跟「这个端点返回哪些列」完全脱钩 —— 于是
|
|
49
|
+
* 一个只返回 id / title 的列表,照样可以 `?secret=sk-a` 让数据库去 `ilike '%sk-a%'`,再从
|
|
50
|
+
* 返回的 total 上把这一列逐字符读出来。列表工厂传进来的是响应 schema 的字段集,也就是
|
|
51
|
+
* 「调用方本来就读得到的那些列」,筛选面与可见面因此对齐。
|
|
52
|
+
*/
|
|
53
|
+
filterable?: readonly string[] | undefined;
|
|
33
54
|
jsonArrayFields?: string[] | undefined;
|
|
34
55
|
/**
|
|
35
56
|
* 行级作用域。**必填**,不想隔离就显式传 NO_SCOPE —— 写成可选的话,漏传就退化成查全表,
|
|
@@ -19,18 +19,32 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// src/backend/actions/get-list-query-core.ts
|
|
20
20
|
var get_list_query_core_exports = {};
|
|
21
21
|
__export(get_list_query_core_exports, {
|
|
22
|
-
createGetListQuery: () => createGetListQuery
|
|
22
|
+
createGetListQuery: () => createGetListQuery,
|
|
23
|
+
escapeLikePattern: () => escapeLikePattern
|
|
23
24
|
});
|
|
24
25
|
module.exports = __toCommonJS(get_list_query_core_exports);
|
|
25
26
|
var import_drizzle_orm = require("drizzle-orm");
|
|
27
|
+
var import_errors = require("../errors");
|
|
26
28
|
var import_scope = require("../scope");
|
|
29
|
+
var import_paging = require("./paging");
|
|
30
|
+
function escapeLikePattern(keyword) {
|
|
31
|
+
return `%${keyword.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
|
|
32
|
+
}
|
|
27
33
|
function columnAt(table, key) {
|
|
28
|
-
|
|
34
|
+
const value = table[key];
|
|
35
|
+
return (0, import_drizzle_orm.is)(value, import_drizzle_orm.Column) ? value : void 0;
|
|
36
|
+
}
|
|
37
|
+
function isIdKey(key) {
|
|
38
|
+
return key === "id" || key.endsWith("Id");
|
|
39
|
+
}
|
|
40
|
+
function isFuzzyMatchable(column) {
|
|
41
|
+
return column.dataType === "string" && !/uuid|enum/i.test(column.columnType);
|
|
29
42
|
}
|
|
30
43
|
function createGetListQuery(dialect) {
|
|
31
44
|
return function getListQuery({
|
|
32
45
|
db,
|
|
33
46
|
fields,
|
|
47
|
+
filterable,
|
|
34
48
|
jsonArrayFields,
|
|
35
49
|
params,
|
|
36
50
|
relations,
|
|
@@ -38,12 +52,18 @@ function createGetListQuery(dialect) {
|
|
|
38
52
|
table
|
|
39
53
|
}) {
|
|
40
54
|
const {
|
|
41
|
-
page
|
|
42
|
-
pageSize
|
|
43
|
-
orderBy
|
|
55
|
+
page,
|
|
56
|
+
pageSize: rawPageSize,
|
|
57
|
+
orderBy,
|
|
44
58
|
orderDir = "desc",
|
|
45
59
|
...filters
|
|
46
60
|
} = params;
|
|
61
|
+
const { current, pageSize } = (0, import_paging.normalizePaging)({
|
|
62
|
+
current: page,
|
|
63
|
+
pageSize: rawPageSize
|
|
64
|
+
});
|
|
65
|
+
const allowed = filterable ? new Set(filterable) : void 0;
|
|
66
|
+
const isFilterable = (key) => !allowed || allowed.has(key);
|
|
47
67
|
function buildBaseQuery() {
|
|
48
68
|
let queryFields = { ...fields };
|
|
49
69
|
if (relations == null ? void 0 : relations.length) {
|
|
@@ -66,7 +86,7 @@ function createGetListQuery(dialect) {
|
|
|
66
86
|
).map((candidate) => columnAt(candidate, key)).find(Boolean);
|
|
67
87
|
}
|
|
68
88
|
function addCondition(conditions2, key, value, targetColumn) {
|
|
69
|
-
const
|
|
89
|
+
const isExact = isIdKey(key) || !isFuzzyMatchable(targetColumn);
|
|
70
90
|
const isJsonArray = jsonArrayFields == null ? void 0 : jsonArrayFields.includes(key);
|
|
71
91
|
if (typeof value !== "string") {
|
|
72
92
|
conditions2.push((0, import_drizzle_orm.eq)(targetColumn, value));
|
|
@@ -78,24 +98,35 @@ function createGetListQuery(dialect) {
|
|
|
78
98
|
return;
|
|
79
99
|
}
|
|
80
100
|
if (isJsonArray) {
|
|
81
|
-
conditions2.push(
|
|
82
|
-
|
|
101
|
+
conditions2.push(
|
|
102
|
+
dialect.jsonArrayContains(
|
|
103
|
+
targetColumn,
|
|
104
|
+
escapeLikePattern(values[0])
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
} else if (isExact) {
|
|
83
108
|
conditions2.push((0, import_drizzle_orm.inArray)(targetColumn, values));
|
|
84
109
|
} else {
|
|
85
110
|
conditions2.push(
|
|
86
111
|
(0, import_drizzle_orm.or)(
|
|
87
|
-
...values.map(
|
|
112
|
+
...values.map(
|
|
113
|
+
(item) => dialect.contains(targetColumn, escapeLikePattern(item))
|
|
114
|
+
)
|
|
88
115
|
)
|
|
89
116
|
);
|
|
90
117
|
}
|
|
91
118
|
return;
|
|
92
119
|
}
|
|
93
120
|
if (isJsonArray) {
|
|
94
|
-
conditions2.push(
|
|
95
|
-
|
|
121
|
+
conditions2.push(
|
|
122
|
+
dialect.jsonArrayContains(targetColumn, escapeLikePattern(value))
|
|
123
|
+
);
|
|
124
|
+
} else if (isExact) {
|
|
96
125
|
conditions2.push((0, import_drizzle_orm.eq)(targetColumn, value));
|
|
97
126
|
} else {
|
|
98
|
-
conditions2.push(
|
|
127
|
+
conditions2.push(
|
|
128
|
+
dialect.contains(targetColumn, escapeLikePattern(value))
|
|
129
|
+
);
|
|
99
130
|
}
|
|
100
131
|
}
|
|
101
132
|
function buildConditions() {
|
|
@@ -105,16 +136,29 @@ function createGetListQuery(dialect) {
|
|
|
105
136
|
continue;
|
|
106
137
|
}
|
|
107
138
|
if (key.endsWith("AtFrom") || key.endsWith("AtTo")) {
|
|
108
|
-
const baseFieldName = key.replace(/(AtFrom|AtTo)$/, "")
|
|
109
|
-
|
|
139
|
+
const baseFieldName = `${key.replace(/(AtFrom|AtTo)$/, "")}At`;
|
|
140
|
+
if (!isFilterable(baseFieldName)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const targetColumn2 = findTargetColumn(baseFieldName);
|
|
110
144
|
if (!targetColumn2) {
|
|
111
145
|
continue;
|
|
112
146
|
}
|
|
147
|
+
const at = new Date(value);
|
|
148
|
+
if (Number.isNaN(at.getTime())) {
|
|
149
|
+
throw new import_errors.HttpError(
|
|
150
|
+
400,
|
|
151
|
+
`${key} 不是一个能识别的时间,请用 ISO 8601(如 2026-08-01T00:00:00Z)。`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
113
154
|
conditions2.push(
|
|
114
|
-
key.endsWith("AtFrom") ? (0, import_drizzle_orm.gte)(targetColumn2,
|
|
155
|
+
key.endsWith("AtFrom") ? (0, import_drizzle_orm.gte)(targetColumn2, at) : (0, import_drizzle_orm.lte)(targetColumn2, at)
|
|
115
156
|
);
|
|
116
157
|
continue;
|
|
117
158
|
}
|
|
159
|
+
if (!isFilterable(key)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
118
162
|
const targetColumn = findTargetColumn(key);
|
|
119
163
|
if (!targetColumn) {
|
|
120
164
|
continue;
|
|
@@ -124,7 +168,17 @@ function createGetListQuery(dialect) {
|
|
|
124
168
|
return conditions2;
|
|
125
169
|
}
|
|
126
170
|
function buildOrderBy() {
|
|
127
|
-
const
|
|
171
|
+
const requested = typeof orderBy === "string" && orderBy ? orderBy : void 0;
|
|
172
|
+
const orderKey = requested ?? "createdAt";
|
|
173
|
+
const orderColumn = columnAt(table, orderKey);
|
|
174
|
+
if (!orderColumn) {
|
|
175
|
+
if (requested) {
|
|
176
|
+
throw new import_errors.HttpError(400, `orderBy "${requested}" 不是这张表上的列。`);
|
|
177
|
+
}
|
|
178
|
+
throw new Error(
|
|
179
|
+
`默认排序列 createdAt 不在这张表上。给这张表的列表查询显式传一个 orderBy。`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
128
182
|
return orderDir === "asc" ? (0, import_drizzle_orm.asc)(orderColumn) : (0, import_drizzle_orm.desc)(orderColumn);
|
|
129
183
|
}
|
|
130
184
|
const baseQuery = buildBaseQuery();
|
|
@@ -132,7 +186,7 @@ function createGetListQuery(dialect) {
|
|
|
132
186
|
const conditions = scopeSql ? [scopeSql] : [];
|
|
133
187
|
conditions.push(...buildConditions());
|
|
134
188
|
const where = conditions.length > 0 ? (0, import_drizzle_orm.and)(...conditions) : void 0;
|
|
135
|
-
const query = baseQuery.where(where).orderBy(buildOrderBy()).limit(pageSize).offset((
|
|
189
|
+
const query = baseQuery.where(where).orderBy(buildOrderBy()).limit(pageSize).offset((current - 1) * pageSize);
|
|
136
190
|
const countQuery = db.select({ count: import_drizzle_orm.sql`COUNT(DISTINCT ${table.id})` }).from(table).where(where);
|
|
137
191
|
if (relations == null ? void 0 : relations.length) {
|
|
138
192
|
for (const { table: relationTable, sql: joinSql } of relations) {
|
|
@@ -147,5 +201,6 @@ function createGetListQuery(dialect) {
|
|
|
147
201
|
}
|
|
148
202
|
// Annotate the CommonJS export names for ESM import in node:
|
|
149
203
|
0 && (module.exports = {
|
|
150
|
-
createGetListQuery
|
|
204
|
+
createGetListQuery,
|
|
205
|
+
escapeLikePattern
|
|
151
206
|
});
|