befly 3.9.5 → 3.9.7
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/befly.config.ts +1 -1
- package/docs/database.md +1012 -0
- package/lib/dbHelper.ts +215 -14
- package/lib/sqlBuilder.ts +24 -0
- package/package.json +4 -3
- package/tests/dbHelper-joins.test.ts +307 -0
- package/types/common.d.ts +13 -0
- package/types/database.d.ts +6 -3
package/lib/dbHelper.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { keysToSnake } from 'befly-shared/keysToSnake';
|
|
|
11
11
|
import { fieldClear } from 'befly-shared/fieldClear';
|
|
12
12
|
import { RedisTTL, RedisKeys } from 'befly-shared/redisKeys';
|
|
13
13
|
import { Logger } from './logger.js';
|
|
14
|
-
import type { WhereConditions } from '../types/common.js';
|
|
14
|
+
import type { WhereConditions, JoinOption } from '../types/common.js';
|
|
15
15
|
import type { BeflyContext } from '../types/befly.js';
|
|
16
16
|
import type { QueryOptions, InsertOptions, UpdateOptions, DeleteOptions, ListResult, TransactionCallback } from '../types/database.js';
|
|
17
17
|
|
|
@@ -167,25 +167,175 @@ export class DbHelper {
|
|
|
167
167
|
});
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* 处理表名(转下划线格式)
|
|
172
|
+
* 'userProfile' -> 'user_profile'
|
|
173
|
+
*/
|
|
174
|
+
private processTableName(table: string): string {
|
|
175
|
+
return snakeCase(table.trim());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 处理联查字段(支持表名.字段名格式)
|
|
180
|
+
* 'user.userId' -> 'user.user_id'
|
|
181
|
+
* 'username' -> 'user_name'
|
|
182
|
+
*/
|
|
183
|
+
private processJoinField(field: string): string {
|
|
184
|
+
// 跳过函数、星号、已处理的字段
|
|
185
|
+
if (field.includes('(') || field === '*' || field.startsWith('`')) {
|
|
186
|
+
return field;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 处理别名 AS
|
|
190
|
+
if (field.toUpperCase().includes(' AS ')) {
|
|
191
|
+
const [fieldPart, aliasPart] = field.split(/\s+AS\s+/i);
|
|
192
|
+
return `${this.processJoinField(fieldPart.trim())} AS ${aliasPart.trim()}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 处理表名.字段名
|
|
196
|
+
if (field.includes('.')) {
|
|
197
|
+
const [tableName, fieldName] = field.split('.');
|
|
198
|
+
return `${snakeCase(tableName)}.${snakeCase(fieldName)}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 普通字段
|
|
202
|
+
return snakeCase(field);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 处理联查的 where 条件键名
|
|
207
|
+
* 'user.userId': 1 -> 'user.user_id': 1
|
|
208
|
+
* 'user.status$in': [...] -> 'user.status$in': [...]
|
|
209
|
+
*/
|
|
210
|
+
private processJoinWhereKey(key: string): string {
|
|
211
|
+
// 保留逻辑操作符
|
|
212
|
+
if (key === '$or' || key === '$and') {
|
|
213
|
+
return key;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 处理带操作符的字段名(如 user.userId$gt)
|
|
217
|
+
if (key.includes('$')) {
|
|
218
|
+
const lastDollarIndex = key.lastIndexOf('$');
|
|
219
|
+
const fieldPart = key.substring(0, lastDollarIndex);
|
|
220
|
+
const operator = key.substring(lastDollarIndex);
|
|
221
|
+
|
|
222
|
+
if (fieldPart.includes('.')) {
|
|
223
|
+
const [tableName, fieldName] = fieldPart.split('.');
|
|
224
|
+
return `${snakeCase(tableName)}.${snakeCase(fieldName)}${operator}`;
|
|
225
|
+
}
|
|
226
|
+
return `${snakeCase(fieldPart)}${operator}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 处理表名.字段名
|
|
230
|
+
if (key.includes('.')) {
|
|
231
|
+
const [tableName, fieldName] = key.split('.');
|
|
232
|
+
return `${snakeCase(tableName)}.${snakeCase(fieldName)}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 普通字段
|
|
236
|
+
return snakeCase(key);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 递归处理联查的 where 条件
|
|
241
|
+
*/
|
|
242
|
+
private processJoinWhere(where: any): any {
|
|
243
|
+
if (!where || typeof where !== 'object') return where;
|
|
244
|
+
|
|
245
|
+
if (Array.isArray(where)) {
|
|
246
|
+
return where.map((item) => this.processJoinWhere(item));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const result: any = {};
|
|
250
|
+
for (const [key, value] of Object.entries(where)) {
|
|
251
|
+
const newKey = this.processJoinWhereKey(key);
|
|
252
|
+
|
|
253
|
+
if (key === '$or' || key === '$and') {
|
|
254
|
+
result[newKey] = (value as any[]).map((item) => this.processJoinWhere(item));
|
|
255
|
+
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
256
|
+
result[newKey] = this.processJoinWhere(value);
|
|
257
|
+
} else {
|
|
258
|
+
result[newKey] = value;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 处理联查的 orderBy
|
|
266
|
+
* 'o.createdAt#DESC' -> 'o.created_at#DESC'
|
|
267
|
+
*/
|
|
268
|
+
private processJoinOrderBy(orderBy: string[]): string[] {
|
|
269
|
+
if (!orderBy || !Array.isArray(orderBy)) return orderBy;
|
|
270
|
+
return orderBy.map((item) => {
|
|
271
|
+
if (typeof item !== 'string' || !item.includes('#')) return item;
|
|
272
|
+
const [field, direction] = item.split('#');
|
|
273
|
+
return `${this.processJoinField(field.trim())}#${direction.trim()}`;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
170
277
|
/**
|
|
171
278
|
* 统一的查询参数预处理方法
|
|
172
279
|
*/
|
|
173
280
|
private async prepareQueryOptions(options: QueryOptions) {
|
|
174
281
|
const cleanWhere = this.cleanFields(options.where || {});
|
|
282
|
+
const hasJoins = options.joins && options.joins.length > 0;
|
|
175
283
|
|
|
176
|
-
//
|
|
284
|
+
// 联查时使用特殊处理逻辑
|
|
285
|
+
if (hasJoins) {
|
|
286
|
+
// 联查时字段直接处理(支持表别名)
|
|
287
|
+
const processedFields = (options.fields || []).map((f) => this.processJoinField(f));
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
table: this.processTableName(options.table),
|
|
291
|
+
fields: processedFields.length > 0 ? processedFields : ['*'],
|
|
292
|
+
where: this.processJoinWhere(cleanWhere),
|
|
293
|
+
joins: options.joins,
|
|
294
|
+
orderBy: this.processJoinOrderBy(options.orderBy || []),
|
|
295
|
+
page: options.page || 1,
|
|
296
|
+
limit: options.limit || 10
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 单表查询使用原有逻辑
|
|
177
301
|
const processedFields = await this.fieldsToSnake(snakeCase(options.table), options.fields || []);
|
|
178
302
|
|
|
179
303
|
return {
|
|
180
304
|
table: snakeCase(options.table),
|
|
181
305
|
fields: processedFields,
|
|
182
306
|
where: this.whereKeysToSnake(cleanWhere),
|
|
307
|
+
joins: undefined,
|
|
183
308
|
orderBy: this.orderByToSnake(options.orderBy || []),
|
|
184
309
|
page: options.page || 1,
|
|
185
310
|
limit: options.limit || 10
|
|
186
311
|
};
|
|
187
312
|
}
|
|
188
313
|
|
|
314
|
+
/**
|
|
315
|
+
* 为 builder 添加 JOIN
|
|
316
|
+
*/
|
|
317
|
+
private applyJoins(builder: SqlBuilder, joins?: JoinOption[]): void {
|
|
318
|
+
if (!joins || joins.length === 0) return;
|
|
319
|
+
|
|
320
|
+
for (const join of joins) {
|
|
321
|
+
const processedTable = this.processTableName(join.table);
|
|
322
|
+
const type = join.type || 'left';
|
|
323
|
+
|
|
324
|
+
switch (type) {
|
|
325
|
+
case 'inner':
|
|
326
|
+
builder.innerJoin(processedTable, join.on);
|
|
327
|
+
break;
|
|
328
|
+
case 'right':
|
|
329
|
+
builder.rightJoin(processedTable, join.on);
|
|
330
|
+
break;
|
|
331
|
+
case 'left':
|
|
332
|
+
default:
|
|
333
|
+
builder.leftJoin(processedTable, join.on);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
189
339
|
/**
|
|
190
340
|
* 添加默认的 state 过滤条件
|
|
191
341
|
* 默认查询 state > 0 的数据(排除已删除和特殊状态)
|
|
@@ -375,17 +525,27 @@ export class DbHelper {
|
|
|
375
525
|
* 查询记录数
|
|
376
526
|
* @param options.table - 表名(支持小驼峰或下划线格式,会自动转换)
|
|
377
527
|
* @param options.where - 查询条件
|
|
528
|
+
* @param options.joins - 多表联查选项
|
|
378
529
|
* @example
|
|
379
530
|
* // 查询总数
|
|
380
531
|
* const count = await db.getCount({ table: 'user' });
|
|
381
532
|
* // 查询指定条件的记录数
|
|
382
533
|
* const activeCount = await db.getCount({ table: 'user', where: { state: 1 } });
|
|
534
|
+
* // 联查计数
|
|
535
|
+
* const count = await db.getCount({
|
|
536
|
+
* table: 'order o',
|
|
537
|
+
* joins: [{ table: 'user u', on: 'o.user_id = u.id' }],
|
|
538
|
+
* where: { 'o.state': 1 }
|
|
539
|
+
* });
|
|
383
540
|
*/
|
|
384
541
|
async getCount(options: Omit<QueryOptions, 'fields' | 'page' | 'limit' | 'orderBy'>): Promise<number> {
|
|
385
|
-
const { table, where } = await this.prepareQueryOptions(options as QueryOptions);
|
|
542
|
+
const { table, where, joins } = await this.prepareQueryOptions(options as QueryOptions);
|
|
386
543
|
|
|
387
544
|
const builder = new SqlBuilder().select(['COUNT(*) as count']).from(table).where(this.addDefaultStateFilter(where));
|
|
388
545
|
|
|
546
|
+
// 添加 JOIN
|
|
547
|
+
this.applyJoins(builder, joins);
|
|
548
|
+
|
|
389
549
|
const { sql, params } = builder.toSelectSql();
|
|
390
550
|
const result = await this.executeWithConn(sql, params);
|
|
391
551
|
|
|
@@ -394,18 +554,28 @@ export class DbHelper {
|
|
|
394
554
|
|
|
395
555
|
/**
|
|
396
556
|
* 查询单条数据
|
|
397
|
-
* @param options.table -
|
|
398
|
-
* @param options.fields -
|
|
557
|
+
* @param options.table - 表名(支持小驼峰或下划线格式,会自动转换;联查时可带别名如 'order o')
|
|
558
|
+
* @param options.fields - 字段列表(联查时需带表别名,如 'o.id', 'u.username')
|
|
559
|
+
* @param options.joins - 多表联查选项
|
|
399
560
|
* @example
|
|
400
|
-
* //
|
|
561
|
+
* // 单表查询
|
|
401
562
|
* getOne({ table: 'userProfile', fields: ['userId', 'userName'] })
|
|
402
|
-
*
|
|
563
|
+
* // 联查
|
|
564
|
+
* getOne({
|
|
565
|
+
* table: 'order o',
|
|
566
|
+
* joins: [{ table: 'user u', on: 'o.user_id = u.id' }],
|
|
567
|
+
* fields: ['o.id', 'o.totalAmount', 'u.username'],
|
|
568
|
+
* where: { 'o.id': 1 }
|
|
569
|
+
* })
|
|
403
570
|
*/
|
|
404
571
|
async getOne<T extends Record<string, any> = Record<string, any>>(options: QueryOptions): Promise<T | null> {
|
|
405
|
-
const { table, fields, where } = await this.prepareQueryOptions(options);
|
|
572
|
+
const { table, fields, where, joins } = await this.prepareQueryOptions(options);
|
|
406
573
|
|
|
407
574
|
const builder = new SqlBuilder().select(fields).from(table).where(this.addDefaultStateFilter(where));
|
|
408
575
|
|
|
576
|
+
// 添加 JOIN
|
|
577
|
+
this.applyJoins(builder, joins);
|
|
578
|
+
|
|
409
579
|
const { sql, params } = builder.toSelectSql();
|
|
410
580
|
const result = await this.executeWithConn(sql, params);
|
|
411
581
|
|
|
@@ -421,11 +591,25 @@ export class DbHelper {
|
|
|
421
591
|
|
|
422
592
|
/**
|
|
423
593
|
* 查询列表(带分页)
|
|
424
|
-
* @param options.table -
|
|
425
|
-
* @param options.fields -
|
|
594
|
+
* @param options.table - 表名(支持小驼峰或下划线格式,会自动转换;联查时可带别名)
|
|
595
|
+
* @param options.fields - 字段列表(联查时需带表别名)
|
|
596
|
+
* @param options.joins - 多表联查选项
|
|
426
597
|
* @example
|
|
427
|
-
* //
|
|
598
|
+
* // 单表分页
|
|
428
599
|
* getList({ table: 'userProfile', fields: ['userId', 'userName', 'createdAt'] })
|
|
600
|
+
* // 联查分页
|
|
601
|
+
* getList({
|
|
602
|
+
* table: 'order o',
|
|
603
|
+
* joins: [
|
|
604
|
+
* { table: 'user u', on: 'o.user_id = u.id' },
|
|
605
|
+
* { table: 'product p', on: 'o.product_id = p.id' }
|
|
606
|
+
* ],
|
|
607
|
+
* fields: ['o.id', 'o.totalAmount', 'u.username', 'p.name AS productName'],
|
|
608
|
+
* where: { 'o.status': 'paid' },
|
|
609
|
+
* orderBy: ['o.createdAt#DESC'],
|
|
610
|
+
* page: 1,
|
|
611
|
+
* limit: 10
|
|
612
|
+
* })
|
|
429
613
|
*/
|
|
430
614
|
async getList<T extends Record<string, any> = Record<string, any>>(options: QueryOptions): Promise<ListResult<T>> {
|
|
431
615
|
const prepared = await this.prepareQueryOptions(options);
|
|
@@ -444,6 +628,9 @@ export class DbHelper {
|
|
|
444
628
|
// 查询总数
|
|
445
629
|
const countBuilder = new SqlBuilder().select(['COUNT(*) as total']).from(prepared.table).where(whereFiltered);
|
|
446
630
|
|
|
631
|
+
// 添加 JOIN(计数也需要)
|
|
632
|
+
this.applyJoins(countBuilder, prepared.joins);
|
|
633
|
+
|
|
447
634
|
const { sql: countSql, params: countParams } = countBuilder.toSelectSql();
|
|
448
635
|
const countResult = await this.executeWithConn(countSql, countParams);
|
|
449
636
|
const total = countResult?.[0]?.total || 0;
|
|
@@ -463,6 +650,9 @@ export class DbHelper {
|
|
|
463
650
|
const offset = (prepared.page - 1) * prepared.limit;
|
|
464
651
|
const dataBuilder = new SqlBuilder().select(prepared.fields).from(prepared.table).where(whereFiltered).limit(prepared.limit).offset(offset);
|
|
465
652
|
|
|
653
|
+
// 添加 JOIN
|
|
654
|
+
this.applyJoins(dataBuilder, prepared.joins);
|
|
655
|
+
|
|
466
656
|
// 只有用户明确指定了 orderBy 才添加排序
|
|
467
657
|
if (prepared.orderBy && prepared.orderBy.length > 0) {
|
|
468
658
|
dataBuilder.orderBy(prepared.orderBy);
|
|
@@ -486,12 +676,20 @@ export class DbHelper {
|
|
|
486
676
|
|
|
487
677
|
/**
|
|
488
678
|
* 查询所有数据(不分页,有上限保护)
|
|
489
|
-
* @param options.table -
|
|
490
|
-
* @param options.fields -
|
|
679
|
+
* @param options.table - 表名(支持小驼峰或下划线格式,会自动转换;联查时可带别名)
|
|
680
|
+
* @param options.fields - 字段列表(联查时需带表别名)
|
|
681
|
+
* @param options.joins - 多表联查选项
|
|
491
682
|
* ⚠️ 警告:此方法会查询大量数据,建议使用 getList 分页查询
|
|
492
683
|
* @example
|
|
493
|
-
* //
|
|
684
|
+
* // 单表查询
|
|
494
685
|
* getAll({ table: 'userProfile', fields: ['userId', 'userName'] })
|
|
686
|
+
* // 联查
|
|
687
|
+
* getAll({
|
|
688
|
+
* table: 'order o',
|
|
689
|
+
* joins: [{ table: 'user u', on: 'o.user_id = u.id' }],
|
|
690
|
+
* fields: ['o.id', 'u.username'],
|
|
691
|
+
* where: { 'o.state': 1 }
|
|
692
|
+
* })
|
|
495
693
|
*/
|
|
496
694
|
async getAll<T extends Record<string, any> = Record<string, any>>(options: Omit<QueryOptions, 'page' | 'limit'>): Promise<T[]> {
|
|
497
695
|
// 添加硬性上限保护,防止内存溢出
|
|
@@ -502,6 +700,9 @@ export class DbHelper {
|
|
|
502
700
|
|
|
503
701
|
const builder = new SqlBuilder().select(prepared.fields).from(prepared.table).where(this.addDefaultStateFilter(prepared.where)).limit(MAX_LIMIT);
|
|
504
702
|
|
|
703
|
+
// 添加 JOIN
|
|
704
|
+
this.applyJoins(builder, prepared.joins);
|
|
705
|
+
|
|
505
706
|
if (prepared.orderBy && prepared.orderBy.length > 0) {
|
|
506
707
|
builder.orderBy(prepared.orderBy);
|
|
507
708
|
}
|
package/lib/sqlBuilder.ts
CHANGED
|
@@ -355,6 +355,30 @@ export class SqlBuilder {
|
|
|
355
355
|
return this;
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
/**
|
|
359
|
+
* RIGHT JOIN
|
|
360
|
+
*/
|
|
361
|
+
rightJoin(table: string, on: string): this {
|
|
362
|
+
if (typeof table !== 'string' || typeof on !== 'string') {
|
|
363
|
+
throw new Error(`JOIN 表名和条件必须是字符串 (table: ${table}, on: ${on})`);
|
|
364
|
+
}
|
|
365
|
+
const escapedTable = this._escapeTable(table);
|
|
366
|
+
this._joins.push(`RIGHT JOIN ${escapedTable} ON ${on}`);
|
|
367
|
+
return this;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* INNER JOIN
|
|
372
|
+
*/
|
|
373
|
+
innerJoin(table: string, on: string): this {
|
|
374
|
+
if (typeof table !== 'string' || typeof on !== 'string') {
|
|
375
|
+
throw new Error(`JOIN 表名和条件必须是字符串 (table: ${table}, on: ${on})`);
|
|
376
|
+
}
|
|
377
|
+
const escapedTable = this._escapeTable(table);
|
|
378
|
+
this._joins.push(`INNER JOIN ${escapedTable} ON ${on}`);
|
|
379
|
+
return this;
|
|
380
|
+
}
|
|
381
|
+
|
|
358
382
|
/**
|
|
359
383
|
* ORDER BY
|
|
360
384
|
* @param fields - 格式为 ["field#ASC", "field2#DESC"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "befly",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.7",
|
|
4
4
|
"description": "Befly - 为 Bun 专属打造的 TypeScript API 接口框架核心引擎",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"license": "Apache-2.0",
|
|
39
39
|
"files": [
|
|
40
40
|
"checks",
|
|
41
|
+
"docs",
|
|
41
42
|
"dist",
|
|
42
43
|
"hooks",
|
|
43
44
|
"lib",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"bun": ">=1.3.0"
|
|
65
66
|
},
|
|
66
67
|
"dependencies": {
|
|
67
|
-
"befly-shared": "^1.2.
|
|
68
|
+
"befly-shared": "^1.2.2",
|
|
68
69
|
"chalk": "^5.6.2",
|
|
69
70
|
"es-toolkit": "^1.42.0",
|
|
70
71
|
"fast-jwt": "^6.1.0",
|
|
@@ -73,7 +74,7 @@
|
|
|
73
74
|
"pino": "^10.1.0",
|
|
74
75
|
"pino-roll": "^4.0.0"
|
|
75
76
|
},
|
|
76
|
-
"gitHead": "
|
|
77
|
+
"gitHead": "2a0ccabca4ae72fbf490d41a6c3e5aeb63fb08c6",
|
|
77
78
|
"devDependencies": {
|
|
78
79
|
"typescript": "^5.9.3"
|
|
79
80
|
}
|