feat(테이블타입): 컬럼 단건 DROP 기능 — ColumnGrid ⋯ 메뉴에 "컬럼 삭제" 추가
- DdlService.dropColumn: ALTER TABLE ... DROP COLUMN (CASCADE 미사용 → FK 참조 시 Postgres 거부, DBeaver 동일)
- 시스템 테이블 / 예약 컬럼(id/created_date/updated_date/company_code/writer) 보호
- 같은 트랜잭션에서 table_type_columns / column_labels 메타 청소 + ddl_execution_log 기록
- DdlController: DELETE /api/ddl/tables/{table}/columns/{column} (SUPER_ADMIN 전용)
- ddlApi.dropColumn 헬퍼
- ColumnGrid: ... 버튼을 DropdownMenu 로 교체, "컬럼 삭제" destructive 메뉴 아이템
- page.tsx: 컬럼 삭제 확인 다이얼로그 + 핸들러, FK 거부 시 토스트로 안내
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,32 @@ public class DdlController {
|
|||||||
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
|
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} - 테이블 삭제
|
* 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
|
// VALIDATE
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ export default function TableManagementPage() {
|
|||||||
// 테이블 삭제 확인 다이얼로그 상태
|
// 테이블 삭제 확인 다이얼로그 상태
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [tableToDelete, setTableToDelete] = useState<string>("");
|
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);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
// PK/인덱스 관리 상태
|
// PK/인덱스 관리 상태
|
||||||
@@ -984,7 +987,20 @@ export default function TableManagementPage() {
|
|||||||
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
|
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
|
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) => {
|
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 nameA = a.display_name || a.table_name;
|
||||||
const nameB = b.display_name || b.table_name;
|
const nameB = b.display_name || b.table_name;
|
||||||
const aKo = isKorean(nameA);
|
const aKo = isKorean(nameA);
|
||||||
@@ -1188,6 +1204,37 @@ export default function TableManagementPage() {
|
|||||||
setDeleteDialogOpen(true);
|
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 () => {
|
const handleDeleteTable = async () => {
|
||||||
if (!tableToDelete) return;
|
if (!tableToDelete) return;
|
||||||
@@ -1678,6 +1725,7 @@ export default function TableManagementPage() {
|
|||||||
onIndexToggle={(columnName, checked) =>
|
onIndexToggle={(columnName, checked) =>
|
||||||
handleIndexToggle(columnName, "index", checked)
|
handleIndexToggle(columnName, "index", checked)
|
||||||
}
|
}
|
||||||
|
onDeleteColumn={handleDeleteColumnClick}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
referenceTableColumns={referenceTableColumns}
|
referenceTableColumns={referenceTableColumns}
|
||||||
/>
|
/>
|
||||||
@@ -1863,6 +1911,62 @@ export default function TableManagementPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ColumnTypeInfo, TableInfo } from "./types";
|
import type { ColumnTypeInfo, TableInfo } from "./types";
|
||||||
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
|
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
|
||||||
@@ -24,6 +30,7 @@ export interface ColumnGridProps {
|
|||||||
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
|
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
|
||||||
onPkToggle?: (columnName: string, checked: boolean) => void;
|
onPkToggle?: (columnName: string, checked: boolean) => void;
|
||||||
onIndexToggle?: (columnName: string, checked: boolean) => void;
|
onIndexToggle?: (columnName: string, checked: boolean) => void;
|
||||||
|
onDeleteColumn?: (columnName: string) => void;
|
||||||
/** 호버 시 한글 라벨 표시용 (Badge title) */
|
/** 호버 시 한글 라벨 표시용 (Badge title) */
|
||||||
tables?: TableInfo[];
|
tables?: TableInfo[];
|
||||||
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
|
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
|
||||||
@@ -57,6 +64,7 @@ export function ColumnGrid({
|
|||||||
getColumnIndexState: externalGetIndexState,
|
getColumnIndexState: externalGetIndexState,
|
||||||
onPkToggle,
|
onPkToggle,
|
||||||
onIndexToggle,
|
onIndexToggle,
|
||||||
|
onDeleteColumn,
|
||||||
tables,
|
tables,
|
||||||
referenceTableColumns,
|
referenceTableColumns,
|
||||||
}: ColumnGridProps) {
|
}: ColumnGridProps) {
|
||||||
@@ -286,19 +294,32 @@ export function ColumnGrid({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Button
|
<DropdownMenu>
|
||||||
type="button"
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
type="button"
|
||||||
className="h-8 w-8"
|
variant="ghost"
|
||||||
onClick={(e) => {
|
size="icon"
|
||||||
e.stopPropagation();
|
className="h-8 w-8"
|
||||||
onSelectColumn(column.column_name);
|
onClick={(e) => e.stopPropagation()}
|
||||||
}}
|
aria-label="컬럼 액션 메뉴"
|
||||||
aria-label="상세 설정"
|
>
|
||||||
>
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
</Button>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ export const ddlApi = {
|
|||||||
return response.data;
|
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