"use client"; import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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"; // 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 [selectedQueryId, setSelectedQueryId] = useState(null); const [isTestRunning, setIsTestRunning] = useState(false); const [parameterValues, setParameterValues] = useState>({}); const [parameterTypes, setParameterTypes] = useState>({}); const [externalConnections, setExternalConnections] = useState([]); const [isLoadingConnections, setIsLoadingConnections] = useState(false); const { toast } = useToast(); const selectedQuery = queries.find((q) => q.id === selectedQueryId); const testResult = selectedQuery ? getQueryResult(selectedQuery.id) : null; // 선택된 쿼리의 안전성 검증 결과 const queryValidation = useMemo( () => (selectedQuery ? validateQuerySafety(selectedQuery.sqlQuery) : { isValid: false, errorMessage: null }), [selectedQuery], ); // 외부 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, // 기본값: 내부 DB }; setQueries([...queries, newQuery]); setSelectedQueryId(newQuery.id); }; // 쿼리 삭제 const handleDeleteQuery = (queryId: string) => { setQueries(queries.filter((q) => q.id !== queryId)); if (selectedQueryId === queryId) { setSelectedQueryId(null); setParameterValues({}); setParameterTypes({}); } }; // 쿼리 선택 변경 const handleSelectQuery = (queryId: string) => { setSelectedQueryId(queryId); setParameterValues({}); setParameterTypes({}); }; // 파라미터 값이 모두 입력되었는지 확인 const isAllParametersFilled = (): boolean => { if (!selectedQuery || selectedQuery.parameters.length === 0) { return true; } return selectedQuery.parameters.every((param) => { const value = parameterValues[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 () => { if (!selectedQuery) { toast({ title: "알림", description: "쿼리를 선택해주세요.", variant: "destructive", }); return; } // SQL 쿼리 안전성 검증 const validation = validateQuerySafety(selectedQuery.sqlQuery); if (!validation.isValid) { toast({ title: "쿼리 검증 실패", description: validation.errorMessage || "잘못된 쿼리입니다.", variant: "destructive", }); return; } setIsTestRunning(true); try { // new 리포트는 임시 ID 사용하고 SQL 쿼리 직접 전달 const testReportId = reportId === "new" ? "TEMP_TEST" : reportId; const sqlQuery = reportId === "new" ? selectedQuery.sqlQuery : undefined; const externalConnectionId = (selectedQuery as any).externalConnectionId || null; // 실제 API 호출 const response = await reportApi.executeQuery( testReportId, selectedQuery.id, parameterValues, sqlQuery, externalConnectionId, ); if (response.success && response.data) { // Context에 실행 결과 저장 setQueryResult(selectedQuery.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(false); } }; return (
{/* 헤더 */}

쿼리 관리

{/* 안내 메시지 */} 마스터 쿼리는 1건의 데이터를 가져오고, 디테일 쿼리는 여러 건을 반복 표시합니다. {/* 쿼리 목록 */}
{queries.map((query) => ( handleSelectQuery(query.id)} >
{query.name} {query.type}
{query.parameters.length > 0 && (
{query.parameters.map((param) => ( {param} ))}
)}
))}
{/* 선택된 쿼리 편집 */} {selectedQuery && ( 쿼리 편집 {/* 쿼리 이름 */}
handleUpdateQuery(selectedQuery.id, { name: e.target.value })} placeholder="쿼리 이름" className="h-8" />
{/* 쿼리 타입 */}
{/* DB 연결 선택 */}
{/* SQL 쿼리 */}