478 lines
19 KiB
TypeScript
478 lines
19 KiB
TypeScript
"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<Record<string, boolean>>({});
|
|
const [parameterValues, setParameterValues] = useState<Record<string, Record<string, string>>>({});
|
|
const [parameterTypes, setParameterTypes] = useState<Record<string, Record<string, string>>>({});
|
|
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
|
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<string>();
|
|
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<ReportQuery>) => {
|
|
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 (
|
|
<ScrollArea className="h-full">
|
|
<div className="space-y-4 p-4">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold">쿼리 관리</h3>
|
|
<Button size="sm" onClick={handleAddQuery}>
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 안내 메시지 */}
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">
|
|
<strong>마스터 쿼리</strong>는 1건의 데이터를 가져오고,
|
|
<strong>디테일 쿼리</strong>는 여러 건을 반복 표시합니다.
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
{/* 아코디언 목록 */}
|
|
{queries.length > 0 ? (
|
|
<Accordion type="single" collapsible>
|
|
{queries.map((query) => {
|
|
const testResult = getQueryResult(query.id);
|
|
const queryValidation = getQueryValidation(query);
|
|
const queryParams = parameterValues[query.id] || {};
|
|
const queryParamTypes = parameterTypes[query.id] || {};
|
|
|
|
return (
|
|
<AccordionItem key={query.id} value={query.id} className="border-b border-gray-200">
|
|
<AccordionTrigger className="px-0 py-2.5 hover:no-underline">
|
|
<div className="flex w-full items-center justify-between pr-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{query.name}</span>
|
|
<Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs">
|
|
{query.type}
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => handleDeleteQuery(query.id, e)}
|
|
className="h-7 w-7 p-0"
|
|
>
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
|
|
{/* 쿼리 이름 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">쿼리 이름</Label>
|
|
<Input
|
|
value={query.name}
|
|
onChange={(e) => handleUpdateQuery(query.id, { name: e.target.value })}
|
|
placeholder="쿼리 이름"
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
|
|
{/* 쿼리 타입 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">쿼리 타입</Label>
|
|
<Select
|
|
value={query.type}
|
|
onValueChange={(value: "MASTER" | "DETAIL") => handleUpdateQuery(query.id, { type: value })}
|
|
>
|
|
<SelectTrigger className="h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="MASTER">마스터 (1건)</SelectItem>
|
|
<SelectItem value="DETAIL">디테일 (반복)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* DB 연결 선택 */}
|
|
<div className="space-y-2">
|
|
<Label className="flex items-center gap-2 text-xs">
|
|
<Link2 className="h-3 w-3" />
|
|
DB 연결
|
|
</Label>
|
|
<Select
|
|
value={(query as any).externalConnectionId?.toString() || "internal"}
|
|
onValueChange={(value) =>
|
|
handleUpdateQuery(query.id, {
|
|
externalConnectionId: value === "internal" ? null : parseInt(value),
|
|
} as any)
|
|
}
|
|
disabled={isLoadingConnections}
|
|
>
|
|
<SelectTrigger className="h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="internal">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="h-4 w-4" />
|
|
내부 DB (PostgreSQL)
|
|
</div>
|
|
</SelectItem>
|
|
{externalConnections.length > 0 && (
|
|
<>
|
|
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">외부 DB</div>
|
|
{externalConnections.map((conn) => (
|
|
<SelectItem key={conn.id} value={conn.id.toString()}>
|
|
<div className="flex items-center gap-2">
|
|
<Database className="h-4 w-4" />
|
|
{conn.connection_name}
|
|
<Badge variant="outline" className="text-xs">
|
|
{conn.db_type.toUpperCase()}
|
|
</Badge>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* SQL 쿼리 */}
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
value={query.sqlQuery}
|
|
onChange={(e) => handleUpdateQuery(query.id, { sqlQuery: e.target.value })}
|
|
placeholder="SELECT * FROM orders WHERE order_id = $1"
|
|
className="min-h-[150px] font-mono text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* 파라미터 입력 */}
|
|
{query.parameters.length > 0 && (
|
|
<div className="space-y-3 rounded-md border border-yellow-200 bg-yellow-50 p-3">
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
|
<Label className="text-xs font-semibold text-yellow-800">파라미터</Label>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{query.parameters.map((param) => {
|
|
const paramType = queryParamTypes[param] || "text";
|
|
return (
|
|
<div key={param} className="flex items-center gap-2">
|
|
<Label className="w-12 text-xs font-semibold">{param}</Label>
|
|
<Select
|
|
value={paramType}
|
|
onValueChange={(value) =>
|
|
setParameterTypes({
|
|
...parameterTypes,
|
|
[query.id]: {
|
|
...queryParamTypes,
|
|
[param]: value,
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 w-24">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">텍스트</SelectItem>
|
|
<SelectItem value="number">숫자</SelectItem>
|
|
<SelectItem value="date">날짜</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
type={paramType === "number" ? "number" : paramType === "date" ? "date" : "text"}
|
|
placeholder="값"
|
|
className="h-8 flex-1"
|
|
value={queryParams[param] || ""}
|
|
onChange={(e) =>
|
|
setParameterValues({
|
|
...parameterValues,
|
|
[query.id]: {
|
|
...queryParams,
|
|
[param]: e.target.value,
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* SQL 검증 경고 메시지 */}
|
|
{!queryValidation.isValid && queryValidation.errorMessage && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">{queryValidation.errorMessage}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* 테스트 실행 */}
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
className="w-full bg-red-500 hover:bg-red-600"
|
|
onClick={() => handleTestQuery(query)}
|
|
disabled={!queryValidation.isValid || isTestRunning[query.id] || !isAllParametersFilled(query)}
|
|
>
|
|
<Play className="mr-2 h-4 w-4" />
|
|
{isTestRunning[query.id] ? "실행 중..." : "실행"}
|
|
</Button>
|
|
|
|
{/* 결과 필드 */}
|
|
{testResult && (
|
|
<div className="space-y-2 rounded-md border border-green-200 bg-green-50 p-3">
|
|
<Label className="text-xs font-semibold text-green-800">결과 필드</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{testResult.fields.map((field) => (
|
|
<Badge key={field} variant="default" className="bg-teal-500">
|
|
{field}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-green-700">{testResult.rows.length}건의 데이터가 조회되었습니다.</p>
|
|
</div>
|
|
)}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
<Database className="mb-2 h-12 w-12 text-gray-300" />
|
|
<p className="text-sm text-gray-500">
|
|
쿼리를 추가하여 리포트에
|
|
<br />
|
|
데이터를 연결하세요
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|