/** * 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(); const result: string[] = []; for (const match of matches) { if (!seen.has(match)) { seen.add(match); result.push(match); } } return result; };