ERP-node/backend-node/src/services/ddlExecutionService.ts

906 lines
23 KiB
TypeScript

/**
* DDL 실행 서비스
* 실제 PostgreSQL 테이블 및 컬럼 생성을 담당
*/
import { query, queryOne, transaction } from "../database/db";
import {
CreateColumnDefinition,
DDLExecutionResult,
WEB_TYPE_TO_POSTGRES_MAP,
WebType,
} from "../types/ddl";
import { DDLSafetyValidator } from "./ddlSafetyValidator";
import { DDLAuditLogger } from "./ddlAuditLogger";
import { logger } from "../utils/logger";
import { cache, CacheKeys } from "../utils/cache";
export class DDLExecutionService {
/**
* 새 테이블 생성
*/
async createTable(
tableName: string,
columns: CreateColumnDefinition[],
userCompanyCode: string,
userId: string,
description?: string
): Promise<DDLExecutionResult> {
// DDL 실행 시작 로그
await DDLAuditLogger.logDDLStart(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
{ columns, description }
);
try {
// 1. 권한 검증
this.validateSuperAdminPermission(userCompanyCode);
// 2. 안전성 검증
const validation = DDLSafetyValidator.validateTableCreation(
tableName,
columns
);
if (!validation.isValid) {
const errorMessage = `테이블 생성 검증 실패: ${validation.errors.join(", ")}`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
"VALIDATION_FAILED",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "VALIDATION_FAILED",
details: validation.errors.join(", "),
},
};
}
// 3. 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (tableExists) {
const errorMessage = `테이블 '${tableName}'이 이미 존재합니다.`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
"TABLE_EXISTS",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "TABLE_EXISTS",
details: errorMessage,
},
};
}
// 4. DDL 쿼리 생성
const ddlQuery = this.generateCreateTableQuery(tableName, columns);
// 5. 트랜잭션으로 안전하게 실행
await transaction(async (client) => {
// 5-1. 테이블 생성
await client.query(ddlQuery);
// 5-2. 테이블 메타데이터 저장
await this.saveTableMetadata(client, tableName, description);
// 5-3. 컬럼 메타데이터 저장
await this.saveColumnMetadata(client, tableName, columns, userCompanyCode);
});
// 6. 성공 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
ddlQuery,
true
);
logger.info("테이블 생성 성공", {
tableName,
userId,
columnCount: columns.length,
});
// 테이블 생성 후 관련 캐시 무효화
this.invalidateTableCache(tableName);
return {
success: true,
message: `테이블 '${tableName}'이 성공적으로 생성되었습니다.`,
executedQuery: ddlQuery,
};
} catch (error) {
const errorMessage = `테이블 생성 실패: ${(error as Error).message}`;
// 실패 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
`FAILED: ${(error as Error).message}`,
false,
errorMessage
);
logger.error("테이블 생성 실패:", {
tableName,
userId,
error: (error as Error).message,
stack: (error as Error).stack,
});
return {
success: false,
message: errorMessage,
error: {
code: "EXECUTION_FAILED",
details: (error as Error).message,
},
};
}
}
/**
* 기존 테이블에 컬럼 추가
*/
async addColumn(
tableName: string,
column: CreateColumnDefinition,
userCompanyCode: string,
userId: string
): Promise<DDLExecutionResult> {
// DDL 실행 시작 로그
await DDLAuditLogger.logDDLStart(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
{ column }
);
try {
// 1. 권한 검증
this.validateSuperAdminPermission(userCompanyCode);
// 2. 안전성 검증
const validation = DDLSafetyValidator.validateColumnAddition(
tableName,
column
);
if (!validation.isValid) {
const errorMessage = `컬럼 추가 검증 실패: ${validation.errors.join(", ")}`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
"VALIDATION_FAILED",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "VALIDATION_FAILED",
details: validation.errors.join(", "),
},
};
}
// 3. 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (!tableExists) {
const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
"TABLE_NOT_EXISTS",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "TABLE_NOT_EXISTS",
details: errorMessage,
},
};
}
// 4. 컬럼 존재 여부 확인
const columnExists = await this.checkColumnExists(tableName, column.name);
if (columnExists) {
const errorMessage = `컬럼 '${column.name}'이 이미 존재합니다.`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
"COLUMN_EXISTS",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "COLUMN_EXISTS",
details: errorMessage,
},
};
}
// 5. DDL 쿼리 생성
const ddlQuery = this.generateAddColumnQuery(tableName, column);
// 6. 트랜잭션으로 안전하게 실행
await transaction(async (client) => {
// 6-1. 컬럼 추가
await client.query(ddlQuery);
// 6-2. 컬럼 메타데이터 저장
await this.saveColumnMetadata(client, tableName, [column], userCompanyCode);
});
// 7. 성공 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
ddlQuery,
true
);
logger.info("컬럼 추가 성공", {
tableName,
columnName: column.name,
webType: column.webType,
userId,
});
// 컬럼 추가 후 관련 캐시 무효화
this.invalidateTableCache(tableName);
return {
success: true,
message: `컬럼 '${column.name}'이 성공적으로 추가되었습니다.`,
executedQuery: ddlQuery,
};
} catch (error) {
const errorMessage = `컬럼 추가 실패: ${(error as Error).message}`;
// 실패 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
`FAILED: ${(error as Error).message}`,
false,
errorMessage
);
logger.error("컬럼 추가 실패:", {
tableName,
columnName: column.name,
userId,
error: (error as Error).message,
stack: (error as Error).stack,
});
return {
success: false,
message: errorMessage,
error: {
code: "EXECUTION_FAILED",
details: (error as Error).message,
},
};
}
}
/**
* CREATE TABLE DDL 쿼리 생성
*/
private generateCreateTableQuery(
tableName: string,
columns: CreateColumnDefinition[]
): string {
// 사용자 정의 컬럼들 - 모두 VARCHAR(500)로 통일
const columnDefinitions = columns
.map((col) => {
// 입력 타입과 관계없이 모든 컬럼을 VARCHAR(500)로 생성
let definition = `"${col.name}" varchar(500)`;
if (!col.nullable) {
definition += " NOT NULL";
}
if (col.defaultValue) {
definition += ` DEFAULT '${col.defaultValue}'`;
}
return definition;
})
.join(",\n ");
// 기본 컬럼들 (날짜는 TIMESTAMP, 나머지는 VARCHAR)
const baseColumns = `
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500)`;
// 최종 CREATE TABLE 쿼리
return `
CREATE TABLE "${tableName}" (${baseColumns},
${columnDefinitions}
);`.trim();
}
/**
* ALTER TABLE ADD COLUMN DDL 쿼리 생성
*/
private generateAddColumnQuery(
tableName: string,
column: CreateColumnDefinition
): string {
// 새로 추가되는 컬럼도 VARCHAR(500)로 통일
let definition = `"${column.name}" varchar(500)`;
if (!column.nullable) {
definition += " NOT NULL";
}
if (column.defaultValue) {
definition += ` DEFAULT '${column.defaultValue}'`;
}
return `ALTER TABLE "${tableName}" ADD COLUMN ${definition};`;
}
/**
* 입력타입을 PostgreSQL 타입으로 매핑 (날짜는 TIMESTAMP, 나머지는 VARCHAR)
* 날짜 타입만 TIMESTAMP로, 나머지는 VARCHAR(500)로 통일
*/
private mapInputTypeToPostgresType(inputType?: string): string {
switch (inputType) {
case "date":
return "timestamp";
default:
// 날짜 외의 모든 타입은 VARCHAR(500)로 통일
return "varchar(500)";
}
}
/**
* 레거시 지원: 웹타입을 PostgreSQL 타입으로 매핑
* @deprecated 새로운 시스템에서는 mapInputTypeToPostgresType 사용
*/
private mapWebTypeToPostgresType(webType: WebType, length?: number): string {
// 레거시 지원을 위해 유지하되, VARCHAR(500)로 통일
logger.info(`레거시 웹타입 사용: ${webType} → varchar(500)로 변환`);
return "varchar(500)";
}
/**
* 테이블 메타데이터 저장
*/
private async saveTableMetadata(
client: any,
tableName: string,
description?: string
): Promise<void> {
await client.query(
`
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ($1, $2, $3, now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = $2,
description = $3,
updated_date = now()
`,
[tableName, tableName, description || `사용자 생성 테이블: ${tableName}`]
);
}
/**
* 컬럼 메타데이터 저장
*/
private async saveColumnMetadata(
client: any,
tableName: string,
columns: CreateColumnDefinition[],
companyCode: string
): Promise<void> {
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
await client.query(
`
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ($1, $2, $3, now(), now())
ON CONFLICT (table_name)
DO UPDATE SET updated_date = now()
`,
[tableName, tableName, `자동 생성된 테이블 메타데이터: ${tableName}`]
);
// 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼)
const defaultColumns = [
{
name: "id",
label: "ID",
inputType: "text",
description: "기본키 (자동생성)",
order: -5,
isVisible: true,
},
{
name: "created_date",
label: "생성일시",
inputType: "date",
description: "레코드 생성일시",
order: -4,
isVisible: true,
},
{
name: "updated_date",
label: "수정일시",
inputType: "date",
description: "레코드 수정일시",
order: -3,
isVisible: true,
},
{
name: "writer",
label: "작성자",
inputType: "text",
description: "레코드 작성자",
order: -2,
isVisible: true,
},
{
name: "company_code",
label: "회사코드",
inputType: "text",
description: "회사 구분 코드",
order: -1,
isVisible: true,
},
];
// 기본 컬럼들을 table_type_columns에 등록
for (const defaultCol of defaultColumns) {
await client.query(
`
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
$1, $2, $3, $4, '{}',
'Y', $5, now(), now()
)
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = $4,
display_order = $5,
updated_date = now()
`,
[tableName, defaultCol.name, companyCode, defaultCol.inputType, defaultCol.order]
);
}
// 사용자 정의 컬럼들을 table_type_columns에 등록
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
const inputType = this.convertWebTypeToInputType(
column.webType || "text"
);
const detailSettings = JSON.stringify(column.detailSettings || {});
await client.query(
`
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
$1, $2, $3, $4, $5,
'Y', $6, now(), now()
)
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = $4,
detail_settings = $5,
display_order = $6,
updated_date = now()
`,
[tableName, column.name, companyCode, inputType, detailSettings, i]
);
}
// 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성)
// 1. 기본 컬럼들을 column_labels에 등록
for (const defaultCol of defaultColumns) {
await client.query(
`
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = $3,
input_type = $4,
detail_settings = $5,
description = $6,
display_order = $7,
is_visible = $8,
updated_date = now()
`,
[
tableName,
defaultCol.name,
defaultCol.label,
defaultCol.inputType,
JSON.stringify({}),
defaultCol.description,
defaultCol.order,
defaultCol.isVisible,
]
);
}
// 2. 사용자 정의 컬럼들을 column_labels에 등록
for (const column of columns) {
const inputType = this.convertWebTypeToInputType(
column.webType || "text"
);
const detailSettings = JSON.stringify(column.detailSettings || {});
await client.query(
`
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = $3,
input_type = $4,
detail_settings = $5,
description = $6,
display_order = $7,
is_visible = $8,
updated_date = now()
`,
[
tableName,
column.name,
column.label || column.name,
inputType,
detailSettings,
column.description,
column.order || 0,
true,
]
);
}
}
/**
* 웹 타입을 입력 타입으로 변환
*/
private convertWebTypeToInputType(webType: string): string {
const webTypeToInputTypeMap: Record<string, string> = {
// 텍스트 관련
text: "text",
textarea: "text",
email: "text",
tel: "text",
url: "text",
password: "text",
// 숫자 관련
number: "number",
decimal: "number",
// 날짜 관련
date: "date",
datetime: "date",
time: "date",
// 선택 관련
select: "select",
dropdown: "select",
checkbox: "checkbox",
boolean: "checkbox",
radio: "radio",
// 참조 관련
code: "code",
entity: "entity",
// 기타
file: "text",
button: "text",
};
return webTypeToInputTypeMap[webType] || "text";
}
/**
* 권한 검증 (슈퍼관리자 확인)
*/
private validateSuperAdminPermission(userCompanyCode: string): void {
if (userCompanyCode !== "*") {
throw new Error("최고 관리자 권한이 필요합니다.");
}
}
/**
* 테이블 존재 여부 확인
*/
private async checkTableExists(tableName: string): Promise<boolean> {
try {
const result = await queryOne<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)
`,
[tableName]
);
return result?.exists || false;
} catch (error) {
logger.error("테이블 존재 확인 오류:", error);
return false;
}
}
/**
* 컬럼 존재 여부 확인
*/
private async checkColumnExists(
tableName: string,
columnName: string
): Promise<boolean> {
try {
const result = await queryOne<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
)
`,
[tableName, columnName]
);
return result?.exists || false;
} catch (error) {
logger.error("컬럼 존재 확인 오류:", error);
return false;
}
}
/**
* 생성된 테이블 정보 조회
*/
async getCreatedTableInfo(tableName: string): Promise<{
tableInfo: any;
columns: any[];
} | null> {
try {
// 테이블 정보 조회
const tableInfo = await queryOne(
`SELECT * FROM table_labels WHERE table_name = $1`,
[tableName]
);
// 컬럼 정보 조회
const columns = await query(
`SELECT * FROM column_labels WHERE table_name = $1 ORDER BY display_order ASC`,
[tableName]
);
if (!tableInfo) {
return null;
}
return {
tableInfo,
columns,
};
} catch (error) {
logger.error("생성된 테이블 정보 조회 실패:", error);
return null;
}
}
/**
* 테이블 삭제 (DROP TABLE)
*/
async dropTable(
tableName: string,
userCompanyCode: string,
userId: string
): Promise<DDLExecutionResult> {
// DDL 실행 시작 로그
await DDLAuditLogger.logDDLStart(
userId,
userCompanyCode,
"DROP_TABLE",
tableName,
{}
);
try {
// 1. 권한 검증 (최고 관리자만 가능)
this.validateSuperAdminPermission(userCompanyCode);
// 2. 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (!tableExists) {
const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"DROP_TABLE",
tableName,
"TABLE_NOT_FOUND",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "TABLE_NOT_FOUND",
details: errorMessage,
},
};
}
// 3. DDL 쿼리 생성
const ddlQuery = `DROP TABLE IF EXISTS "${tableName}" CASCADE`;
// 4. 트랜잭션으로 안전하게 실행
await transaction(async (client) => {
// 4-1. 테이블 삭제
await client.query(ddlQuery);
// 4-2. 관련 메타데이터 삭제
await client.query(`DELETE FROM column_labels WHERE table_name = $1`, [
tableName,
]);
await client.query(`DELETE FROM table_labels WHERE table_name = $1`, [
tableName,
]);
});
// 5. 성공 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"DROP_TABLE",
tableName,
ddlQuery,
true
);
logger.info("테이블 삭제 성공", {
tableName,
userId,
});
// 테이블 삭제 후 관련 캐시 무효화
this.invalidateTableCache(tableName);
return {
success: true,
message: `테이블 '${tableName}'이 성공적으로 삭제되었습니다.`,
executedQuery: ddlQuery,
};
} catch (error) {
const errorMessage = `테이블 삭제 실패: ${(error as Error).message}`;
// 실패 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"DROP_TABLE",
tableName,
`FAILED: ${(error as Error).message}`,
false,
errorMessage
);
logger.error("테이블 삭제 실패:", {
tableName,
userId,
error: (error as Error).message,
stack: (error as Error).stack,
});
return {
success: false,
message: errorMessage,
error: {
code: "EXECUTION_FAILED",
details: (error as Error).message,
},
};
}
}
/**
* 테이블 관련 캐시 무효화
* DDL 작업 후 호출하여 캐시된 데이터를 클리어
*/
private invalidateTableCache(tableName: string): void {
try {
// 테이블 컬럼 관련 캐시 무효화
const columnCacheDeleted = cache.deleteByPattern(
`table_columns:${tableName}`
);
const countCacheDeleted = cache.deleteByPattern(
`table_column_count:${tableName}`
);
cache.delete("table_list");
const totalDeleted = columnCacheDeleted + countCacheDeleted + 1;
logger.info(
`테이블 캐시 무효화 완료: ${tableName}, 삭제된 키: ${totalDeleted}`
);
} catch (error) {
logger.warn(`테이블 캐시 무효화 실패: ${tableName}`, error);
}
}
}