ERP-node/frontend/components/admin/SqlQueryModal.tsx

376 lines
14 KiB
TypeScript

"use client";
import { useState, useEffect, ChangeEvent } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
interface TableColumn {
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
}
interface TableInfo {
table_name: string;
columns: TableColumn[];
description: string | null;
}
interface QueryResult {
[key: string]: string | number | boolean | null | undefined;
}
interface TableColumn {
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
}
interface TableInfo {
table_name: string;
columns: TableColumn[];
description: string | null;
}
interface SqlQueryModalProps {
isOpen: boolean;
onClose: () => void;
connectionId: number;
connectionName: string;
}
export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, connectionId, connectionName }) => {
const { toast } = useToast();
const [query, setQuery] = useState("SELECT * FROM ");
const [results, setResults] = useState<QueryResult[]>([]);
const [loading, setLoading] = useState(false);
const [tables, setTables] = useState<TableInfo[]>([]);
const [selectedTable, setSelectedTable] = useState("");
const [loadingTables, setLoadingTables] = useState(false);
const [selectedTableColumns, setSelectedTableColumns] = useState<TableColumn[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 테이블 목록 로딩
useEffect(() => {
console.log("SqlQueryModal - connectionId:", connectionId);
const loadTables = async () => {
setLoadingTables(true);
try {
const result = await ExternalDbConnectionAPI.getTables(connectionId);
if (result.success && result.data) {
setTables(result.data as unknown as TableInfo[]);
}
} catch (error) {
console.error("테이블 목록 로딩 오류:", error);
toast({
title: "오류",
description: "테이블 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 테이블 선택 시 컬럼 정보 로딩
const loadTableColumns = async (tableName: string) => {
if (!tableName) {
setSelectedTableColumns([]);
return;
}
setLoadingColumns(true);
try {
const result = await ExternalDbConnectionAPI.getTableColumns(connectionId, tableName);
if (result.success && result.data) {
setSelectedTableColumns(result.data as TableColumn[]);
}
} catch (error) {
console.error("컬럼 정보 로딩 오류:", error);
toast({
title: "오류",
description: "컬럼 정보를 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setLoadingColumns(false);
}
};
const handleExecute = async () => {
console.log("실행 버튼 클릭");
if (!query.trim()) {
toast({
title: "오류",
description: "실행할 쿼리를 입력해주세요.",
variant: "destructive",
});
return;
}
// SELECT 쿼리만 허용하는 검증
const trimmedQuery = query.trim().toUpperCase();
if (!trimmedQuery.startsWith('SELECT')) {
toast({
title: "보안 오류",
description: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다. INSERT, UPDATE, DELETE는 허용되지 않습니다.",
variant: "destructive",
});
return;
}
// 위험한 키워드 검사
const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE'];
const hasDangerousKeyword = dangerousKeywords.some(keyword =>
trimmedQuery.includes(keyword)
);
if (hasDangerousKeyword) {
toast({
title: "보안 오류",
description: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.",
variant: "destructive",
});
return;
}
console.log("쿼리 실행 시작:", { connectionId, query });
setLoading(true);
try {
const result = await ExternalDbConnectionAPI.executeQuery(connectionId, query);
console.log("쿼리 실행 결과:", result);
if (result.success && result.data) {
setResults(result.data);
toast({
title: "성공",
description: "쿼리가 성공적으로 실행되었습니다.",
});
} else {
toast({
title: "오류",
description: result.message || "쿼리 실행 중 오류가 발생했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("쿼리 실행 오류:", error);
toast({
title: "오류",
description: error instanceof Error ? error.message : "쿼리 실행 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto" aria-describedby="modal-description">
<DialogHeader>
<DialogTitle>{connectionName} - SQL </DialogTitle>
<DialogDescription>
SQL SELECT .
</DialogDescription>
</DialogHeader>
{/* 쿼리 입력 영역 */}
<div className="space-y-4">
<div className="space-y-2">
{/* 테이블 선택 */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Select
value={selectedTable}
onValueChange={(value) => {
setSelectedTable(value);
loadTableColumns(value);
// 현재 커서 위치에 테이블 이름 삽입
setQuery((prev) => {
const fromIndex = prev.toUpperCase().lastIndexOf("FROM");
if (fromIndex === -1) {
return `SELECT * FROM ${value}`;
}
const beforeFrom = prev.substring(0, fromIndex + 4);
return `${beforeFrom} ${value}`;
});
}}
disabled={loadingTables}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={loadingTables ? "테이블 로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
{table.table_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 테이블 정보 */}
<div className="rounded-md border bg-muted/50 p-4 space-y-4">
<div>
<h3 className="mb-2 font-medium text-sm"> </h3>
<div className="max-h-[200px] overflow-y-auto">
<div className="space-y-2 pr-2">
{tables.map((table) => (
<div key={table.table_name} className="rounded-lg border bg-card p-3 shadow-sm">
<div className="flex items-center justify-between">
<h4 className="font-mono font-bold text-sm">{table.table_name}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedTable(table.table_name);
loadTableColumns(table.table_name);
setQuery(`SELECT * FROM ${table.table_name}`);
}}
className="h-8 text-xs"
>
</Button>
</div>
{table.description && (
<p className="mt-1 text-sm text-muted-foreground">{table.description}</p>
)}
</div>
))}
</div>
</div>
</div>
{/* 선택된 테이블의 컬럼 정보 */}
{selectedTable && (
<div>
<h3 className="mb-2 font-medium text-sm"> : {selectedTable}</h3>
{loadingColumns ? (
<div className="text-sm text-muted-foreground"> ...</div>
) : selectedTableColumns.length > 0 ? (
<div className="max-h-[200px] overflow-y-auto">
<div className="rounded-lg border bg-card shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[150px]"> </TableHead>
<TableHead className="w-[100px]">NULL </TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTableColumns.map((column) => (
<TableRow key={column.column_name}>
<TableCell className="font-mono font-medium">{column.column_name}</TableCell>
<TableCell className="text-sm">{column.data_type}</TableCell>
<TableCell className="text-sm">{column.is_nullable}</TableCell>
<TableCell className="text-sm">{column.column_default || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : (
<div className="text-sm text-muted-foreground"> .</div>
)}
</div>
)}
</div>
</div>
{/* 쿼리 입력 */}
<div className="relative">
<Textarea
value={query}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setQuery(e.target.value)}
placeholder="SELECT * FROM table_name"
className="min-h-[120px] pr-24 font-mono"
/>
<Button
onClick={() => {
console.log("버튼 클릭됨");
handleExecute();
}}
disabled={loading}
className="absolute top-2 right-2 w-20"
size="sm"
>
{loading ? "실행 중..." : "실행"}
</Button>
</div>
</div>
</div>
{/* 결과 섹션 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{loading ? "쿼리 실행 중..." : results.length > 0 ? `${results.length}개의 결과가 있습니다.` : "실행된 쿼리가 없습니다."}
</div>
</div>
{/* 결과 그리드 */}
<div className="rounded-md border bg-card">
<div className="max-h-[300px] overflow-y-auto">
<div className="inline-block min-w-full align-middle">
<div className="overflow-x-auto">
<Table>
{results.length > 0 ? (
<>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
{Object.keys(results[0]).map((key) => (
<TableHead key={key} className="font-mono font-bold">
{key}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{results.map((row: QueryResult, i: number) => (
<TableRow key={i} className="hover:bg-muted/50">
{Object.values(row).map((value, j) => (
<TableCell key={j} className="font-mono whitespace-nowrap">
{value === null ? (
<span className="text-muted-foreground italic">NULL</span>
) : (
String(value)
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</>
) : (
<TableBody>
<TableRow>
<TableCell className="text-muted-foreground h-32 text-center">
{loading ? "쿼리 실행 중..." : "쿼리를 실행하면 결과가 여기에 표시됩니다."}
</TableCell>
</TableRow>
</TableBody>
)}
</Table>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};