@xfilecom/xframe 0.1.14 → 0.1.15
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/package.json +1 -1
- package/template/apps/api/src/app.module.ts +4 -2
- package/template/apps/api/src/config.loader.ts +2 -0
- package/template/apps/api/src/sql-query/sql-query.controller.ts +62 -0
- package/template/apps/api/src/sql-query/sql-query.service.ts +236 -0
- package/template/apps/api/tsconfig.build.json +5 -1
- package/template/docs/DATABASE.md +16 -0
- package/template/shared/README.md +1 -1
- package/template/shared/config/api/application.yml +6 -1
- package/template/shared/schema/README.md +1 -1
- package/template/shared/sql/README.md +46 -4
- package/template/shared/sql/pageable-drizzle.ts +28 -0
- package/template/shared/sql/pageable-tables.ts +74 -0
- package/template/shared/sql/sql.ts +140 -0
- package/template/shared/endpoint/.gitkeep +0 -0
- package/template/shared/schema/.gitkeep +0 -0
- package/template/shared/sql/.gitkeep +0 -0
package/package.json
CHANGED
|
@@ -3,6 +3,8 @@ import { CoreModule, type CoreModuleOptions } from '@xfilecom/backend-core';
|
|
|
3
3
|
import { appConfig } from './config.loader';
|
|
4
4
|
import { AppController } from './app.controller';
|
|
5
5
|
import { AppService } from './app.service';
|
|
6
|
+
import { SqlQueryController } from './sql-query/sql-query.controller';
|
|
7
|
+
import { SqlQueryService } from './sql-query/sql-query.service';
|
|
6
8
|
import { schema } from '../../../shared/schema';
|
|
7
9
|
|
|
8
10
|
function coreOptionsFromYaml(): CoreModuleOptions {
|
|
@@ -20,7 +22,7 @@ function coreOptionsFromYaml(): CoreModuleOptions {
|
|
|
20
22
|
|
|
21
23
|
@Module({
|
|
22
24
|
imports: [CoreModule.forHttpApi(coreOptionsFromYaml())],
|
|
23
|
-
controllers: [AppController],
|
|
24
|
-
providers: [AppService],
|
|
25
|
+
controllers: [AppController, SqlQueryController],
|
|
26
|
+
providers: [AppService, SqlQueryService],
|
|
25
27
|
})
|
|
26
28
|
export class AppModule {}
|
|
@@ -70,5 +70,7 @@ export type AppConfigShape = {
|
|
|
70
70
|
filters?: { exception?: boolean | Record<string, unknown> };
|
|
71
71
|
guards?: { jwt?: boolean };
|
|
72
72
|
jwt?: { secret?: string; expiresIn?: string };
|
|
73
|
+
/** true: GET/POST sql-query 라우트 (endpointSqlQueries·pageable·/api/database) — 운영 비권장 */
|
|
74
|
+
sqlEndpoint?: { enabled?: boolean };
|
|
73
75
|
};
|
|
74
76
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Get,
|
|
5
|
+
NotFoundException,
|
|
6
|
+
Param,
|
|
7
|
+
Post,
|
|
8
|
+
} from '@nestjs/common';
|
|
9
|
+
import { ControllerHelpers, Public } from '@xfilecom/backend-core';
|
|
10
|
+
import { appConfig } from '../config.loader';
|
|
11
|
+
import type { SqlDatabaseListPostBody, SqlListPostBody } from '../../../../shared/sql/sql';
|
|
12
|
+
import { SqlQueryService } from './sql-query.service';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* - GET `/api/:resource/:action` → `endpointSqlQueries` 정적 SQL
|
|
16
|
+
* - POST `/api/database` + `{ table, action?, page?, sort?, search? }` → MySQL 테이블명 기준 목록
|
|
17
|
+
* - POST `/api/:resource/list` + JSON 본문 → 스키마 export 이름 기준 목록
|
|
18
|
+
*/
|
|
19
|
+
@Controller('api')
|
|
20
|
+
export class SqlQueryController {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly sqlQuery: SqlQueryService,
|
|
23
|
+
private readonly controllerHelpers: ControllerHelpers,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
@Public()
|
|
27
|
+
@Post('database')
|
|
28
|
+
async runDatabase(@Body() body: SqlDatabaseListPostBody) {
|
|
29
|
+
if (!appConfig.core?.sqlEndpoint?.enabled) {
|
|
30
|
+
throw new NotFoundException();
|
|
31
|
+
}
|
|
32
|
+
const result = await this.sqlQuery.runDatabaseList(body);
|
|
33
|
+
return this.controllerHelpers.success(result);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Public()
|
|
37
|
+
@Get(':resource/:action')
|
|
38
|
+
async runGet(
|
|
39
|
+
@Param('resource') resource: string,
|
|
40
|
+
@Param('action') action: string,
|
|
41
|
+
) {
|
|
42
|
+
if (!appConfig.core?.sqlEndpoint?.enabled) {
|
|
43
|
+
throw new NotFoundException();
|
|
44
|
+
}
|
|
45
|
+
const rows = await this.sqlQuery.run(resource, action);
|
|
46
|
+
return this.controllerHelpers.success(rows);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Public()
|
|
50
|
+
@Post(':resource/:action')
|
|
51
|
+
async runPost(
|
|
52
|
+
@Param('resource') resource: string,
|
|
53
|
+
@Param('action') action: string,
|
|
54
|
+
@Body() body: SqlListPostBody,
|
|
55
|
+
) {
|
|
56
|
+
if (!appConfig.core?.sqlEndpoint?.enabled) {
|
|
57
|
+
throw new NotFoundException();
|
|
58
|
+
}
|
|
59
|
+
const result = await this.sqlQuery.runDynamic(resource, action, body ?? {});
|
|
60
|
+
return this.controllerHelpers.success(result);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BadRequestException,
|
|
3
|
+
Injectable,
|
|
4
|
+
Optional,
|
|
5
|
+
ServiceUnavailableException,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { DatabaseService } from '@xfilecom/backend-core';
|
|
8
|
+
import type { MySqlTable } from 'drizzle-orm/mysql-core';
|
|
9
|
+
import type { RowDataPacket } from 'mysql2';
|
|
10
|
+
import {
|
|
11
|
+
listPageableColumnNames,
|
|
12
|
+
pageableFromClause,
|
|
13
|
+
pickDefaultSortColumn,
|
|
14
|
+
} from '../../../../shared/sql/pageable-drizzle';
|
|
15
|
+
import {
|
|
16
|
+
getPageableResourceKeyByMysqlTableName,
|
|
17
|
+
getPageableTable,
|
|
18
|
+
getPageableTableByMysqlTableName,
|
|
19
|
+
PAGEABLE_RESOURCE_OPTIONS,
|
|
20
|
+
} from '../../../../shared/sql/pageable-tables';
|
|
21
|
+
import {
|
|
22
|
+
escapeLikePattern,
|
|
23
|
+
isSafeSqlIdentifier,
|
|
24
|
+
PAGEABLE_LIST_DEFAULTS,
|
|
25
|
+
resolveEndpointSqlQuery,
|
|
26
|
+
type SqlDatabaseListPostBody,
|
|
27
|
+
type SqlListPostBody,
|
|
28
|
+
} from '../../../../shared/sql/sql';
|
|
29
|
+
|
|
30
|
+
@Injectable()
|
|
31
|
+
export class SqlQueryService {
|
|
32
|
+
constructor(@Optional() private readonly database?: DatabaseService) {}
|
|
33
|
+
|
|
34
|
+
private pool() {
|
|
35
|
+
if (!this.database?.isConnected() || !this.database.mysql) {
|
|
36
|
+
throw new ServiceUnavailableException(
|
|
37
|
+
'데이터베이스에 연결되어 있지 않습니다. core.database.auto 와 application.yml database.* 를 확인하세요.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return this.database.mysql;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async run(resource: string, action: string): Promise<unknown> {
|
|
44
|
+
const resolved = resolveEndpointSqlQuery(resource, action);
|
|
45
|
+
if ('missing' in resolved) {
|
|
46
|
+
throw new BadRequestException(
|
|
47
|
+
`SQL 이 등록되어 있지 않습니다. shared/sql/sql.ts 의 endpointSqlQueries 에 ` +
|
|
48
|
+
`'${resource}.${action}' 에 해당하는 문자열 쿼리를 추가하세요. ` +
|
|
49
|
+
`(예: endpointSqlQueries.${resource} = { ${action}: 'SELECT ...' })`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const [rows] = await this.pool().query(resolved.sql);
|
|
54
|
+
return rows;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `POST /api/database` — 본문 `table` 은 MySQL 실제 테이블명 (`app_meta`, `users` 등).
|
|
59
|
+
*/
|
|
60
|
+
async runDatabaseList(body: SqlDatabaseListPostBody): Promise<{
|
|
61
|
+
rows: unknown;
|
|
62
|
+
pagination: {
|
|
63
|
+
total: number;
|
|
64
|
+
page: number;
|
|
65
|
+
limit: number;
|
|
66
|
+
totalPages: number;
|
|
67
|
+
pageRequested?: number;
|
|
68
|
+
};
|
|
69
|
+
}> {
|
|
70
|
+
const action = String(body.action ?? 'list').trim();
|
|
71
|
+
if (action !== 'list') {
|
|
72
|
+
throw new BadRequestException(
|
|
73
|
+
`동적 POST 는 action=list 만 지원합니다. (본문 action 생략 시 list)`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const raw = String(body.table ?? '').trim();
|
|
78
|
+
if (!raw || !isSafeSqlIdentifier(raw)) {
|
|
79
|
+
throw new BadRequestException(
|
|
80
|
+
`유효한 table 이 필요합니다. MySQL 테이블명(식별자)만 허용합니다. 예: { "table": "users", "action": "list" }`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const table = getPageableTableByMysqlTableName(raw);
|
|
85
|
+
if (!table) {
|
|
86
|
+
throw new BadRequestException(
|
|
87
|
+
`table '${raw}' 은 pageable 스키마에 없거나 허용되지 않습니다. Drizzle 스키마·PAGEABLE_SCHEMA_EXPORT_DENYLIST 를 확인하세요.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const resourceKey = getPageableResourceKeyByMysqlTableName(raw);
|
|
92
|
+
if (!resourceKey) {
|
|
93
|
+
throw new BadRequestException(`table '${raw}' 에 대한 리소스 키를 찾을 수 없습니다.`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return this.runListForPageableTable(table, resourceKey, body);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `POST .../:resource/list` — `resource` 는 스키마 export 이름.
|
|
101
|
+
*/
|
|
102
|
+
async runDynamic(
|
|
103
|
+
resource: string,
|
|
104
|
+
action: string,
|
|
105
|
+
body: SqlListPostBody,
|
|
106
|
+
): Promise<{
|
|
107
|
+
rows: unknown;
|
|
108
|
+
pagination: {
|
|
109
|
+
total: number;
|
|
110
|
+
page: number;
|
|
111
|
+
limit: number;
|
|
112
|
+
totalPages: number;
|
|
113
|
+
pageRequested?: number;
|
|
114
|
+
};
|
|
115
|
+
}> {
|
|
116
|
+
if (action !== 'list') {
|
|
117
|
+
throw new BadRequestException(
|
|
118
|
+
`동적 POST 는 action=list 만 지원합니다. resource 는 shared/schema 의 Drizzle 테이블 export 이름과 같아야 합니다.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const table = getPageableTable(resource);
|
|
123
|
+
if (!table) {
|
|
124
|
+
throw new BadRequestException(
|
|
125
|
+
`resource '${resource}' 에 해당하는 Drizzle 테이블 export 가 없습니다. shared/schema/schema.ts 에 ` +
|
|
126
|
+
`\`export const ${resource} = mysqlTable(...)\` 가 있는지, PAGEABLE_SCHEMA_EXPORT_DENYLIST 에 막혀 있지 않은지 확인하세요.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return this.runListForPageableTable(table, resource, body);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async runListForPageableTable(
|
|
134
|
+
table: MySqlTable,
|
|
135
|
+
resourceKeyForOptions: string,
|
|
136
|
+
body: SqlListPostBody,
|
|
137
|
+
): Promise<{
|
|
138
|
+
rows: unknown;
|
|
139
|
+
pagination: {
|
|
140
|
+
total: number;
|
|
141
|
+
page: number;
|
|
142
|
+
limit: number;
|
|
143
|
+
totalPages: number;
|
|
144
|
+
pageRequested?: number;
|
|
145
|
+
};
|
|
146
|
+
}> {
|
|
147
|
+
const allowed = listPageableColumnNames(table);
|
|
148
|
+
if (allowed.length === 0) {
|
|
149
|
+
throw new BadRequestException(
|
|
150
|
+
`테이블에 사용 가능한 컬럼이 없습니다. 스키마·블록리스트를 확인하세요.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ro = PAGEABLE_RESOURCE_OPTIONS[resourceKeyForOptions] ?? {};
|
|
155
|
+
const maxLimit = ro.maxLimit ?? PAGEABLE_LIST_DEFAULTS.maxLimit;
|
|
156
|
+
const defaultLimit = ro.defaultLimit ?? PAGEABLE_LIST_DEFAULTS.defaultLimit;
|
|
157
|
+
const defaultSortOrder = ro.defaultSortOrder ?? PAGEABLE_LIST_DEFAULTS.defaultSortOrder;
|
|
158
|
+
|
|
159
|
+
const pageRequested = Math.max(1, Math.floor(Number(body.page?.page) || 1));
|
|
160
|
+
const limitRaw = Math.floor(Number(body.page?.limit) || defaultLimit);
|
|
161
|
+
const limit = Math.min(maxLimit, Math.max(1, limitRaw));
|
|
162
|
+
|
|
163
|
+
const defaultSortField = pickDefaultSortColumn(allowed);
|
|
164
|
+
const sortFieldRaw = body.sort?.field?.trim();
|
|
165
|
+
const sortField =
|
|
166
|
+
sortFieldRaw &&
|
|
167
|
+
allowed.includes(sortFieldRaw) &&
|
|
168
|
+
isSafeSqlIdentifier(sortFieldRaw)
|
|
169
|
+
? sortFieldRaw
|
|
170
|
+
: defaultSortField;
|
|
171
|
+
if (!isSafeSqlIdentifier(sortField) || !allowed.includes(sortField)) {
|
|
172
|
+
throw new BadRequestException(`유효하지 않은 기본 정렬 컬럼: ${defaultSortField}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const orderRaw = body.sort?.order?.toLowerCase();
|
|
176
|
+
const sortDir =
|
|
177
|
+
orderRaw === 'asc' || orderRaw === 'desc' ? orderRaw : defaultSortOrder;
|
|
178
|
+
|
|
179
|
+
const term = String(body.search?.term ?? '').trim();
|
|
180
|
+
const requestedFields = Array.isArray(body.search?.fields) ? body.search!.fields! : [];
|
|
181
|
+
const searchFields = requestedFields.filter(
|
|
182
|
+
(f) => typeof f === 'string' && isSafeSqlIdentifier(f) && allowed.includes(f),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const fromClause = pageableFromClause(table);
|
|
186
|
+
const whereParts: string[] = ['1=1'];
|
|
187
|
+
const params: unknown[] = [];
|
|
188
|
+
|
|
189
|
+
if (term.length > 0 && searchFields.length > 0) {
|
|
190
|
+
const likes = searchFields.map((f) => `\`${f}\` LIKE ?`);
|
|
191
|
+
whereParts.push(`(${likes.join(' OR ')})`);
|
|
192
|
+
const likeVal = `%${escapeLikePattern(term)}%`;
|
|
193
|
+
searchFields.forEach(() => params.push(likeVal));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
197
|
+
const countSql = `SELECT COUNT(*) AS __cnt ${fromClause} ${whereSql}`;
|
|
198
|
+
const dataSql =
|
|
199
|
+
`SELECT * ${fromClause} ${whereSql} ` +
|
|
200
|
+
`ORDER BY \`${sortField}\` ${sortDir.toUpperCase()} LIMIT ? OFFSET ?`;
|
|
201
|
+
|
|
202
|
+
const pool = this.pool();
|
|
203
|
+
const [countRows] = await pool.query<RowDataPacket[]>(countSql, params);
|
|
204
|
+
const total = Number((countRows[0] as { __cnt?: number })?.__cnt ?? 0);
|
|
205
|
+
|
|
206
|
+
const totalPages = limit > 0 ? Math.ceil(total / limit) : 0;
|
|
207
|
+
let page = pageRequested;
|
|
208
|
+
if (totalPages > 0) {
|
|
209
|
+
page = Math.max(1, Math.min(pageRequested, totalPages));
|
|
210
|
+
} else {
|
|
211
|
+
page = 1;
|
|
212
|
+
}
|
|
213
|
+
const offset = (page - 1) * limit;
|
|
214
|
+
|
|
215
|
+
const dataParams = [...params, limit, offset];
|
|
216
|
+
const [rows] = await pool.query(dataSql, dataParams);
|
|
217
|
+
|
|
218
|
+
const pagination: {
|
|
219
|
+
total: number;
|
|
220
|
+
page: number;
|
|
221
|
+
limit: number;
|
|
222
|
+
totalPages: number;
|
|
223
|
+
pageRequested?: number;
|
|
224
|
+
} = {
|
|
225
|
+
total,
|
|
226
|
+
page,
|
|
227
|
+
limit,
|
|
228
|
+
totalPages,
|
|
229
|
+
};
|
|
230
|
+
if (page !== pageRequested) {
|
|
231
|
+
pagination.pageRequested = pageRequested;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { rows, pagination };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"rootDir": "../../"
|
|
5
5
|
},
|
|
6
|
-
"include": [
|
|
6
|
+
"include": [
|
|
7
|
+
"src/**/*",
|
|
8
|
+
"../../shared/schema/**/*.ts",
|
|
9
|
+
"../../shared/sql/**/*.ts"
|
|
10
|
+
],
|
|
7
11
|
"exclude": ["node_modules", "dist", "**/*spec.ts"]
|
|
8
12
|
}
|
|
@@ -120,3 +120,19 @@ export class AppModule {}
|
|
|
120
120
|
## 6. 연결 실패 시
|
|
121
121
|
|
|
122
122
|
`DatabaseService` 는 연결에 실패해도 **애플리케이션 기동은 계속**하고 경고만 남깁니다. DB 가 필요한 핸들러에서는 `isConnected()` 확인 또는 try/catch 로 처리하세요.
|
|
123
|
+
|
|
124
|
+
## 7. HTTP 로 정적·동적 조회 (`core.sqlEndpoint.enabled`)
|
|
125
|
+
|
|
126
|
+
`apps/api/src/sql-query/` — **`core.sqlEndpoint.enabled: true`** 이고 **`core.database.auto: true`** 일 때만 라우트가 살아 있습니다.
|
|
127
|
+
|
|
128
|
+
| 메서드·경로 | 출처 | 설명 |
|
|
129
|
+
|-------------|------|------|
|
|
130
|
+
| `GET /api/:resource/:action` | `shared/sql/sql.ts` 의 `endpointSqlQueries` | 등록된 SQL 문자열만 실행 |
|
|
131
|
+
| `POST /api/:resource/list` | `pageable-tables.ts` + Drizzle | URL `resource` = 스키마 **export 이름**. 스키마의 테이블 `export` 를 `isTable` 로 모아 자동 허용 |
|
|
132
|
+
| `POST /api/database` | 동일 | 본문 `table` = MySQL **물리 테이블명** (`mysqlTable('app_meta', …)` 첫 인자) |
|
|
133
|
+
|
|
134
|
+
- 컬럼 정렬·LIKE 검색 허용 범위: `shared/sql/pageable-drizzle.ts` + `PAGEABLE_COLUMN_BLOCKLIST_SUBSTRINGS` (`sql.ts`).
|
|
135
|
+
- 테이블 노출 제외: `PAGEABLE_SCHEMA_EXPORT_DENYLIST` (`pageable-tables.ts`).
|
|
136
|
+
- 페이지 한도 등: `PAGEABLE_RESOURCE_OPTIONS` / `PAGEABLE_LIST_DEFAULTS`.
|
|
137
|
+
|
|
138
|
+
자세한 curl·주의사항은 **`shared/sql/README.md`** 를 참고하세요.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
| **`config/api`** | Nest API YAML (`application.yml`, `application-<env>.yml`) |
|
|
8
8
|
| **`config/web/client`**, **`config/web/admin`** | 각 Vite 앱별 YAML (`VITE_API_BASE_URL`, `VITE_APP_TITLE` 등 주입) |
|
|
9
9
|
| **`schema/`** | Drizzle 등 DB 스키마(TS). `apps/api`의 `tsconfig.build.json`에 경로를 포함하거나, 스키마만 `apps/api/src/database`에 두고 여기서 re-export 하는 식으로 맞추면 됩니다. Nest 에서의 주입·쿼리 패턴은 루트 **[docs/DATABASE.md](../docs/DATABASE.md)** 참고. |
|
|
10
|
-
| **`sql/`** | 마이그레이션·시드·원시 SQL (`
|
|
10
|
+
| **`sql/`** | 마이그레이션·시드·원시 SQL; **`sql/sql.ts`** (`databaseSqlManifest`) 로 경로·절차 메타 정리 |
|
|
11
11
|
| **`endpoint/`** | HTTP 계약(OpenAPI yaml, 공유 DTO 타입, 라우트 메타) — 제품에 맞게 확장 |
|
|
12
12
|
|
|
13
13
|
`apps/api/src/config.loader.ts`는 **`shared/config/api`** 를 읽습니다.
|
|
@@ -31,8 +31,13 @@ cors:
|
|
|
31
31
|
origin: true
|
|
32
32
|
|
|
33
33
|
core:
|
|
34
|
+
# true: GET /api/:resource/:action → sql.ts 의 endpointSqlQueries
|
|
35
|
+
# POST /api/:resource/list, POST /api/database → pageable 목록 (Drizzle 스키마·컬럼 화이트리스트)
|
|
36
|
+
# 운영·공개망에서는 false 권장 (임의 SQL·대량 조회 노출 방지). 로컬 개발만 true.
|
|
37
|
+
sqlEndpoint:
|
|
38
|
+
enabled: false
|
|
34
39
|
database:
|
|
35
|
-
# true 시 부팅 시 MySQL 연결 시도, DatabaseService 가 성공/실패 로그 출력
|
|
40
|
+
# true 시 부팅 시 MySQL 연결 시도, DatabaseService 가 성공/실패 로그 출력 (위 엔드포인트에 필요)
|
|
36
41
|
auto: true
|
|
37
42
|
interceptors:
|
|
38
43
|
logging: true
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Schema
|
|
2
2
|
|
|
3
3
|
- Drizzle `schema.ts` / 테이블 정의를 여기 두고, `apps/api`에서 import 해 `CoreModule`의 `database: { auto: true, schema }`에 넘깁니다.
|
|
4
|
-
- `index.ts`는 `export const schema` 로 테이블을
|
|
4
|
+
- `index.ts`는 `export const schema` 로 테이블을 묶고 `export * from './schema'` 로 re-export 합니다. `shared/sql/pageable-tables.ts` 가 **`import * from '../schema'`** 로 이 엔트리를 읽어 `POST /api/.../list`·`POST /api/database` 허용 테이블을 자동 수집합니다. `npm run db:pull` 후 `relations.ts`가 생기면 Drizzle 문서에 맞게 `index.ts`를 정리하세요.
|
|
5
5
|
- Nest 기본 빌드는 `apps/api/src`만 컴파일하므로, `shared/schema`에 TS를 두면 **`apps/api/tsconfig.build.json`의 `include`** 에 `../../shared/schema/**/*.ts` 를 추가하거나, 스키마를 `apps/api/src/database/schema`에 두고 이 폴더는 문서·SQL 덤프용으로만 써도 됩니다.
|
|
6
6
|
|
|
7
7
|
## `DatabaseQuery` 와 테이블 이름
|
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
# SQL
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
- **`sql.ts`** — 마이그레이션 경로·npm 스크립트 이름·시드 순서·운영 체크리스트 등을 JSON에 가깝게 모아 둔 메타.
|
|
4
|
+
|
|
5
|
+
## `sql.ts` 사용법
|
|
6
|
+
|
|
7
|
+
1. **역할**
|
|
8
|
+
`databaseSqlManifest` 등은 Nest가 자동으로 읽지 **않습니다**. 스크립트·CI·문서용입니다. **SQL HTTP 엔드포인트**는 `endpointSqlQueries`·`SqlQueryService` 가 런타임에 사용합니다.
|
|
9
|
+
|
|
10
|
+
2. **TypeScript에서 import**
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { databaseSqlManifest, type DatabaseSqlManifest } from './shared/sql/sql';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
3. **JSON 덤프**
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx tsx -e "import { databaseSqlManifest } from './shared/sql/sql.ts'; console.log(JSON.stringify(databaseSqlManifest, null, 2))"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
4. **시드** — `shared/sql/seeds/` 와 `databaseSqlManifest.seeds.order` 를 팀 스크립트가 읽어 실행.
|
|
23
|
+
|
|
24
|
+
5. **`drizzle.config.ts`** 변경 시 `sql.ts` 의 `drizzleKit` 도 동기화.
|
|
25
|
+
|
|
26
|
+
## REST `GET /api/:resource/:action` (`endpointSqlQueries`)
|
|
27
|
+
|
|
28
|
+
- `core.sqlEndpoint.enabled: true` 일 때만 동작. **템플릿 기본은 `false`** (운영·공개망에서는 끄는 것을 권장). 로컬에서 동적 조회를 쓰려면 `true` 로 켜고 `core.database.auto: true` 와 `database.*` 를 맞춥니다.
|
|
29
|
+
|
|
30
|
+
## REST `POST /api/:resource/list` — Drizzle 스키마 자동 연동
|
|
31
|
+
|
|
32
|
+
- URL `resource` = **`shared/schema` 의 테이블 `export` 이름** (`export const appMeta` → `/api/appMeta/list`).
|
|
33
|
+
- `pageable-tables.ts` 가 `import *` 로 **`../schema`(index)** 를 읽고 `isTable` 로 테이블만 수집합니다. `npm run db:pull` 로 테이블이 늘어도 맵을 수동 편집할 필요 없습니다.
|
|
34
|
+
- 내부 전용 테이블은 `PAGEABLE_SCHEMA_EXPORT_DENYLIST` 에 export 이름 추가.
|
|
35
|
+
- 컬럼·페이지·검색 규칙은 example 의 `shared/sql/README.md` 와 동일합니다.
|
|
36
|
+
|
|
37
|
+
## REST `POST /api/database`
|
|
38
|
+
|
|
39
|
+
- 본문에 **`table`** (MySQL 물리 테이블명, 예: `app_meta`, `users`), 선택 **`action`** (생략 시 `list`).
|
|
40
|
+
- 허용 테이블은 Drizzle 스키마에 있고 denylist 가 아닌 것만.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
curl -sS -X POST 'http://localhost:3000/api/database' \
|
|
44
|
+
-H 'Content-Type: application/json' \
|
|
45
|
+
-d '{"table":"app_meta","page":{"limit":10}}'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- 마이그레이션 산출물은 `shared/sql/migrations/` 아래입니다.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getTableConfig } from 'drizzle-orm/mysql-core';
|
|
2
|
+
import type { MySqlTable } from 'drizzle-orm/mysql-core';
|
|
3
|
+
import { isSafeSqlIdentifier, PAGEABLE_COLUMN_BLOCKLIST_SUBSTRINGS } from './sql';
|
|
4
|
+
|
|
5
|
+
/** Drizzle 테이블에서 정렬·검색 허용 컬럼(DB 이름). 블록리스트·식별자 안전 규칙 적용. */
|
|
6
|
+
export function listPageableColumnNames(table: MySqlTable): string[] {
|
|
7
|
+
const cfg = getTableConfig(table);
|
|
8
|
+
return cfg.columns
|
|
9
|
+
.map((c) => c.name)
|
|
10
|
+
.filter((n) => isSafeSqlIdentifier(n))
|
|
11
|
+
.filter(
|
|
12
|
+
(n) =>
|
|
13
|
+
!PAGEABLE_COLUMN_BLOCKLIST_SUBSTRINGS.some((s) =>
|
|
14
|
+
n.toLowerCase().includes(s),
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function pickDefaultSortColumn(allowed: string[]): string {
|
|
20
|
+
if (allowed.includes('id')) return 'id';
|
|
21
|
+
return allowed[0] ?? 'id';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `FROM \`table_name\`` — 스키마 접두사는 Drizzle 설정 따름 */
|
|
25
|
+
export function pageableFromClause(table: MySqlTable): string {
|
|
26
|
+
const name = getTableConfig(table).name.replace(/`/g, '');
|
|
27
|
+
return `FROM \`${name}\``;
|
|
28
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { isTable } from 'drizzle-orm';
|
|
2
|
+
import { getTableConfig } from 'drizzle-orm/mysql-core';
|
|
3
|
+
import type { MySqlTable } from 'drizzle-orm/mysql-core';
|
|
4
|
+
/** `db:pull` 후에도 `index.ts` 가 스키마를 모아 re-export 하므로 엔트리는 여기서 통일 */
|
|
5
|
+
import * as schemaModule from '../schema';
|
|
6
|
+
import { isSafeSqlIdentifier } from './sql';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `shared/schema/index.ts` → `./schema` 의 **export 이름**이 API `resource` 가 됩니다.
|
|
10
|
+
* 예: `export const users` → `POST /api/users/list`
|
|
11
|
+
*
|
|
12
|
+
* `npm run db:pull` 후에도 `index.ts` 가 테이블을 re-export 하면 **맵 수동 편집 없이** 반영됩니다.
|
|
13
|
+
* 노출 금지 테이블은 `PAGEABLE_SCHEMA_EXPORT_DENYLIST` 에 export 이름을 넣으세요.
|
|
14
|
+
*/
|
|
15
|
+
export const PAGEABLE_SCHEMA_EXPORT_DENYLIST: ReadonlySet<string> = new Set([
|
|
16
|
+
// 예: 'internalAuditLog',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function collectTablesFromSchema(): Record<string, MySqlTable> {
|
|
20
|
+
const out: Record<string, MySqlTable> = {};
|
|
21
|
+
for (const [name, value] of Object.entries(schemaModule)) {
|
|
22
|
+
if (PAGEABLE_SCHEMA_EXPORT_DENYLIST.has(name)) continue;
|
|
23
|
+
if (isTable(value)) {
|
|
24
|
+
out[name] = value as MySqlTable;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 런타임에 스키마 모듈에서 한 번 수집 */
|
|
31
|
+
export const PAGEABLE_TABLE_BY_RESOURCE: Record<string, MySqlTable> =
|
|
32
|
+
collectTablesFromSchema();
|
|
33
|
+
|
|
34
|
+
/** 리소스별 옵션 (선택, 키 = 스키마 export 이름) */
|
|
35
|
+
export const PAGEABLE_RESOURCE_OPTIONS: Record<
|
|
36
|
+
string,
|
|
37
|
+
{
|
|
38
|
+
maxLimit?: number;
|
|
39
|
+
defaultLimit?: number;
|
|
40
|
+
defaultSortOrder?: 'asc' | 'desc';
|
|
41
|
+
}
|
|
42
|
+
> = {
|
|
43
|
+
appMeta: { defaultLimit: 20 },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function getPageableTable(resource: string): MySqlTable | undefined {
|
|
47
|
+
return PAGEABLE_TABLE_BY_RESOURCE[resource];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mysqlNameOf(table: MySqlTable): string {
|
|
51
|
+
return getTableConfig(table).name.replace(/`/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getPageableTableByMysqlTableName(
|
|
55
|
+
mysqlTableName: string,
|
|
56
|
+
): MySqlTable | undefined {
|
|
57
|
+
const normalized = mysqlTableName.replace(/`/g, '').trim();
|
|
58
|
+
if (!isSafeSqlIdentifier(normalized)) return undefined;
|
|
59
|
+
for (const table of Object.values(PAGEABLE_TABLE_BY_RESOURCE)) {
|
|
60
|
+
if (mysqlNameOf(table) === normalized) return table;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getPageableResourceKeyByMysqlTableName(
|
|
66
|
+
mysqlTableName: string,
|
|
67
|
+
): string | undefined {
|
|
68
|
+
const normalized = mysqlTableName.replace(/`/g, '').trim();
|
|
69
|
+
if (!isSafeSqlIdentifier(normalized)) return undefined;
|
|
70
|
+
for (const [resourceKey, table] of Object.entries(PAGEABLE_TABLE_BY_RESOURCE)) {
|
|
71
|
+
if (mysqlNameOf(table) === normalized) return resourceKey;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB·SQL 작업을 정리한 머신이 읽기 쉬운 JSON 형태 설정.
|
|
3
|
+
* 런타임 Nest와 직접 연결하지 않아도 되고, 스크립트·CI·문서 생성 시 참조용으로 쓸 수 있습니다.
|
|
4
|
+
*
|
|
5
|
+
* `JSON.stringify(databaseSqlManifest, null, 2)` 로 그대로 덤프 가능합니다.
|
|
6
|
+
*/
|
|
7
|
+
export const databaseSqlManifest = {
|
|
8
|
+
meta: {
|
|
9
|
+
kind: 'xframe.database.sql',
|
|
10
|
+
version: 1,
|
|
11
|
+
description:
|
|
12
|
+
'Drizzle Kit 마이그레이션·시드·운영 절차 메타. drizzle.config.ts 의 out/schema 경로와 맞출 것.',
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
drizzleKit: {
|
|
16
|
+
dialect: 'mysql',
|
|
17
|
+
schemaGlobs: ['./shared/schema/**/*.ts'],
|
|
18
|
+
migrationsDirectory: './shared/sql/migrations',
|
|
19
|
+
kitConfigFile: './drizzle.config.ts',
|
|
20
|
+
introspectConfigFile: './drizzle.introspect.config.ts',
|
|
21
|
+
journalRelativePath: './shared/sql/migrations/meta/_journal.json',
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
npmScripts: {
|
|
25
|
+
generate: 'db:generate',
|
|
26
|
+
push: 'db:push',
|
|
27
|
+
migrate: 'db:migrate',
|
|
28
|
+
studio: 'db:studio',
|
|
29
|
+
pull: 'db:pull',
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/** 시드 SQL 실행 순서 (위에서 아래). 파일이 없으면 항목만 두고 enabled: false */
|
|
33
|
+
seeds: {
|
|
34
|
+
directory: './shared/sql/seeds',
|
|
35
|
+
order: [] as {
|
|
36
|
+
id: string;
|
|
37
|
+
file: string;
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
note?: string;
|
|
40
|
+
}[],
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/** 운영·로컬에서 DB 다룰 때 체크리스트 (자동 실행 아님) */
|
|
44
|
+
workflows: {
|
|
45
|
+
localDev: [
|
|
46
|
+
'application.yml database.* 와 실제 MySQL 일치 확인',
|
|
47
|
+
'스키마 변경 후 루트에서 npm run db:generate → 마이그레이션 검토',
|
|
48
|
+
'필요 시 npm run db:migrate 또는 개발 전용 db:push',
|
|
49
|
+
],
|
|
50
|
+
deploy: [
|
|
51
|
+
'백업 후 npm run db:migrate',
|
|
52
|
+
'마이그레이션 실패 시 롤백·저널(shared/sql/migrations/meta) 상태 확인',
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
safety: {
|
|
57
|
+
avoidPartialMigrationCleanup:
|
|
58
|
+
'migrations/meta/_journal.json 만 지우지 말 것 — migrations 폴더 단위로 정리',
|
|
59
|
+
production: {
|
|
60
|
+
preferMigrateOverPush: true,
|
|
61
|
+
neverRunPushOnProduction: true,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
} as const;
|
|
65
|
+
|
|
66
|
+
export type DatabaseSqlManifest = typeof databaseSqlManifest;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* REST `GET /api/:resource/:action` 가 여기서 SQL 문자열을 찾아 실행합니다.
|
|
70
|
+
* 키는 객체 경로로 `resource` + `action` (예: users + findOne → users.findOne).
|
|
71
|
+
* 등록되지 않은 조합이면 API 는 `shared/sql/sql.ts` 에 추가하라는 오류를 돌려줍니다.
|
|
72
|
+
*
|
|
73
|
+
* 보안: 운영에서는 `core.sqlEndpoint.enabled: false` 로 끄세요. 임의 SQL 노출입니다.
|
|
74
|
+
*/
|
|
75
|
+
export const endpointSqlQueries = {
|
|
76
|
+
users: {
|
|
77
|
+
findOne: 'SELECT * FROM users LIMIT 1',
|
|
78
|
+
},
|
|
79
|
+
appMeta: {
|
|
80
|
+
findOne: 'SELECT * FROM app_meta LIMIT 1',
|
|
81
|
+
},
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
export type EndpointSqlQueries = typeof endpointSqlQueries;
|
|
85
|
+
|
|
86
|
+
export function resolveEndpointSqlQuery(
|
|
87
|
+
resource: string,
|
|
88
|
+
action: string,
|
|
89
|
+
): { sql: string } | { missing: true } {
|
|
90
|
+
const root = endpointSqlQueries as Record<string, Record<string, string>>;
|
|
91
|
+
const bucket = root[resource];
|
|
92
|
+
if (!bucket) return { missing: true };
|
|
93
|
+
const sql = bucket[action];
|
|
94
|
+
if (typeof sql !== 'string' || !sql.trim()) return { missing: true };
|
|
95
|
+
return { sql: sql.trim() };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 프론트 POST 본문 — `POST /api/:resource/list`.
|
|
100
|
+
* 정렬·검색 컬럼은 Drizzle 스키마 + 블록리스트로 허용 범위가 정해짐 (`pageable-drizzle.ts`).
|
|
101
|
+
*/
|
|
102
|
+
export interface SqlListPostBody {
|
|
103
|
+
page?: { limit?: number; page?: number };
|
|
104
|
+
sort?: { field?: string; order?: 'asc' | 'desc' };
|
|
105
|
+
search?: { fields?: string[]; term?: string };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `POST /api/database` — MySQL 실제 테이블명(`mysqlTable('users', …)` 의 첫 인자).
|
|
110
|
+
* 스키마에 없거나 denylist 면 거절됨.
|
|
111
|
+
*/
|
|
112
|
+
export interface SqlDatabaseListPostBody extends SqlListPostBody {
|
|
113
|
+
table: string;
|
|
114
|
+
/** 생략 시 `list` */
|
|
115
|
+
action?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 컬럼명(소문자)에 부분 일치하면 정렬·LIKE 검색 제외 */
|
|
119
|
+
export const PAGEABLE_COLUMN_BLOCKLIST_SUBSTRINGS = [
|
|
120
|
+
'password',
|
|
121
|
+
'secret',
|
|
122
|
+
'token',
|
|
123
|
+
'hash',
|
|
124
|
+
] as const;
|
|
125
|
+
|
|
126
|
+
export const PAGEABLE_LIST_DEFAULTS = {
|
|
127
|
+
maxLimit: 100,
|
|
128
|
+
defaultLimit: 10,
|
|
129
|
+
defaultSortOrder: 'desc' as const,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** SQL 식별자(컬럼명) — 화이트리스트 통과 후에도 한 번 더 검증 */
|
|
133
|
+
export function isSafeSqlIdentifier(name: string): boolean {
|
|
134
|
+
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** LIKE 패턴용: % _ \ 이스케이프 */
|
|
138
|
+
export function escapeLikePattern(term: string): string {
|
|
139
|
+
return term.replace(/[\\%_]/g, '\\$&');
|
|
140
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|