@xfilecom/xframe 0.1.13 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfilecom/xframe",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Scaffold full-stack app: Nest + @xfilecom/backend-core, Vite/React + @xfilecom/front-core",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -16,7 +16,7 @@ Nest API (`@xfilecom/backend-core`) + Vite/React client·admin (`@xfilecom/front
16
16
 
17
17
  ## URLs
18
18
 
19
- - API: `http://localhost:3000` — `GET /health` (`AppService`: Drizzle 직접 카운트; `DatabaseQuery` `docs/DATABASE.md`)
19
+ - API: `http://localhost:3000` — `GET /health` (`AppService`: DB 로드·연결 여부만; 쿼리 패턴은 `docs/DATABASE.md`)
20
20
  - Client: `http://localhost:3001` — `CommonResponse` 형태로 `/health` 표시
21
21
  - Admin: `http://localhost:3002`
22
22
 
@@ -1,14 +1,17 @@
1
1
  import { Controller, Get } from '@nestjs/common';
2
- import { Public } from '@xfilecom/backend-core';
2
+ import { ControllerHelpers, Public } from '@xfilecom/backend-core';
3
3
  import { AppService } from './app.service';
4
4
 
5
5
  @Controller()
6
6
  export class AppController {
7
- constructor(private readonly appService: AppService) {}
7
+ constructor(
8
+ private readonly appService: AppService,
9
+ private readonly controllerHelpers: ControllerHelpers,
10
+ ) {}
8
11
 
9
12
  @Public()
10
13
  @Get('health')
11
14
  health() {
12
- return this.appService.getHealth();
15
+ return this.controllerHelpers.success(this.appService.getHealth());
13
16
  }
14
17
  }
@@ -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 {}
@@ -1,15 +1,14 @@
1
1
  import { Injectable, Optional } from '@nestjs/common';
2
- import { sql } from 'drizzle-orm';
3
2
  import { DatabaseService } from '@xfilecom/backend-core';
4
- import { appMeta } from '../../../shared/schema';
5
3
 
6
4
  /**
7
5
  * DB 사용은 backend-core 에서 두 가지가 있습니다.
8
6
  *
9
- * 1. **DatabaseService** — `this.database.db` 로 Drizzle API 직접 (아래 `getHealth` 가 이 방식).
10
- * 2. **DatabaseQuery** — `findOne` / `count` / `select` 등 헬퍼. 예: `await dbQuery.count('appMeta')`.
7
+ * 1. **DatabaseService** — `this.database.db` 로 Drizzle API 직접.
8
+ * 2. **DatabaseQuery** — `findOne` / `count` / `select` 등 헬퍼.
11
9
  *
12
- * `docs/DATABASE.md` §3 참고.
10
+ * `GET /health` 특정 테이블을 가정하지 않음 (`db:pull` 등으로 스키마가 바뀌어도 깨지지 않게).
11
+ * 도메인 쿼리는 별도 서비스에서 스키마 심볼·`DatabaseQuery` 로 작성하면 됨. → `docs/DATABASE.md` §3.
13
12
  */
14
13
  @Injectable()
15
14
  export class AppService {
@@ -20,48 +19,20 @@ export class AppService {
20
19
  }
21
20
 
22
21
  /**
23
- * `GET /health` 용. `core.database.auto: false` 이면 DB 프로바이더가 없고 optional 로 비어 있음.
22
+ * `GET /health`. `core.database.auto: false` 이면 DB 모듈 미로드 optional 미주입.
24
23
  */
25
- async getHealth(): Promise<{
24
+ getHealth(): {
26
25
  ok: true;
27
26
  service: string;
28
- database: {
29
- moduleLoaded: boolean;
30
- connected: boolean;
31
- /** Drizzle 직접 `count(*)` — 동일 결과: `DatabaseQuery.count('appMeta')` */
32
- rowCount?: number;
33
- sampleTable?: string;
34
- };
35
- }> {
36
- const sampleTable = 'appMeta';
27
+ database: { moduleLoaded: boolean; connected: boolean };
28
+ } {
37
29
  const service = this.serviceLabel();
38
30
  const moduleLoaded = this.database !== undefined;
39
- if (!moduleLoaded || !this.database.isConnected()) {
40
- return {
41
- ok: true,
42
- service,
43
- database: { moduleLoaded, connected: false, sampleTable },
44
- };
45
- }
46
- let rowCount: number | undefined;
47
- try {
48
- const db = this.database.db;
49
- const [row] = await db
50
- .select({ c: sql<number>`count(*)` })
51
- .from(appMeta);
52
- rowCount = Number(row?.c ?? 0);
53
- } catch {
54
- rowCount = undefined;
55
- }
31
+ const connected = moduleLoaded && this.database.isConnected();
56
32
  return {
57
33
  ok: true,
58
34
  service,
59
- database: {
60
- moduleLoaded,
61
- connected: true,
62
- rowCount,
63
- sampleTable,
64
- },
35
+ database: { moduleLoaded, connected },
65
36
  };
66
37
  }
67
38
  }
@@ -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": ["src/**/*", "../../shared/schema/**/*.ts"],
6
+ "include": [
7
+ "src/**/*",
8
+ "../../shared/schema/**/*.ts",
9
+ "../../shared/sql/**/*.ts"
10
+ ],
7
11
  "exclude": ["node_modules", "dist", "**/*spec.ts"]
8
12
  }
@@ -32,7 +32,7 @@ xframe 프로젝트는 **`@xfilecom/backend-core`** 가 MySQL 풀과 Drizzle 인
32
32
  | **타입** | 스키마 심볼 import (`appMeta` 등) — 권장 | 테이블 키는 문자열 (`'appMeta'`) |
33
33
  | **언제** | 복잡한 조인·raw·Drizzle 문서 그대로 | 단순 CRUD·페이지네이션·object `where` |
34
34
 
35
- 스캐폴드 **`apps/api/src/app.service.ts`** `GET /health` **`DatabaseService.db` + Drizzle** `app_meta` 수를 구합니다. 같은 값은 **`DatabaseQuery.count('appMeta')`** 로도 얻을 있습니다 (`@Optional()` DB 꺼짐 대응은 동일).
35
+ 스캐폴드 **`apps/api/src/app.service.ts`** `GET /health` **DB 모듈 로드 여부·연결 여부**만 돌려주며, 어떤 테이블도 가정하지 않습니다. 실제 쿼리는 아래 패턴으로 별도 서비스를 두면 됩니다.
36
36
 
37
37
  ### A. Drizzle API 직접 (`DatabaseService.db`)
38
38
 
@@ -98,6 +98,11 @@ export class AppModule {}
98
98
 
99
99
  `DatabaseModule` 을 다시 import 할 필요는 없습니다.
100
100
 
101
+ ## 공통 응답 (`CommonResponseDto`)
102
+
103
+ `CoreModule.forHttpApi` 는 기본으로 **`ResponseTransformInterceptor`** 를 켜서, 컨트롤러가 객체만 반환해도 `{ code, data, meta?, error }` 형태로 감쌉니다.
104
+ 그래도 **`ControllerHelpers.success(payload)`** 로 `CommonResponseDto` 를 직접 반환하는 편이 의도가 분명합니다. `CoreModule` 이 `@Global()` 이라 **`ControllerHelpers` 는 주입만 하면 됩니다** — `apps/api/src/app.controller.ts` 의 `GET /health` 가 그 패턴입니다.
105
+
101
106
  ## 5. Drizzle Kit (루트에서 실행)
102
107
 
103
108
  프로젝트 **루트**에서 스크립트를 실행합니다 (`process.cwd()` 가 루트여야 `drizzle.env.ts` 가 YAML 을 찾습니다).
@@ -115,3 +120,19 @@ export class AppModule {}
115
120
  ## 6. 연결 실패 시
116
121
 
117
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 (`drizzle-kit`, 수동 스크립트 등) |
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` 로 테이블을 묶습니다. `npm run db:pull`(역추출) 후 `relations.ts`가 생기면 Drizzle 문서에 맞게 `schema` export를 정리하세요.
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
- - Drizzle Kit 마이그레이션 출력(`drizzle.config.ts` `out`)은 `migrations/` 아래입니다.
4
- - **처음에는 `migrations/` 를 만들지 않아도 됩니다.** 첫 `npm run db:generate` 가 `migrations/`·`meta/_journal.json`·snapshot·`.sql` 을 한꺼번에 만듭니다.
5
- - 주의: **빈 `meta/` 만 두고 `_journal.json` 만 없애면** Kit이 저널을 다시 쓰지 않아 `generate` 가 깨질 수 있으니, 깔끔히 비울 때는 `migrations/` 전체를 지우세요.
6
- - 시드·리포트용 `.sql`도 이 트리에 둘 수 있습니다.
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
+ }
@@ -12,7 +12,5 @@ export type HealthData = {
12
12
  database?: {
13
13
  moduleLoaded: boolean;
14
14
  connected: boolean;
15
- rowCount?: number;
16
- sampleTable?: string;
17
15
  };
18
16
  };
@@ -1,6 +0,0 @@
1
- # Nest API·Drizzle 부트스트랩은 루트 .env 를 읽지 않음. DB 등은 `shared/config/api/application*.yml` 기준.
2
- # 기본 CONFIG_SOURCE=yaml → 셸에서 export 한 DB_* 로 YAML 이 바뀌지 않음. 덮어쓰기: CONFIG_SOURCE=yamlWithEnvOverrides
3
- #
4
- # (선택) 로컬 도구용으로만 .env 를 쓸 경우 — 앱/ drizzle-kit 은 자동 로드하지 않음.
5
- #
6
- # 접속 오류 시: GUI 와 동일한 host·user·password·database 이름을 YAML 에 맞출 것.
File without changes
File without changes
File without changes