"use client"; import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Plus, Trash2, Play, AlertCircle, Database, Link2 } from "lucide-react"; import { useReportDesigner, ReportQuery } from "@/contexts/ReportDesignerContext"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; import type { ExternalConnection } from "@/types/report"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; // SQL 쿼리 안전성 검증 함수 (컴포넌트 외부에 선언) const validateQuerySafety = (sql: string): { isValid: boolean; errorMessage: string | null } => { if (!sql || sql.trim() === "") { return { isValid: false, errorMessage: "쿼리를 입력해주세요." }; } // 위험한 SQL 명령어 목록 const dangerousKeywords = [ "DELETE", "DROP", "TRUNCATE", "INSERT", "UPDATE", "ALTER", "CREATE", "REPLACE", "MERGE", "GRANT", "REVOKE", "EXECUTE", "EXEC", "CALL", ]; // SQL을 대문자로 변환하여 검사 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 쿼리만 허용됩니다.`, }; } } // 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 }; }; export function QueryManager() { const { queries, setQueries, reportId, setQueryResult, getQueryResult } = useReportDesigner(); const [isTestRunning, setIsTestRunning] = useState>({}); const [parameterValues, setParameterValues] = useState>>({}); const [parameterTypes, setParameterTypes] = useState>>({}); const [externalConnections, setExternalConnections] = useState([]); const [isLoadingConnections, setIsLoadingConnections] = useState(false); const { toast } = useToast(); // 각 쿼리의 안전성 검증 결과 const getQueryValidation = (query: ReportQuery) => validateQuerySafety(query.sqlQuery); // 외부 DB 연결 목록 조회 useEffect(() => { const fetchConnections = async () => { setIsLoadingConnections(true); try { const response = await reportApi.getExternalConnections(); if (response.success && response.data) { setExternalConnections(response.data); } } catch (error) { console.error("외부 DB 연결 목록 조회 실패:", error); } finally { setIsLoadingConnections(false); } }; fetchConnections(); }, []); // 파라미터 감지 ($1, $2 등, 단 작은따옴표 안은 제외) 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; }; // 새 쿼리 추가 const handleAddQuery = () => { const newQuery: ReportQuery = { id: `query_${Date.now()}`, name: `쿼리 ${queries.length + 1}`, type: "MASTER", sqlQuery: "", parameters: [], externalConnectionId: null, }; setQueries([...queries, newQuery]); }; // 쿼리 삭제 const handleDeleteQuery = (queryId: string, e: React.MouseEvent) => { e.stopPropagation(); setQueries(queries.filter((q) => q.id !== queryId)); // 해당 쿼리의 상태 정리 const newParameterValues = { ...parameterValues }; const newParameterTypes = { ...parameterTypes }; const newIsTestRunning = { ...isTestRunning }; delete newParameterValues[queryId]; delete newParameterTypes[queryId]; delete newIsTestRunning[queryId]; setParameterValues(newParameterValues); setParameterTypes(newParameterTypes); setIsTestRunning(newIsTestRunning); }; // 파라미터 값이 모두 입력되었는지 확인 const isAllParametersFilled = (query: ReportQuery): boolean => { if (!query || query.parameters.length === 0) { return true; } const queryParams = parameterValues[query.id] || {}; return query.parameters.every((param) => { const value = queryParams[param]; return value !== undefined && value.trim() !== ""; }); }; // 쿼리 업데이트 const handleUpdateQuery = (queryId: string, updates: Partial) => { setQueries( queries.map((q) => { if (q.id === queryId) { const updated = { ...q, ...updates }; // SQL이 변경되면 파라미터 재감지 if (updates.sqlQuery !== undefined) { updated.parameters = detectParameters(updated.sqlQuery); } return updated; } return q; }), ); }; // 쿼리 테스트 실행 const handleTestQuery = async (query: ReportQuery) => { // SQL 쿼리 안전성 검증 const validation = validateQuerySafety(query.sqlQuery); if (!validation.isValid) { toast({ title: "쿼리 검증 실패", description: validation.errorMessage || "잘못된 쿼리입니다.", variant: "destructive", }); return; } setIsTestRunning({ ...isTestRunning, [query.id]: true }); try { const testReportId = reportId === "new" ? "TEMP_TEST" : reportId; const sqlQuery = reportId === "new" ? query.sqlQuery : undefined; const externalConnectionId = (query as any).externalConnectionId || null; const queryParams = parameterValues[query.id] || {}; const response = await reportApi.executeQuery( testReportId, query.id, queryParams, sqlQuery, externalConnectionId, ); if (response.success && response.data) { setQueryResult(query.id, response.data.fields, response.data.rows); toast({ title: "성공", description: `${response.data.rows.length}건의 데이터가 조회되었습니다.`, }); } } catch (error: any) { toast({ title: "오류", description: error.response?.data?.message || "쿼리 실행에 실패했습니다.", variant: "destructive", }); } finally { setIsTestRunning({ ...isTestRunning, [query.id]: false }); } }; return (
{/* 헤더 */}

쿼리 관리

{/* 안내 메시지 */} 마스터 쿼리는 1건의 데이터를 가져오고, 디테일 쿼리는 여러 건을 반복 표시합니다. {/* 아코디언 목록 */} {queries.length > 0 ? ( {queries.map((query) => { const testResult = getQueryResult(query.id); const queryValidation = getQueryValidation(query); const queryParams = parameterValues[query.id] || {}; const queryParamTypes = parameterTypes[query.id] || {}; return (
{query.name} {query.type}
{/* 쿼리 이름 */}
handleUpdateQuery(query.id, { name: e.target.value })} placeholder="쿼리 이름" className="h-8" />
{/* 쿼리 타입 */}
{/* DB 연결 선택 */}
{/* SQL 쿼리 */}