feat(테이블타입): 컬럼 단건 DROP + CreateTableModal flex 레이아웃 수정 (#22)
Build & Deploy to K8s / build-and-deploy (push) Successful in 13m20s
Build & Deploy to K8s / build-and-deploy (push) Successful in 13m20s
johngreen → main: 컬럼 단건 DROP 기능 + CreateTableModal 다이얼로그 flex 레이아웃 수정
This commit was merged in pull request #22.
This commit is contained in:
@@ -91,6 +91,32 @@ public class DdlController {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/tables/{tableName}/columns/{columnName} - 컬럼 삭제
|
||||
*/
|
||||
@DeleteMapping("/tables/{tableName}/columns/{columnName}")
|
||||
public ResponseEntity<ApiResponse<?>> dropColumn(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> result = ddlService.dropColumn(tableName, columnName, companyCode, userId);
|
||||
|
||||
if (Boolean.TRUE.equals(result.get("success"))) {
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||
"table_name", result.get("table_name"),
|
||||
"column_name", result.get("column_name"),
|
||||
"executed_query", result.get("executed_query")
|
||||
), (String) result.get("message")));
|
||||
}
|
||||
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/tables/{tableName} - 테이블 삭제
|
||||
*/
|
||||
|
||||
@@ -226,6 +226,79 @@ public class DdlService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// DROP COLUMN (DBeaver 방식: FK 등 위반은 Postgres 가 던지는 에러를 그대로 노출)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> dropColumn(String tableName, String columnName,
|
||||
String companyCode, String userId) {
|
||||
// 1. 시스템 테이블 보호
|
||||
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
|
||||
String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 컬럼을 삭제할 수 없습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
||||
"SYSTEM_TABLE_PROTECTED", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED");
|
||||
}
|
||||
|
||||
// 2. 예약 컬럼 보호 (id / created_date / updated_date / company_code / writer)
|
||||
if (RESERVED_COLUMNS.contains(columnName.toLowerCase()) || "writer".equalsIgnoreCase(columnName)) {
|
||||
String errorMsg = "'" + columnName + "'은 시스템 예약 컬럼이므로 삭제할 수 없습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
||||
"RESERVED_COLUMN_PROTECTED", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "RESERVED_COLUMN_PROTECTED");
|
||||
}
|
||||
|
||||
// 3. 테이블/컬럼 존재 여부
|
||||
if (!tableExists(tableName)) {
|
||||
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "TABLE_NOT_FOUND", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND");
|
||||
}
|
||||
if (!columnExists(tableName, columnName)) {
|
||||
String errorMsg = "컬럼 '" + columnName + "'이 존재하지 않습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "COLUMN_NOT_FOUND", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_NOT_FOUND");
|
||||
}
|
||||
|
||||
// 4. DDL 실행 — CASCADE 안 붙임 → FK 참조 있으면 Postgres 가 거부 (DBeaver 와 동일)
|
||||
String ddlQuery = "ALTER TABLE \"" + sanitize(tableName) + "\" DROP COLUMN \"" + sanitize(columnName) + "\"";
|
||||
|
||||
try {
|
||||
transactionTemplate.execute(status -> {
|
||||
jdbcTemplate.execute(ddlQuery);
|
||||
// 컬럼 메타 청소
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM table_type_columns WHERE table_name = ? AND column_name = ?",
|
||||
tableName, columnName);
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM column_labels WHERE table_name = ? AND column_name = ?",
|
||||
tableName, columnName);
|
||||
return null;
|
||||
});
|
||||
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, ddlQuery, true, null);
|
||||
log.info("컬럼 삭제 성공: {}.{}, 사용자: {}", tableName, columnName, userId);
|
||||
|
||||
return Map.of(
|
||||
"success", true,
|
||||
"message", "컬럼 '" + columnName + "'이 성공적으로 삭제되었습니다.",
|
||||
"table_name", tableName,
|
||||
"column_name", columnName,
|
||||
"executed_query", ddlQuery
|
||||
);
|
||||
} catch (Exception e) {
|
||||
String rawMsg = e.getMessage() != null ? e.getMessage() : "";
|
||||
String guidance = rawMsg.toLowerCase().contains("depend") || rawMsg.toLowerCase().contains("foreign key")
|
||||
? " (다른 테이블에서 외래키로 참조 중인 컬럼은 삭제할 수 없습니다)"
|
||||
: "";
|
||||
String errorMsg = "컬럼 삭제 실패: " + rawMsg + guidance;
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
||||
"FAILED: " + rawMsg, false, errorMsg);
|
||||
log.error("컬럼 삭제 실패: {}.{}, 사용자: {}, 오류: {}", tableName, columnName, userId, rawMsg, e);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// VALIDATE
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -120,6 +120,9 @@ export default function TableManagementPage() {
|
||||
// 테이블 삭제 확인 다이얼로그 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [tableToDelete, setTableToDelete] = useState<string>("");
|
||||
const [deleteColumnDialogOpen, setDeleteColumnDialogOpen] = useState(false);
|
||||
const [columnToDelete, setColumnToDelete] = useState<string>("");
|
||||
const [isDeletingColumn, setIsDeletingColumn] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// PK/인덱스 관리 상태
|
||||
@@ -984,7 +987,20 @@ export default function TableManagementPage() {
|
||||
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
|
||||
const q = searchTerm.trim().toLowerCase();
|
||||
// 검색 매치 강도: 0=정확, 1=시작, 2=포함 — 낮을수록 위
|
||||
const matchScore = (t: typeof tables[number]) => {
|
||||
if (!q) return 0;
|
||||
const tn = (t.table_name ?? "").toLowerCase();
|
||||
const dn = (t.display_name ?? "").toLowerCase();
|
||||
if (tn === q || dn === q) return 0;
|
||||
if (tn.startsWith(q) || dn.startsWith(q)) return 1;
|
||||
return 2;
|
||||
};
|
||||
return filtered.sort((a, b) => {
|
||||
const sa = matchScore(a);
|
||||
const sb = matchScore(b);
|
||||
if (sa !== sb) return sa - sb;
|
||||
const nameA = a.display_name || a.table_name;
|
||||
const nameB = b.display_name || b.table_name;
|
||||
const aKo = isKorean(nameA);
|
||||
@@ -1188,6 +1204,37 @@ export default function TableManagementPage() {
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 컬럼 삭제 (DBeaver 방식 — FK 참조 있으면 Postgres 가 거부)
|
||||
const handleDeleteColumnClick = (columnName: string) => {
|
||||
setColumnToDelete(columnName);
|
||||
setDeleteColumnDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteColumn = async () => {
|
||||
if (!selectedTable || !columnToDelete) return;
|
||||
setIsDeletingColumn(true);
|
||||
try {
|
||||
const result = await ddlApi.dropColumn(selectedTable, columnToDelete);
|
||||
if (result.success) {
|
||||
toast.success(`컬럼 '${columnToDelete}'이 삭제되었습니다.`);
|
||||
if (selectedColumn === columnToDelete) setSelectedColumn(null);
|
||||
await loadColumnTypes(selectedTable);
|
||||
} else {
|
||||
showErrorToast("컬럼 삭제에 실패했습니다", result.message, {
|
||||
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("컬럼 삭제에 실패했습니다", error, {
|
||||
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingColumn(false);
|
||||
setDeleteColumnDialogOpen(false);
|
||||
setColumnToDelete("");
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 삭제 실행
|
||||
const handleDeleteTable = async () => {
|
||||
if (!tableToDelete) return;
|
||||
@@ -1678,6 +1725,7 @@ export default function TableManagementPage() {
|
||||
onIndexToggle={(columnName, checked) =>
|
||||
handleIndexToggle(columnName, "index", checked)
|
||||
}
|
||||
onDeleteColumn={handleDeleteColumnClick}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
/>
|
||||
@@ -1863,6 +1911,62 @@ export default function TableManagementPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 컬럼 삭제 확인 다이얼로그 */}
|
||||
<Dialog open={deleteColumnDialogOpen} onOpenChange={setDeleteColumnDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">컬럼 삭제 확인</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
정말 삭제할까요? 이 작업은 되돌릴 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{columnToDelete && (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||
<p className="text-destructive text-sm font-semibold">경고</p>
|
||||
<p className="text-destructive/80 mt-1.5 text-sm">
|
||||
<span className="font-mono font-bold">{selectedTable}.{columnToDelete}</span> 컬럼과 해당 컬럼의
|
||||
모든 데이터가 영구적으로 삭제됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteColumnDialogOpen(false);
|
||||
setColumnToDelete("");
|
||||
}}
|
||||
disabled={isDeletingColumn}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteColumn}
|
||||
disabled={isDeletingColumn}
|
||||
className="h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isDeletingColumn ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ export function CreateTableModal({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
@@ -336,7 +336,7 @@ export function CreateTableModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-y-auto pr-1">
|
||||
{/* 테이블 기본 정보 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
|
||||
import { MoreHorizontal, Database, Layers, FileStack, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnTypeInfo, TableInfo } from "./types";
|
||||
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
|
||||
@@ -24,6 +30,7 @@ export interface ColumnGridProps {
|
||||
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
|
||||
onPkToggle?: (columnName: string, checked: boolean) => void;
|
||||
onIndexToggle?: (columnName: string, checked: boolean) => void;
|
||||
onDeleteColumn?: (columnName: string) => void;
|
||||
/** 호버 시 한글 라벨 표시용 (Badge title) */
|
||||
tables?: TableInfo[];
|
||||
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
|
||||
@@ -57,6 +64,7 @@ export function ColumnGrid({
|
||||
getColumnIndexState: externalGetIndexState,
|
||||
onPkToggle,
|
||||
onIndexToggle,
|
||||
onDeleteColumn,
|
||||
tables,
|
||||
referenceTableColumns,
|
||||
}: ColumnGridProps) {
|
||||
@@ -286,19 +294,32 @@ export function ColumnGrid({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectColumn(column.column_name);
|
||||
}}
|
||||
aria-label="상세 설정"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="컬럼 액션 메뉴"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDeleteColumn?.(column.column_name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
컬럼 삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,14 @@ export const ddlApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 컬럼 삭제 (ALTER TABLE ... DROP COLUMN)
|
||||
*/
|
||||
dropColumn: async (tableName: string, columnName: string): Promise<DDLExecutionResult> => {
|
||||
const response = await apiClient.delete(`/ddl/tables/${tableName}/columns/${columnName}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 테이블 생성 사전 검증 (실제 생성하지 않고 검증만)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user