407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
/**
|
|
* 매핑 제약사항 검증 유틸리티
|
|
* INSERT/UPDATE/DELETE 액션의 매핑 규칙을 검증합니다.
|
|
*/
|
|
|
|
export interface ValidationResult {
|
|
isValid: boolean;
|
|
error?: string;
|
|
warnings?: string[];
|
|
}
|
|
|
|
export interface ColumnMapping {
|
|
id?: string;
|
|
fromColumnName?: string;
|
|
toColumnName: string;
|
|
sourceTable?: string;
|
|
targetTable?: string;
|
|
defaultValue?: string;
|
|
transformFunction?: string;
|
|
}
|
|
|
|
export interface UpdateCondition {
|
|
id: string;
|
|
fromColumn: string;
|
|
operator: string;
|
|
value: string | string[];
|
|
logicalOperator?: "AND" | "OR";
|
|
}
|
|
|
|
export interface WhereCondition {
|
|
id: string;
|
|
toColumn: string;
|
|
operator: string;
|
|
valueSource: string;
|
|
fromColumn?: string;
|
|
staticValue?: string;
|
|
logicalOperator?: "AND" | "OR";
|
|
}
|
|
|
|
export interface DeleteCondition {
|
|
id: string;
|
|
fromColumn: string;
|
|
operator: string;
|
|
value: string | string[];
|
|
logicalOperator?: "AND" | "OR";
|
|
}
|
|
|
|
/**
|
|
* 전체 매핑 제약사항 검증
|
|
*/
|
|
export const validateMappingConstraints = (
|
|
actionType: "insert" | "update" | "delete",
|
|
newMapping: ColumnMapping,
|
|
existingMappings: ColumnMapping[],
|
|
): ValidationResult => {
|
|
switch (actionType) {
|
|
case "insert":
|
|
return validateInsertMapping(newMapping, existingMappings);
|
|
case "update":
|
|
return validateUpdateMapping(newMapping, existingMappings);
|
|
case "delete":
|
|
return validateDeleteConditions(newMapping, existingMappings);
|
|
default:
|
|
return { isValid: false, error: "지원하지 않는 액션 타입입니다." };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* INSERT 매핑 검증
|
|
* 규칙: 1:N 매핑 허용, N:1 매핑 금지
|
|
*/
|
|
export const validateInsertMapping = (
|
|
newMapping: ColumnMapping,
|
|
existingMappings: ColumnMapping[],
|
|
): ValidationResult => {
|
|
// TO 컬럼이 이미 다른 FROM 컬럼과 매핑되어 있는지 확인
|
|
const existingToMapping = existingMappings.find((mapping) => mapping.toColumnName === newMapping.toColumnName);
|
|
|
|
if (
|
|
existingToMapping &&
|
|
existingToMapping.fromColumnName &&
|
|
existingToMapping.fromColumnName !== newMapping.fromColumnName
|
|
) {
|
|
return {
|
|
isValid: false,
|
|
error: `대상 컬럼 '${newMapping.toColumnName}'은 이미 '${existingToMapping.fromColumnName}'과 매핑되어 있습니다.`,
|
|
};
|
|
}
|
|
|
|
// 기본값이 설정된 경우와 FROM 컬럼이 동시에 설정되어 있는지 확인
|
|
if (newMapping.fromColumnName && newMapping.defaultValue && newMapping.defaultValue.trim()) {
|
|
return {
|
|
isValid: false,
|
|
error: `'${newMapping.toColumnName}' 컬럼에는 FROM 컬럼과 기본값을 동시에 설정할 수 없습니다.`,
|
|
};
|
|
}
|
|
|
|
return { isValid: true };
|
|
};
|
|
|
|
/**
|
|
* UPDATE 매핑 검증
|
|
*/
|
|
export const validateUpdateMapping = (
|
|
newMapping: ColumnMapping,
|
|
existingMappings: ColumnMapping[],
|
|
): ValidationResult => {
|
|
// 기본 INSERT 규칙 적용
|
|
const baseValidation = validateInsertMapping(newMapping, existingMappings);
|
|
if (!baseValidation.isValid) {
|
|
return baseValidation;
|
|
}
|
|
|
|
// UPDATE 특화 검증 로직 추가 가능
|
|
return { isValid: true };
|
|
};
|
|
|
|
/**
|
|
* DELETE 조건 검증
|
|
*/
|
|
export const validateDeleteConditions = (
|
|
newMapping: ColumnMapping,
|
|
existingMappings: ColumnMapping[],
|
|
): ValidationResult => {
|
|
// DELETE는 기본적으로 조건 기반이므로 매핑 제약이 다름
|
|
return { isValid: true };
|
|
};
|
|
|
|
/**
|
|
* 자기 자신 테이블 UPDATE 작업 검증
|
|
*/
|
|
export const validateSelfTableUpdate = (
|
|
fromTable: string,
|
|
toTable: string,
|
|
updateConditions: UpdateCondition[],
|
|
whereConditions: WhereCondition[],
|
|
): ValidationResult => {
|
|
if (fromTable === toTable) {
|
|
// 1. WHERE 조건 필수
|
|
if (!whereConditions.length) {
|
|
return {
|
|
isValid: false,
|
|
error: "자기 자신 테이블 업데이트 시 WHERE 조건이 필수입니다.",
|
|
};
|
|
}
|
|
|
|
// 2. 업데이트 조건과 WHERE 조건이 겹치지 않도록 체크
|
|
const conditionColumns = updateConditions.map((c) => c.fromColumn);
|
|
const whereColumns = whereConditions.map((c) => c.toColumn);
|
|
const overlap = conditionColumns.filter((col) => whereColumns.includes(col));
|
|
|
|
if (overlap.length > 0) {
|
|
return {
|
|
isValid: false,
|
|
error: `업데이트 조건과 WHERE 조건에서 같은 컬럼(${overlap.join(", ")})을 사용하면 예상치 못한 결과가 발생할 수 있습니다.`,
|
|
};
|
|
}
|
|
|
|
// 3. 무한 루프 방지 체크
|
|
const hasInfiniteLoopRisk = updateConditions.some((condition) =>
|
|
whereConditions.some(
|
|
(where) => where.fromColumn === condition.toColumn && where.toColumn === condition.fromColumn,
|
|
),
|
|
);
|
|
|
|
if (hasInfiniteLoopRisk) {
|
|
return {
|
|
isValid: false,
|
|
error: "자기 참조 업데이트로 인한 무한 루프 위험이 있습니다.",
|
|
};
|
|
}
|
|
}
|
|
|
|
return { isValid: true };
|
|
};
|
|
|
|
/**
|
|
* 자기 자신 테이블 DELETE 작업 검증
|
|
*/
|
|
export const validateSelfTableDelete = (
|
|
fromTable: string,
|
|
toTable: string,
|
|
deleteConditions: DeleteCondition[],
|
|
whereConditions: WhereCondition[],
|
|
maxDeleteCount: number,
|
|
): ValidationResult => {
|
|
if (fromTable === toTable) {
|
|
// 1. WHERE 조건 필수 체크
|
|
if (!whereConditions.length) {
|
|
return {
|
|
isValid: false,
|
|
error: "자기 자신 테이블 삭제 시 WHERE 조건이 필수입니다.",
|
|
};
|
|
}
|
|
|
|
// 2. 강화된 안전장치: 더 엄격한 제한
|
|
const selfDeleteMaxCount = Math.min(maxDeleteCount, 10);
|
|
|
|
if (maxDeleteCount > selfDeleteMaxCount) {
|
|
return {
|
|
isValid: false,
|
|
error: `자기 자신 테이블 삭제 시 최대 ${selfDeleteMaxCount}개까지만 허용됩니다.`,
|
|
};
|
|
}
|
|
|
|
// 3. 삭제 조건이 너무 광범위한지 체크
|
|
const hasBroadCondition = deleteConditions.some(
|
|
(condition) =>
|
|
condition.operator === "!=" || condition.operator === "NOT IN" || condition.operator === "NOT EXISTS",
|
|
);
|
|
|
|
if (hasBroadCondition) {
|
|
return {
|
|
isValid: false,
|
|
error: "자기 자신 테이블 삭제 시 부정 조건(!=, NOT IN, NOT EXISTS)은 위험합니다.",
|
|
};
|
|
}
|
|
|
|
// 4. WHERE 조건이 충분히 구체적인지 체크
|
|
if (whereConditions.length < 2) {
|
|
return {
|
|
isValid: false,
|
|
error: "자기 자신 테이블 삭제 시 WHERE 조건을 2개 이상 설정하는 것을 권장합니다.",
|
|
warnings: ["안전을 위해 더 구체적인 조건을 설정하세요."],
|
|
};
|
|
}
|
|
}
|
|
|
|
return { isValid: true };
|
|
};
|
|
|
|
/**
|
|
* 컬럼 데이터 타입 호환성 검증
|
|
*/
|
|
export const validateDataTypeCompatibility = (fromColumnType: string, toColumnType: string): ValidationResult => {
|
|
// 기본 호환성 규칙
|
|
const fromType = normalizeDataType(fromColumnType);
|
|
const toType = normalizeDataType(toColumnType);
|
|
|
|
// 같은 타입이면 호환
|
|
if (fromType === toType) {
|
|
return { isValid: true };
|
|
}
|
|
|
|
// 숫자 타입 간 호환성
|
|
const numericTypes = ["int", "integer", "bigint", "smallint", "decimal", "numeric", "float", "double"];
|
|
if (numericTypes.includes(fromType) && numericTypes.includes(toType)) {
|
|
return {
|
|
isValid: true,
|
|
warnings: ["숫자 타입 간 변환 시 정밀도 손실이 발생할 수 있습니다."],
|
|
};
|
|
}
|
|
|
|
// 문자열 타입 간 호환성
|
|
const stringTypes = ["varchar", "char", "text", "string"];
|
|
if (stringTypes.includes(fromType) && stringTypes.includes(toType)) {
|
|
return {
|
|
isValid: true,
|
|
warnings: ["문자열 길이 제한을 확인하세요."],
|
|
};
|
|
}
|
|
|
|
// 날짜/시간 타입 간 호환성
|
|
const dateTypes = ["date", "datetime", "timestamp", "time"];
|
|
if (dateTypes.includes(fromType) && dateTypes.includes(toType)) {
|
|
return {
|
|
isValid: true,
|
|
warnings: ["날짜/시간 형식 변환 시 데이터 손실이 발생할 수 있습니다."],
|
|
};
|
|
}
|
|
|
|
// 호환되지 않는 타입
|
|
return {
|
|
isValid: false,
|
|
error: `'${fromColumnType}' 타입과 '${toColumnType}' 타입은 호환되지 않습니다.`,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 데이터 타입 정규화
|
|
*/
|
|
const normalizeDataType = (dataType: string): string => {
|
|
const lowerType = dataType.toLowerCase();
|
|
|
|
// 정수 타입
|
|
if (lowerType.includes("int") || lowerType.includes("serial")) {
|
|
return "int";
|
|
}
|
|
|
|
// 실수 타입
|
|
if (
|
|
lowerType.includes("decimal") ||
|
|
lowerType.includes("numeric") ||
|
|
lowerType.includes("float") ||
|
|
lowerType.includes("double")
|
|
) {
|
|
return "decimal";
|
|
}
|
|
|
|
// 문자열 타입
|
|
if (
|
|
lowerType.includes("varchar") ||
|
|
lowerType.includes("char") ||
|
|
lowerType.includes("text") ||
|
|
lowerType.includes("string")
|
|
) {
|
|
return "varchar";
|
|
}
|
|
|
|
// 날짜 타입
|
|
if (lowerType.includes("date")) {
|
|
return "date";
|
|
}
|
|
|
|
// 시간 타입
|
|
if (lowerType.includes("time")) {
|
|
return "datetime";
|
|
}
|
|
|
|
// 불린 타입
|
|
if (lowerType.includes("bool")) {
|
|
return "boolean";
|
|
}
|
|
|
|
return lowerType;
|
|
};
|
|
|
|
/**
|
|
* 매핑 완성도 검증
|
|
*/
|
|
export const validateMappingCompleteness = (requiredColumns: string[], mappings: ColumnMapping[]): ValidationResult => {
|
|
const mappedColumns = mappings.map((m) => m.toColumnName);
|
|
const unmappedRequired = requiredColumns.filter((col) => !mappedColumns.includes(col));
|
|
|
|
if (unmappedRequired.length > 0) {
|
|
return {
|
|
isValid: false,
|
|
error: `필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
return { isValid: true };
|
|
};
|
|
|
|
/**
|
|
* 전체 액션 설정 검증
|
|
*/
|
|
export const validateActionConfiguration = (
|
|
actionType: "insert" | "update" | "delete",
|
|
fromTable?: string,
|
|
toTable?: string,
|
|
mappings?: ColumnMapping[],
|
|
conditions?: any[],
|
|
): ValidationResult => {
|
|
// 기본 필수 정보 체크
|
|
if (!toTable) {
|
|
return {
|
|
isValid: false,
|
|
error: "대상 테이블을 선택해야 합니다.",
|
|
};
|
|
}
|
|
|
|
// 액션 타입별 검증
|
|
switch (actionType) {
|
|
case "insert":
|
|
if (!mappings || mappings.length === 0) {
|
|
return {
|
|
isValid: false,
|
|
error: "INSERT 작업에는 최소 하나의 필드 매핑이 필요합니다.",
|
|
};
|
|
}
|
|
break;
|
|
|
|
case "update":
|
|
if (!fromTable) {
|
|
return {
|
|
isValid: false,
|
|
error: "UPDATE 작업에는 조건 확인용 소스 테이블이 필요합니다.",
|
|
};
|
|
}
|
|
if (!conditions || conditions.length === 0) {
|
|
return {
|
|
isValid: false,
|
|
error: "UPDATE 작업에는 WHERE 조건이 필요합니다.",
|
|
};
|
|
}
|
|
break;
|
|
|
|
case "delete":
|
|
if (!fromTable) {
|
|
return {
|
|
isValid: false,
|
|
error: "DELETE 작업에는 조건 확인용 소스 테이블이 필요합니다.",
|
|
};
|
|
}
|
|
if (!conditions || conditions.length === 0) {
|
|
return {
|
|
isValid: false,
|
|
error: "DELETE 작업에는 WHERE 조건이 필요합니다.",
|
|
};
|
|
}
|
|
break;
|
|
}
|
|
|
|
return { isValid: true };
|
|
};
|