ERP-node/frontend/components/report/designer/QueryManager.tsx

479 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;
// 항상 sqlQuery를 전달 (새 쿼리가 아직 DB에 저장되지 않았을 수 있음)
const sqlQuery = query.sqlQuery;
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">
<div className="flex items-center gap-1">
<AccordionTrigger className="flex-1 px-0 py-2.5 hover:no-underline">
<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>
</AccordionTrigger>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteQuery(query.id, e)}
className="h-7 w-7 shrink-0 p-0"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
<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>
);
}