ERP-node/frontend/lib/report/queryUtils.ts

95 lines
2.6 KiB
TypeScript

/**
* queryUtils.ts
*
* SQL 쿼리 관련 순수 유틸 함수 모음.
*
* [사용처]
* - QueryManager.tsx : 쿼리 탭에서 안전성 검증 + 파라미터 자동 감지
* - modals/QuerySettingsTab.tsx : 인캔버스 설정 모달의 데이터 연결 탭
*
* Phase 5-A에서 QueryManager의 인라인 함수를 이곳으로 추출.
* 두 소비처가 동일 로직을 공유할 수 있도록 단일 소스로 유지한다.
*/
/** SQL 안전성 검증 결과 */
export interface QueryValidationResult {
isValid: boolean;
errorMessage: string | null;
}
/**
* SELECT 전용 SQL 안전성 검증.
* DML/DDL 키워드를 포함하거나 다중 쿼리를 감지하면 오류를 반환한다.
*/
export const validateQuerySafety = (sql: string): QueryValidationResult => {
if (!sql || sql.trim() === "") {
return { isValid: false, errorMessage: "쿼리를 입력해주세요." };
}
const dangerousKeywords = [
"DELETE",
"DROP",
"TRUNCATE",
"INSERT",
"UPDATE",
"ALTER",
"CREATE",
"REPLACE",
"MERGE",
"GRANT",
"REVOKE",
"EXECUTE",
"EXEC",
"CALL",
];
const upperSql = sql.toUpperCase().trim();
for (const keyword of dangerousKeywords) {
const regex = new RegExp(`\\b${keyword}\\b`, "i");
if (regex.test(upperSql)) {
return {
isValid: false,
errorMessage: `보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.`,
};
}
}
if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) {
return {
isValid: false,
errorMessage: "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다.",
};
}
const semicolonCount = (sql.match(/;/g) || []).length;
if (semicolonCount > 1 || (semicolonCount === 1 && !sql.trim().endsWith(";"))) {
return {
isValid: false,
errorMessage: "보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다.",
};
}
return { isValid: true, errorMessage: null };
};
/**
* SQL에서 파라미터 플레이스홀더($1, $2 …) 추출.
* 문자열 리터럴(단일따옴표) 내부는 제외하며, 등장 순서를 유지한다.
*/
export const detectParameters = (sql: string): string[] => {
const withoutStrings = sql.replace(/'[^']*'/g, "");
const matches = withoutStrings.match(/\$\d+/g);
if (!matches) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const match of matches) {
if (!seen.has(match)) {
seen.add(match);
result.push(match);
}
}
return result;
};