@dtdyq/restbase 1.0.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/README.md +105 -0
- package/auth.ts +164 -0
- package/bin/restbase.ts +2 -0
- package/client/package.json +22 -0
- package/client/restbase-client.ts +576 -0
- package/crud.ts +329 -0
- package/db.ts +197 -0
- package/logger.ts +94 -0
- package/package.json +50 -0
- package/query.ts +580 -0
- package/server.ts +201 -0
- package/types.ts +186 -0
package/query.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* query.ts — 查询条件 → SQL
|
|
3
|
+
*
|
|
4
|
+
* 两种入口:
|
|
5
|
+
* A) URL 查询参数(GET/DELETE,用于命令行调试)
|
|
6
|
+
* buildListSQL / buildDeleteSQL
|
|
7
|
+
* B) JSON Body(POST /api/query、POST /api/delete,用于前端)
|
|
8
|
+
* buildBodyListSQL / buildBodyDeleteSQL
|
|
9
|
+
*
|
|
10
|
+
* URL 语法:
|
|
11
|
+
* 普通条件: ?field=value 或 ?field=op.value
|
|
12
|
+
* 操作符: eq ne ge gt le lt is nis(ns) like nlike in nin
|
|
13
|
+
* 逻辑组合: ?or=f.op.v,f2.op.v2 → (cond OR cond)
|
|
14
|
+
* 特殊参数: select / order / pageNo / pageSize / group
|
|
15
|
+
*
|
|
16
|
+
* Body 语法:
|
|
17
|
+
* where: 数组/元组/对象,支持嵌套 and/or
|
|
18
|
+
* select: 字符串或对象数组
|
|
19
|
+
* order: 字符串或对象数组
|
|
20
|
+
* group: 字段名数组
|
|
21
|
+
*/
|
|
22
|
+
import type {ColMeta, TblMeta} from "./db.ts";
|
|
23
|
+
import {
|
|
24
|
+
AppError, ownerCond, q,
|
|
25
|
+
type BodyWhereItem, type BodyWhereInput, type BodySelectItem,
|
|
26
|
+
type BodyOrderItem, type BodyQuery,
|
|
27
|
+
} from "./types.ts";
|
|
28
|
+
|
|
29
|
+
/* ─── 操作符映射 ─── */
|
|
30
|
+
const OP: Record<string, string> = {
|
|
31
|
+
eq: "=", ne: "!=", ge: ">=", gt: ">", le: "<=", lt: "<",
|
|
32
|
+
};
|
|
33
|
+
const ALL_OPS = new Set([
|
|
34
|
+
...Object.keys(OP), "is", "nis", "ns", "like", "nlike", "in", "nin",
|
|
35
|
+
]);
|
|
36
|
+
const AGG_FUNCS = new Set(["avg", "max", "min", "count", "sum"]);
|
|
37
|
+
const RESERVED = new Set([
|
|
38
|
+
"select", "order", "pageNo", "pageSize", "group", "or", "and",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/* ─── 输出结构 ─── */
|
|
42
|
+
export interface ListSQL {
|
|
43
|
+
sql: string;
|
|
44
|
+
countSql: string;
|
|
45
|
+
values: unknown[];
|
|
46
|
+
countValues: unknown[];
|
|
47
|
+
pageNo?: number;
|
|
48
|
+
pageSize?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DeleteSQL {
|
|
52
|
+
sql: string;
|
|
53
|
+
values: unknown[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ─── 内部类型 ─── */
|
|
57
|
+
interface Ctx {
|
|
58
|
+
n: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Cond {
|
|
62
|
+
sql: string;
|
|
63
|
+
values: unknown[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ══════════════════════════════════════════════════════════════
|
|
67
|
+
公共:从 URL query 构建 WHERE 子句 + 参数值
|
|
68
|
+
══════════════════════════════════════════════════════════════ */
|
|
69
|
+
|
|
70
|
+
function buildWhere(
|
|
71
|
+
tbl: TblMeta,
|
|
72
|
+
params: Record<string, string>,
|
|
73
|
+
userId: number,
|
|
74
|
+
ctx: Ctx,
|
|
75
|
+
): { where: string; values: unknown[] } {
|
|
76
|
+
const wp: string[] = [];
|
|
77
|
+
const wv: unknown[] = [];
|
|
78
|
+
|
|
79
|
+
/* 1) owner 过滤 */
|
|
80
|
+
if (tbl.hasOwner) {
|
|
81
|
+
wp.push(ownerCond(ctx.n++));
|
|
82
|
+
wv.push(userId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* 2) 普通字段条件(多个字段之间 AND) */
|
|
86
|
+
for (const [key, val] of Object.entries(params)) {
|
|
87
|
+
if (RESERVED.has(key) || !tbl.colMap.has(key)) continue;
|
|
88
|
+
const c = fieldCond(key, val, tbl, ctx);
|
|
89
|
+
wp.push(c.sql);
|
|
90
|
+
wv.push(...c.values);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* 3) or=… / and=… 逻辑组合 */
|
|
94
|
+
if (params.or) {
|
|
95
|
+
const c = group("OR", params.or, tbl, ctx);
|
|
96
|
+
wp.push(c.sql);
|
|
97
|
+
wv.push(...c.values);
|
|
98
|
+
}
|
|
99
|
+
if (params.and) {
|
|
100
|
+
const c = group("AND", params.and, tbl, ctx);
|
|
101
|
+
wp.push(c.sql);
|
|
102
|
+
wv.push(...c.values);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const where = wp.length ? `WHERE ${wp.join(" AND ")}` : "";
|
|
106
|
+
return {where, values: wv};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ══════════════════════════════════════════════════════════════
|
|
110
|
+
主入口:从 URL query 构建完整 SELECT SQL + COUNT SQL
|
|
111
|
+
══════════════════════════════════════════════════════════════ */
|
|
112
|
+
|
|
113
|
+
export function buildListSQL(
|
|
114
|
+
tbl: TblMeta,
|
|
115
|
+
params: Record<string, string>,
|
|
116
|
+
userId: number,
|
|
117
|
+
): ListSQL {
|
|
118
|
+
const ctx: Ctx = {n: 1};
|
|
119
|
+
const {where, values: wv} = buildWhere(tbl, params, userId, ctx);
|
|
120
|
+
|
|
121
|
+
/* SELECT */
|
|
122
|
+
const sel = buildSel(params.select, tbl);
|
|
123
|
+
|
|
124
|
+
/* GROUP BY */
|
|
125
|
+
const grp = params.group
|
|
126
|
+
? `GROUP BY ${params.group.split(",").map((s) => q(s.trim())).join(", ")}`
|
|
127
|
+
: "";
|
|
128
|
+
|
|
129
|
+
/* ORDER BY */
|
|
130
|
+
const ord = params.order ? `ORDER BY ${buildOrd(params.order)}` : "";
|
|
131
|
+
|
|
132
|
+
/* 分页(pageSize 上限 1000) */
|
|
133
|
+
const pageNo = params.pageNo ? Math.max(1, Number(params.pageNo)) : undefined;
|
|
134
|
+
const pageSize = params.pageSize ? Math.min(1000, Math.max(1, Number(params.pageSize))) : undefined;
|
|
135
|
+
const lim =
|
|
136
|
+
pageNo !== undefined && pageSize !== undefined
|
|
137
|
+
? `LIMIT ${pageSize} OFFSET ${(pageNo - 1) * pageSize}`
|
|
138
|
+
: "";
|
|
139
|
+
|
|
140
|
+
/* 拼接 */
|
|
141
|
+
const t = q(tbl.name);
|
|
142
|
+
const sql = [`SELECT ${sel} FROM ${t}`, where, grp, ord, lim]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join(" ");
|
|
145
|
+
|
|
146
|
+
/* COUNT SQL(有 GROUP BY 时用子查询) */
|
|
147
|
+
const countSql = grp
|
|
148
|
+
? `SELECT COUNT(*) AS total FROM (SELECT 1 FROM ${t} ${where} ${grp}) AS _sub`
|
|
149
|
+
: `SELECT COUNT(*) AS total FROM ${t} ${where}`;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
sql,
|
|
153
|
+
countSql,
|
|
154
|
+
values: [...wv],
|
|
155
|
+
countValues: [...wv],
|
|
156
|
+
pageNo,
|
|
157
|
+
pageSize,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ══════════════════════════════════════════════════════════════
|
|
162
|
+
DELETE SQL:复用 WHERE 构建,支持与 GET 相同的条件语法
|
|
163
|
+
══════════════════════════════════════════════════════════════ */
|
|
164
|
+
|
|
165
|
+
export function buildDeleteSQL(
|
|
166
|
+
tbl: TblMeta,
|
|
167
|
+
params: Record<string, string>,
|
|
168
|
+
userId: number,
|
|
169
|
+
): DeleteSQL {
|
|
170
|
+
const ctx: Ctx = {n: 1};
|
|
171
|
+
const {where, values} = buildWhere(tbl, params, userId, ctx);
|
|
172
|
+
const sql = `DELETE
|
|
173
|
+
FROM ${q(tbl.name)} ${where}`;
|
|
174
|
+
return {sql, values: [...values]};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ══════════════════════════════════════════════════════════════
|
|
178
|
+
单字段条件: field=op.value 或 field=value(默认 eq)
|
|
179
|
+
══════════════════════════════════════════════════════════════ */
|
|
180
|
+
|
|
181
|
+
function fieldCond(field: string, raw: string, tbl: TblMeta, ctx: Ctx): Cond {
|
|
182
|
+
const col = tbl.colMap.get(field)!;
|
|
183
|
+
|
|
184
|
+
/* in/nin 无 dot 写法: in(…) nin(…) */
|
|
185
|
+
if (/^n?in\(/.test(raw)) {
|
|
186
|
+
const neg = raw.startsWith("n");
|
|
187
|
+
return buildCond(field, neg ? "nin" : "in", raw.slice(neg ? 3 : 2), col, ctx);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* 标准: op.value */
|
|
191
|
+
const dot = raw.indexOf(".");
|
|
192
|
+
if (dot === -1) return buildCond(field, "eq", raw, col, ctx);
|
|
193
|
+
|
|
194
|
+
const maybeOp = raw.slice(0, dot).toLowerCase();
|
|
195
|
+
if (ALL_OPS.has(maybeOp))
|
|
196
|
+
return buildCond(field, maybeOp, raw.slice(dot + 1), col, ctx);
|
|
197
|
+
|
|
198
|
+
/* 不是已知操作符 → 整个值当 eq */
|
|
199
|
+
return buildCond(field, "eq", raw, col, ctx);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ══════════════════════════════════════════════════════════════
|
|
203
|
+
构建单条件 SQL
|
|
204
|
+
══════════════════════════════════════════════════════════════ */
|
|
205
|
+
|
|
206
|
+
function buildCond(
|
|
207
|
+
field: string, op: string, val: string, col: ColMeta, ctx: Ctx,
|
|
208
|
+
): Cond {
|
|
209
|
+
const f = q(field);
|
|
210
|
+
|
|
211
|
+
/* IS NULL / IS NOT NULL */
|
|
212
|
+
if (op === "is") return {sql: `${f} IS NULL`, values: []};
|
|
213
|
+
if (op === "nis" || op === "ns") return {sql: `${f} IS NOT NULL`, values: []};
|
|
214
|
+
|
|
215
|
+
/* LIKE / NOT LIKE (* → %) */
|
|
216
|
+
if (op === "like" || op === "nlike") {
|
|
217
|
+
const sqlOp = op === "like" ? "LIKE" : "NOT LIKE";
|
|
218
|
+
return {sql: `${f} ${sqlOp} $${ctx.n++}`, values: [val.replace(/\*/g, "%")]};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* IN / NOT IN / BETWEEN */
|
|
222
|
+
if (op === "in" || op === "nin") return buildIn(f, op === "nin", val, col, ctx);
|
|
223
|
+
|
|
224
|
+
/* 标准比较 eq ne ge gt le lt */
|
|
225
|
+
const sqlOp = OP[op];
|
|
226
|
+
if (!sqlOp) throw new AppError("QUERY_ERROR", `Unknown operator: ${op}`);
|
|
227
|
+
return {sql: `${f} ${sqlOp} $${ctx.n++}`, values: [typed(val, col)]};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ─── IN / NOT IN / BETWEEN ─── */
|
|
231
|
+
|
|
232
|
+
function buildIn(
|
|
233
|
+
f: string, neg: boolean, val: string, col: ColMeta, ctx: Ctx,
|
|
234
|
+
): Cond {
|
|
235
|
+
let inner = val;
|
|
236
|
+
if (inner.startsWith("(") && inner.endsWith(")")) inner = inner.slice(1, -1);
|
|
237
|
+
const not = neg ? "NOT " : "";
|
|
238
|
+
|
|
239
|
+
/* BETWEEN: 12...20 */
|
|
240
|
+
if (inner.includes("...")) {
|
|
241
|
+
const [lo, hi] = inner.split("...");
|
|
242
|
+
return {
|
|
243
|
+
sql: `${f} ${not}BETWEEN $${ctx.n++} AND $${ctx.n++}`,
|
|
244
|
+
values: [typed(lo!, col), typed(hi!, col)],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* IN (a, b, c) */
|
|
249
|
+
const items = inner.split(",").map((s) => s.trim());
|
|
250
|
+
const ph = items.map(() => `$${ctx.n++}`).join(", ");
|
|
251
|
+
return {sql: `${f} ${not}IN (${ph})`, values: items.map((s) => typed(s, col))};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ─── 按列类型转换 ─── */
|
|
255
|
+
function typed(v: string, col: ColMeta): string | number {
|
|
256
|
+
return col.isNumeric ? Number(v) : v;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ══════════════════════════════════════════════════════════════
|
|
260
|
+
逻辑分组: or=… / and=… 支持嵌套
|
|
261
|
+
══════════════════════════════════════════════════════════════ */
|
|
262
|
+
|
|
263
|
+
function group(logic: "AND" | "OR", raw: string, tbl: TblMeta, ctx: Ctx): Cond {
|
|
264
|
+
const parts = splitTop(raw);
|
|
265
|
+
const conds = parts.map((p) => expr(p, tbl, ctx));
|
|
266
|
+
return {
|
|
267
|
+
sql: `(${conds.map((c) => c.sql).join(` ${logic} `)})`,
|
|
268
|
+
values: conds.flatMap((c) => c.values),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 解析嵌套表达式(递归) */
|
|
273
|
+
function expr(e: string, tbl: TblMeta, ctx: Ctx): Cond {
|
|
274
|
+
/* 嵌套组: or.( … ) / and.( … ) */
|
|
275
|
+
const m = e.match(/^(or|and)\.\((.+)\)$/);
|
|
276
|
+
if (m) return group(m[1]!.toUpperCase() as "AND" | "OR", m[2]!, tbl, ctx);
|
|
277
|
+
|
|
278
|
+
/* 简单条件: field.op.value */
|
|
279
|
+
const dot = e.indexOf(".");
|
|
280
|
+
if (dot === -1) throw new AppError("QUERY_ERROR", `Invalid expression: ${e}`);
|
|
281
|
+
const field = e.slice(0, dot);
|
|
282
|
+
if (!tbl.colMap.has(field))
|
|
283
|
+
throw new AppError("QUERY_ERROR", `Unknown column: ${field}`);
|
|
284
|
+
return fieldCond(field, e.slice(dot + 1), tbl, ctx);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 逗号分割(尊重括号嵌套) */
|
|
288
|
+
function splitTop(s: string): string[] {
|
|
289
|
+
const r: string[] = [];
|
|
290
|
+
let depth = 0;
|
|
291
|
+
let start = 0;
|
|
292
|
+
for (let i = 0; i < s.length; i++) {
|
|
293
|
+
if (s[i] === "(") depth++;
|
|
294
|
+
else if (s[i] === ")") depth--;
|
|
295
|
+
else if (s[i] === "," && depth === 0) {
|
|
296
|
+
r.push(s.slice(start, i));
|
|
297
|
+
start = i + 1;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
r.push(s.slice(start));
|
|
301
|
+
return r;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* ══════════════════════════════════════════════════════════════
|
|
305
|
+
SELECT 解析: name | age:userAge | max:age | max:age:maxAge
|
|
306
|
+
══════════════════════════════════════════════════════════════ */
|
|
307
|
+
|
|
308
|
+
function buildSel(raw: string | undefined, _tbl: TblMeta): string {
|
|
309
|
+
if (!raw) return "*";
|
|
310
|
+
return raw
|
|
311
|
+
.split(",")
|
|
312
|
+
.map((item) => {
|
|
313
|
+
const p = item.trim().split(":");
|
|
314
|
+
if (p.length === 1) return q(p[0]!);
|
|
315
|
+
if (p.length === 2) {
|
|
316
|
+
/* func:field 或 field:alias */
|
|
317
|
+
if (AGG_FUNCS.has(p[0]!.toLowerCase())) {
|
|
318
|
+
const fn = p[0]!.toUpperCase();
|
|
319
|
+
return `${fn}(${q(p[1]!)}) AS ${q(`${p[0]!.toLowerCase()}:${p[1]!}`)}`;
|
|
320
|
+
}
|
|
321
|
+
return `${q(p[0]!)} AS ${q(p[1]!)}`;
|
|
322
|
+
}
|
|
323
|
+
/* func:field:alias */
|
|
324
|
+
return `${p[0]!.toUpperCase()}(${q(p[1]!)}) AS ${q(p[2]!)}`;
|
|
325
|
+
})
|
|
326
|
+
.join(", ");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* ══════════════════════════════════════════════════════════════
|
|
330
|
+
ORDER BY 解析: asc.age,desc.name (省略方向默认 ASC)
|
|
331
|
+
══════════════════════════════════════════════════════════════ */
|
|
332
|
+
|
|
333
|
+
function buildOrd(raw: string): string {
|
|
334
|
+
return raw
|
|
335
|
+
.split(",")
|
|
336
|
+
.map((s) => {
|
|
337
|
+
const t = s.trim();
|
|
338
|
+
if (t.startsWith("asc.")) return `${q(t.slice(4))} ASC`;
|
|
339
|
+
if (t.startsWith("desc.")) return `${q(t.slice(5))} DESC`;
|
|
340
|
+
return `${q(t)} ASC`;
|
|
341
|
+
})
|
|
342
|
+
.join(", ");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
346
|
+
██████ Body 模式(POST JSON)→ SQL
|
|
347
|
+
(类型定义和 Zod Schema 见 types.ts)
|
|
348
|
+
══════════════════════════════════════════════════════════════════════════ */
|
|
349
|
+
|
|
350
|
+
/* ══════════════════════════════════════════════════════════════
|
|
351
|
+
Body 模式入口: 完整 SELECT
|
|
352
|
+
══════════════════════════════════════════════════════════════ */
|
|
353
|
+
|
|
354
|
+
export function buildBodyListSQL(
|
|
355
|
+
tbl: TblMeta, body: BodyQuery, userId: number,
|
|
356
|
+
): ListSQL {
|
|
357
|
+
const ctx: Ctx = {n: 1};
|
|
358
|
+
const {where, values: wv} = bodyBuildWhere(tbl, body.where, userId, ctx);
|
|
359
|
+
|
|
360
|
+
const sel = bodyBuildSel(body.select);
|
|
361
|
+
const grp = body.group?.length
|
|
362
|
+
? `GROUP BY ${body.group.map((f) => q(f)).join(", ")}`
|
|
363
|
+
: "";
|
|
364
|
+
const ord = body.order?.length
|
|
365
|
+
? `ORDER BY ${bodyBuildOrd(body.order)}`
|
|
366
|
+
: "";
|
|
367
|
+
|
|
368
|
+
const pageNo = body.pageNo ? Math.max(1, body.pageNo) : undefined;
|
|
369
|
+
const pageSize = body.pageSize ? Math.min(1000, Math.max(1, body.pageSize)) : undefined;
|
|
370
|
+
const lim =
|
|
371
|
+
pageNo !== undefined && pageSize !== undefined
|
|
372
|
+
? `LIMIT ${pageSize} OFFSET ${(pageNo - 1) * pageSize}`
|
|
373
|
+
: "";
|
|
374
|
+
|
|
375
|
+
const t = q(tbl.name);
|
|
376
|
+
const sql = [`SELECT ${sel} FROM ${t}`, where, grp, ord, lim]
|
|
377
|
+
.filter(Boolean)
|
|
378
|
+
.join(" ");
|
|
379
|
+
|
|
380
|
+
const countSql = grp
|
|
381
|
+
? `SELECT COUNT(*) AS total FROM (SELECT 1 FROM ${t} ${where} ${grp}) AS _sub`
|
|
382
|
+
: `SELECT COUNT(*) AS total FROM ${t} ${where}`;
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
sql, countSql,
|
|
386
|
+
values: [...wv], countValues: [...wv],
|
|
387
|
+
pageNo, pageSize,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/* ══════════════════════════════════════════════════════════════
|
|
392
|
+
Body 模式入口: DELETE
|
|
393
|
+
══════════════════════════════════════════════════════════════ */
|
|
394
|
+
|
|
395
|
+
export function buildBodyDeleteSQL(
|
|
396
|
+
tbl: TblMeta, where: BodyWhereInput | undefined, userId: number,
|
|
397
|
+
): DeleteSQL {
|
|
398
|
+
const ctx: Ctx = {n: 1};
|
|
399
|
+
const w = bodyBuildWhere(tbl, where, userId, ctx);
|
|
400
|
+
return {
|
|
401
|
+
sql: `DELETE
|
|
402
|
+
FROM ${q(tbl.name)} ${w.where}`, values: [...w.values]
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* ══════════════════════════════════════════════════════════════
|
|
407
|
+
Body WHERE → SQL
|
|
408
|
+
══════════════════════════════════════════════════════════════ */
|
|
409
|
+
|
|
410
|
+
function bodyBuildWhere(
|
|
411
|
+
tbl: TblMeta, input: BodyWhereInput | undefined, userId: number, ctx: Ctx,
|
|
412
|
+
): { where: string; values: unknown[] } {
|
|
413
|
+
const wp: string[] = [];
|
|
414
|
+
const wv: unknown[] = [];
|
|
415
|
+
|
|
416
|
+
/* owner 过滤 */
|
|
417
|
+
if (tbl.hasOwner) {
|
|
418
|
+
wp.push(ownerCond(ctx.n++));
|
|
419
|
+
wv.push(userId);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (input) {
|
|
423
|
+
const items = normalizeWhereInput(input);
|
|
424
|
+
for (const item of items) {
|
|
425
|
+
const c = bodyCondItem(item, tbl, ctx);
|
|
426
|
+
wp.push(c.sql);
|
|
427
|
+
wv.push(...c.values);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const where = wp.length ? `WHERE ${wp.join(" AND ")}` : "";
|
|
432
|
+
return {where, values: wv};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 规范化 where 输入:
|
|
437
|
+
* - 如果是数组且首元素为 string → 当作单条件元组
|
|
438
|
+
* - 如果是对象(非数组)→ 包装为单元素数组
|
|
439
|
+
* - 否则 → 条件数组
|
|
440
|
+
*/
|
|
441
|
+
function normalizeWhereInput(input: BodyWhereInput): BodyWhereItem[] {
|
|
442
|
+
if (!Array.isArray(input)) return [input as BodyWhereItem];
|
|
443
|
+
if (input.length === 0) return [];
|
|
444
|
+
/* 首元素是 string → 单条件元组 [field, op?, value] */
|
|
445
|
+
if (typeof input[0] === "string") return [input as BodyWhereItem];
|
|
446
|
+
return input as BodyWhereItem[];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 解析单个 where 条件项 → SQL */
|
|
450
|
+
function bodyCondItem(item: BodyWhereItem, tbl: TblMeta, ctx: Ctx): Cond {
|
|
451
|
+
/* 1) 元组格式 */
|
|
452
|
+
if (Array.isArray(item)) {
|
|
453
|
+
if (item.length === 2) {
|
|
454
|
+
const [field, value] = item as [string, unknown];
|
|
455
|
+
return bodyFieldCond(field, "eq", value, tbl, ctx);
|
|
456
|
+
}
|
|
457
|
+
if (item.length === 3) {
|
|
458
|
+
const [field, op, value] = item as [string, string, unknown];
|
|
459
|
+
return bodyFieldCond(field, op, value, tbl, ctx);
|
|
460
|
+
}
|
|
461
|
+
throw new AppError("QUERY_ERROR", "Invalid where tuple, expected 2 or 3 elements");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/* 2) 逻辑组合: { op: "and"/"or", cond: [...] } */
|
|
465
|
+
if ("cond" in item) {
|
|
466
|
+
const logic = (item.op || "and").toUpperCase() as "AND" | "OR";
|
|
467
|
+
const children = (item.cond || []).map((c) => {
|
|
468
|
+
const items = normalizeWhereInput(c);
|
|
469
|
+
/* 如果子输入规范化后是多条件,包装成 AND 组 */
|
|
470
|
+
if (items.length === 1) return bodyCondItem(items[0]!, tbl, ctx);
|
|
471
|
+
const subs = items.map((i) => bodyCondItem(i, tbl, ctx));
|
|
472
|
+
return {
|
|
473
|
+
sql: `(${subs.map((s) => s.sql).join(" AND ")})`,
|
|
474
|
+
values: subs.flatMap((s) => s.values),
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
return {
|
|
478
|
+
sql: `(${children.map((c) => c.sql).join(` ${logic} `)})`,
|
|
479
|
+
values: children.flatMap((c) => c.values),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/* 3) 对象格式: { field, op, value } */
|
|
484
|
+
return bodyFieldCond(item.field, item.op, item.value, tbl, ctx);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Body 模式:单字段条件 → SQL */
|
|
488
|
+
function bodyFieldCond(
|
|
489
|
+
field: string, op: string, value: unknown, tbl: TblMeta, ctx: Ctx,
|
|
490
|
+
): Cond {
|
|
491
|
+
const col = tbl.colMap.get(field);
|
|
492
|
+
if (!col) throw new AppError("QUERY_ERROR", `Unknown column: ${field}`);
|
|
493
|
+
|
|
494
|
+
const f = q(field);
|
|
495
|
+
const o = op.toLowerCase();
|
|
496
|
+
|
|
497
|
+
/* IS NULL / IS NOT NULL */
|
|
498
|
+
if (o === "is") return {sql: `${f} IS NULL`, values: []};
|
|
499
|
+
if (o === "nis" || o === "ns") return {sql: `${f} IS NOT NULL`, values: []};
|
|
500
|
+
|
|
501
|
+
/* LIKE / NOT LIKE */
|
|
502
|
+
if (o === "like" || o === "nlike") {
|
|
503
|
+
const sqlOp = o === "like" ? "LIKE" : "NOT LIKE";
|
|
504
|
+
return {sql: `${f} ${sqlOp} $${ctx.n++}`, values: [String(value)]};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/* IN / NOT IN */
|
|
508
|
+
if (o === "in" || o === "nin") {
|
|
509
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
510
|
+
const not = o === "nin" ? "NOT " : "";
|
|
511
|
+
const ph = arr.map(() => `$${ctx.n++}`).join(", ");
|
|
512
|
+
return {
|
|
513
|
+
sql: `${f} ${not}IN (${ph})`,
|
|
514
|
+
values: arr.map((v) => col.isNumeric ? Number(v) : v),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/* BETWEEN */
|
|
519
|
+
if (o === "between" || o === "bt") {
|
|
520
|
+
const arr = Array.isArray(value) ? value : [];
|
|
521
|
+
if (arr.length !== 2)
|
|
522
|
+
throw new AppError("QUERY_ERROR", "between requires [lo, hi] array");
|
|
523
|
+
return {
|
|
524
|
+
sql: `${f} BETWEEN $${ctx.n++} AND $${ctx.n++}`,
|
|
525
|
+
values: arr.map((v) => col.isNumeric ? Number(v) : v),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/* 标准比较 eq ne ge gt le lt */
|
|
530
|
+
const sqlOp = OP[o];
|
|
531
|
+
if (!sqlOp) throw new AppError("QUERY_ERROR", `Unknown operator: ${op}`);
|
|
532
|
+
const v = col.isNumeric ? Number(value) : value;
|
|
533
|
+
return {sql: `${f} ${sqlOp} $${ctx.n++}`, values: [v]};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* ══════════════════════════════════════════════════════════════
|
|
537
|
+
Body SELECT 解析
|
|
538
|
+
══════════════════════════════════════════════════════════════ */
|
|
539
|
+
|
|
540
|
+
function bodyBuildSel(items?: BodySelectItem[]): string {
|
|
541
|
+
if (!items || items.length === 0) return "*";
|
|
542
|
+
return items.map((item) => {
|
|
543
|
+
if (typeof item === "string") {
|
|
544
|
+
/* 复用 URL 解析逻辑(冒号分割) */
|
|
545
|
+
const p = item.split(":");
|
|
546
|
+
if (p.length === 1) return q(p[0]!);
|
|
547
|
+
if (p.length === 2) {
|
|
548
|
+
if (AGG_FUNCS.has(p[0]!.toLowerCase())) {
|
|
549
|
+
const fn = p[0]!.toUpperCase();
|
|
550
|
+
return `${fn}(${q(p[1]!)}) AS ${q(`${p[0]!.toLowerCase()}:${p[1]!}`)}`;
|
|
551
|
+
}
|
|
552
|
+
return `${q(p[0]!)} AS ${q(p[1]!)}`;
|
|
553
|
+
}
|
|
554
|
+
return `${p[0]!.toUpperCase()}(${q(p[1]!)}) AS ${q(p[2]!)}`;
|
|
555
|
+
}
|
|
556
|
+
/* 对象格式 { field, alias?, func? } */
|
|
557
|
+
const col = item.func
|
|
558
|
+
? `${item.func.toUpperCase()}(${q(item.field)})`
|
|
559
|
+
: q(item.field);
|
|
560
|
+
/* func 无 alias 时默认 AS "func:field" */
|
|
561
|
+
if (item.alias) return `${col} AS ${q(item.alias)}`;
|
|
562
|
+
if (item.func) return `${col} AS ${q(`${item.func.toLowerCase()}:${item.field}`)}`;
|
|
563
|
+
return col;
|
|
564
|
+
}).join(", ");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* ══════════════════════════════════════════════════════════════
|
|
568
|
+
Body ORDER BY 解析
|
|
569
|
+
══════════════════════════════════════════════════════════════ */
|
|
570
|
+
|
|
571
|
+
function bodyBuildOrd(items: BodyOrderItem[]): string {
|
|
572
|
+
return items.map((item) => {
|
|
573
|
+
if (typeof item === "string") {
|
|
574
|
+
if (item.startsWith("asc.")) return `${q(item.slice(4))} ASC`;
|
|
575
|
+
if (item.startsWith("desc.")) return `${q(item.slice(5))} DESC`;
|
|
576
|
+
return `${q(item)} ASC`;
|
|
577
|
+
}
|
|
578
|
+
return `${q(item.field)} ${(item.dir || "asc").toUpperCase()}`;
|
|
579
|
+
}).join(", ");
|
|
580
|
+
}
|