391 lines
10 KiB
TypeScript
391 lines
10 KiB
TypeScript
/**
|
|
* DDL 안전성 검증 서비스
|
|
* 테이블/컬럼 생성 전 모든 보안 검증을 수행
|
|
*/
|
|
|
|
import {
|
|
CreateColumnDefinition,
|
|
ValidationResult,
|
|
SYSTEM_TABLES,
|
|
RESERVED_WORDS,
|
|
RESERVED_COLUMNS,
|
|
} from "../types/ddl";
|
|
import { logger } from "../utils/logger";
|
|
|
|
export class DDLSafetyValidator {
|
|
/**
|
|
* 테이블 생성 전 전체 검증
|
|
*/
|
|
static validateTableCreation(
|
|
tableName: string,
|
|
columns: CreateColumnDefinition[]
|
|
): ValidationResult {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
try {
|
|
// 1. 테이블명 기본 검증
|
|
const tableNameValidation = this.validateTableName(tableName);
|
|
if (!tableNameValidation.isValid) {
|
|
errors.push(...tableNameValidation.errors);
|
|
}
|
|
|
|
// 2. 컬럼 기본 검증
|
|
if (columns.length === 0) {
|
|
errors.push("최소 1개의 컬럼이 필요합니다.");
|
|
}
|
|
|
|
// 3. 컬럼 목록 검증
|
|
const columnsValidation = this.validateColumnList(columns);
|
|
if (!columnsValidation.isValid) {
|
|
errors.push(...columnsValidation.errors);
|
|
}
|
|
if (columnsValidation.warnings) {
|
|
warnings.push(...columnsValidation.warnings);
|
|
}
|
|
|
|
// 4. 컬럼명 중복 검증
|
|
const duplicateValidation = this.validateColumnDuplication(columns);
|
|
if (!duplicateValidation.isValid) {
|
|
errors.push(...duplicateValidation.errors);
|
|
}
|
|
|
|
logger.info("테이블 생성 검증 완료", {
|
|
tableName,
|
|
columnCount: columns.length,
|
|
errorCount: errors.length,
|
|
warningCount: warnings.length,
|
|
});
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
} catch (error) {
|
|
logger.error("테이블 생성 검증 중 오류 발생:", error);
|
|
return {
|
|
isValid: false,
|
|
errors: ["테이블 생성 검증 중 내부 오류가 발생했습니다."],
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컬럼 추가 전 검증
|
|
*/
|
|
static validateColumnAddition(
|
|
tableName: string,
|
|
column: CreateColumnDefinition
|
|
): ValidationResult {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
try {
|
|
// 1. 테이블명 검증 (시스템 테이블 확인)
|
|
if (this.isSystemTable(tableName)) {
|
|
errors.push(
|
|
`'${tableName}'은 시스템 테이블이므로 컬럼을 추가할 수 없습니다.`
|
|
);
|
|
}
|
|
|
|
// 2. 컬럼 정의 검증
|
|
const columnValidation = this.validateSingleColumn(column);
|
|
if (!columnValidation.isValid) {
|
|
errors.push(...columnValidation.errors);
|
|
}
|
|
if (columnValidation.warnings) {
|
|
warnings.push(...columnValidation.warnings);
|
|
}
|
|
|
|
logger.info("컬럼 추가 검증 완료", {
|
|
tableName,
|
|
columnName: column.name,
|
|
webType: column.webType,
|
|
errorCount: errors.length,
|
|
});
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
} catch (error) {
|
|
logger.error("컬럼 추가 검증 중 오류 발생:", error);
|
|
return {
|
|
isValid: false,
|
|
errors: ["컬럼 추가 검증 중 내부 오류가 발생했습니다."],
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블명 검증
|
|
*/
|
|
private static validateTableName(tableName: string): ValidationResult {
|
|
const errors: string[] = [];
|
|
|
|
// 1. 기본 형식 검증
|
|
if (!this.isValidTableName(tableName)) {
|
|
errors.push(
|
|
"유효하지 않은 테이블명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다."
|
|
);
|
|
}
|
|
|
|
// 2. 길이 검증
|
|
if (tableName.length > 63) {
|
|
errors.push("테이블명은 63자를 초과할 수 없습니다.");
|
|
}
|
|
|
|
if (tableName.length < 2) {
|
|
errors.push("테이블명은 최소 2자 이상이어야 합니다.");
|
|
}
|
|
|
|
// 3. 시스템 테이블 보호
|
|
if (this.isSystemTable(tableName)) {
|
|
errors.push(
|
|
`'${tableName}'은 시스템 테이블명으로 사용할 수 없습니다. 다른 이름을 선택해주세요.`
|
|
);
|
|
}
|
|
|
|
// 4. 예약어 검증
|
|
if (this.isReservedWord(tableName)) {
|
|
errors.push(
|
|
`'${tableName}'은 SQL 예약어이므로 테이블명으로 사용할 수 없습니다.`
|
|
);
|
|
}
|
|
|
|
// 5. 일반적인 네이밍 컨벤션 검증
|
|
if (tableName.startsWith("_") || tableName.endsWith("_")) {
|
|
errors.push("테이블명은 언더스코어로 시작하거나 끝날 수 없습니다.");
|
|
}
|
|
|
|
if (tableName.includes("__")) {
|
|
errors.push("테이블명에 연속된 언더스코어는 사용할 수 없습니다.");
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 컬럼 목록 검증
|
|
*/
|
|
private static validateColumnList(
|
|
columns: CreateColumnDefinition[]
|
|
): ValidationResult {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
for (let i = 0; i < columns.length; i++) {
|
|
const column = columns[i];
|
|
const columnValidation = this.validateSingleColumn(column, i + 1);
|
|
|
|
if (!columnValidation.isValid) {
|
|
errors.push(...columnValidation.errors);
|
|
}
|
|
|
|
if (columnValidation.warnings) {
|
|
warnings.push(...columnValidation.warnings);
|
|
}
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 개별 컬럼 검증
|
|
*/
|
|
private static validateSingleColumn(
|
|
column: CreateColumnDefinition,
|
|
position?: number
|
|
): ValidationResult {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
const prefix = position
|
|
? `컬럼 ${position}(${column.name}): `
|
|
: `컬럼 '${column.name}': `;
|
|
|
|
// 1. 컬럼명 기본 검증
|
|
if (!column.name || column.name.trim() === "") {
|
|
errors.push(`${prefix}컬럼명은 필수입니다.`);
|
|
return { isValid: false, errors };
|
|
}
|
|
|
|
if (!this.isValidColumnName(column.name)) {
|
|
errors.push(
|
|
`${prefix}유효하지 않은 컬럼명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.`
|
|
);
|
|
}
|
|
|
|
// 2. 길이 검증
|
|
if (column.name.length > 63) {
|
|
errors.push(`${prefix}컬럼명은 63자를 초과할 수 없습니다.`);
|
|
}
|
|
|
|
if (column.name.length < 2) {
|
|
errors.push(`${prefix}컬럼명은 최소 2자 이상이어야 합니다.`);
|
|
}
|
|
|
|
// 3. 예약된 컬럼명 검증
|
|
if (this.isReservedColumnName(column.name)) {
|
|
errors.push(
|
|
`${prefix}'${column.name}'은 예약된 컬럼명입니다. 기본 컬럼(id, created_date, updated_date, company_code)과 중복됩니다.`
|
|
);
|
|
}
|
|
|
|
// 4. SQL 예약어 검증
|
|
if (this.isReservedWord(column.name)) {
|
|
errors.push(
|
|
`${prefix}'${column.name}'은 SQL 예약어이므로 컬럼명으로 사용할 수 없습니다.`
|
|
);
|
|
}
|
|
|
|
// 5. 웹타입 검증
|
|
if (!column.webType) {
|
|
errors.push(`${prefix}웹타입이 지정되지 않았습니다.`);
|
|
}
|
|
|
|
// 6. 길이 설정 검증 (text, code 타입에서만 허용)
|
|
if (column.length !== undefined) {
|
|
if (
|
|
!["text", "code", "email", "tel", "select", "radio"].includes(
|
|
column.webType
|
|
)
|
|
) {
|
|
warnings.push(
|
|
`${prefix}${column.webType} 타입에서는 길이 설정이 무시됩니다.`
|
|
);
|
|
} else if (column.length <= 0 || column.length > 65535) {
|
|
errors.push(`${prefix}길이는 1 이상 65535 이하여야 합니다.`);
|
|
}
|
|
}
|
|
|
|
// 7. 네이밍 컨벤션 검증
|
|
if (column.name.startsWith("_") || column.name.endsWith("_")) {
|
|
warnings.push(
|
|
`${prefix}컬럼명이 언더스코어로 시작하거나 끝나는 것은 권장하지 않습니다.`
|
|
);
|
|
}
|
|
|
|
if (column.name.includes("__")) {
|
|
errors.push(`${prefix}컬럼명에 연속된 언더스코어는 사용할 수 없습니다.`);
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 컬럼명 중복 검증
|
|
*/
|
|
private static validateColumnDuplication(
|
|
columns: CreateColumnDefinition[]
|
|
): ValidationResult {
|
|
const errors: string[] = [];
|
|
const columnNames = columns.map((col) => col.name.toLowerCase());
|
|
const seen = new Set<string>();
|
|
const duplicates = new Set<string>();
|
|
|
|
for (const name of columnNames) {
|
|
if (seen.has(name)) {
|
|
duplicates.add(name);
|
|
} else {
|
|
seen.add(name);
|
|
}
|
|
}
|
|
|
|
if (duplicates.size > 0) {
|
|
errors.push(
|
|
`중복된 컬럼명이 있습니다: ${Array.from(duplicates).join(", ")}`
|
|
);
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 테이블명 유효성 검증 (정규식)
|
|
*/
|
|
private static isValidTableName(tableName: string): boolean {
|
|
const tableNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
return tableNameRegex.test(tableName);
|
|
}
|
|
|
|
/**
|
|
* 컬럼명 유효성 검증 (정규식)
|
|
*/
|
|
private static isValidColumnName(columnName: string): boolean {
|
|
const columnNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
return columnNameRegex.test(columnName) && columnName.length <= 63;
|
|
}
|
|
|
|
/**
|
|
* 시스템 테이블 확인
|
|
*/
|
|
private static isSystemTable(tableName: string): boolean {
|
|
return SYSTEM_TABLES.includes(tableName.toLowerCase() as any);
|
|
}
|
|
|
|
/**
|
|
* SQL 예약어 확인
|
|
*/
|
|
private static isReservedWord(word: string): boolean {
|
|
return RESERVED_WORDS.includes(word.toLowerCase() as any);
|
|
}
|
|
|
|
/**
|
|
* 예약된 컬럼명 확인
|
|
*/
|
|
private static isReservedColumnName(columnName: string): boolean {
|
|
return RESERVED_COLUMNS.includes(columnName.toLowerCase() as any);
|
|
}
|
|
|
|
/**
|
|
* 전체 검증 통계 생성
|
|
*/
|
|
static generateValidationReport(
|
|
tableName: string,
|
|
columns: CreateColumnDefinition[]
|
|
): {
|
|
tableName: string;
|
|
totalColumns: number;
|
|
validationResult: ValidationResult;
|
|
summary: string;
|
|
} {
|
|
const validationResult = this.validateTableCreation(tableName, columns);
|
|
|
|
let summary = `테이블 '${tableName}' 검증 완료. `;
|
|
summary += `컬럼 ${columns.length}개 중 `;
|
|
|
|
if (validationResult.isValid) {
|
|
summary += "모든 검증 통과.";
|
|
} else {
|
|
summary += `${validationResult.errors.length}개 오류 발견.`;
|
|
}
|
|
|
|
if (validationResult.warnings && validationResult.warnings.length > 0) {
|
|
summary += ` ${validationResult.warnings.length}개 경고 있음.`;
|
|
}
|
|
|
|
return {
|
|
tableName,
|
|
totalColumns: columns.length,
|
|
validationResult,
|
|
summary,
|
|
};
|
|
}
|
|
}
|