From e065835c4dc7a6c0b4bdef8382cb39e7ccf79d1f Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Feb 2026 16:07:44 +0900 Subject: [PATCH] feat: Add PK and index management APIs for table management - Implemented new API endpoints for managing primary keys and indexes in the table management system. - Added functionality to retrieve table constraints, set primary keys, toggle indexes, and manage NOT NULL constraints. - Enhanced the frontend to support PK and index management, including loading constraints and handling user interactions for toggling indexes and setting primary keys. - Improved error handling and logging for better debugging and user feedback during these operations. --- .../controllers/tableManagementController.ts | 257 ++++++++++++ .../src/routes/tableManagementRoutes.ts | 28 ++ .../admin/systemMng/tableMngList/page.tsx | 386 +++++++++++------- 3 files changed, 529 insertions(+), 142 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index a494ae3d..320ab74b 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2447,3 +2447,260 @@ export async function getReferencedByTables( res.status(500).json(response); } } + +// ======================================== +// PK / 인덱스 관리 API +// ======================================== + +/** + * PK/인덱스 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +export async function getTableConstraints( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + + if (!tableName) { + res.status(400).json({ success: false, message: "테이블명이 필요합니다." }); + return; + } + + // PK 조회 + const pkResult = await query( + `SELECT tc.conname AS constraint_name, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p' + GROUP BY tc.conname`, + [tableName] + ); + + // array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환 + const parseColumns = (cols: any): string[] => { + if (Array.isArray(cols)) return cols; + if (typeof cols === "string") { + // PostgreSQL 배열 형식: {col1,col2} + return cols.replace(/[{}]/g, "").split(",").filter(Boolean); + } + return []; + }; + + const primaryKey = pkResult.length > 0 + ? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) } + : { name: "", columns: [] }; + + // 인덱스 조회 (PK 인덱스 제외) + const indexResult = await query( + `SELECT i.relname AS index_name, + ix.indisunique AS is_unique, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_index ix + JOIN pg_class t ON ix.indrelid = t.oid + JOIN pg_class i ON ix.indexrelid = i.oid + JOIN pg_namespace ns ON t.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND t.relname = $1 + AND ix.indisprimary = false + GROUP BY i.relname, ix.indisunique + ORDER BY i.relname`, + [tableName] + ); + + const indexes = indexResult.map((row: any) => ({ + name: row.index_name, + columns: parseColumns(row.columns), + isUnique: row.is_unique, + })); + + logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}개`); + + res.status(200).json({ + success: true, + data: { primaryKey, indexes }, + }); + } catch (error) { + logger.error("제약조건 조회 오류:", error); + res.status(500).json({ + success: false, + message: "제약조건 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * PK 설정 + * PUT /api/table-management/tables/:tableName/primary-key + */ +export async function setTablePrimaryKey( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columns } = req.body; + + if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) { + res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." }); + return; + } + + logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`); + + // 기존 PK 제약조건 이름 조회 + const existingPk = await query( + `SELECT conname FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`, + [tableName] + ); + + // 기존 PK 삭제 + if (existingPk.length > 0) { + const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`; + logger.info(`기존 PK 삭제: ${dropSql}`); + await query(dropSql); + } + + // 새 PK 추가 + const colList = columns.map((c: string) => `"${c}"`).join(", "); + const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`; + logger.info(`새 PK 추가: ${addSql}`); + await query(addSql); + + res.status(200).json({ + success: true, + message: `PK가 설정되었습니다: ${columns.join(", ")}`, + }); + } catch (error) { + logger.error("PK 설정 오류:", error); + res.status(500).json({ + success: false, + message: "PK 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +export async function toggleTableIndex( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columnName, indexType, action } = req.body; + + if (!tableName || !columnName || !indexType || !action) { + res.status(400).json({ + success: false, + message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.", + }); + return; + } + + const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`; + + logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); + + if (action === "create") { + const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; + const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; + logger.info(`인덱스 생성: ${sql}`); + await query(sql); + } else if (action === "drop") { + const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`; + logger.info(`인덱스 삭제: ${sql}`); + await query(sql); + } else { + res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." }); + return; + } + + res.status(200).json({ + success: true, + message: action === "create" + ? `인덱스가 생성되었습니다: ${indexName}` + : `인덱스가 삭제되었습니다: ${indexName}`, + }); + } catch (error: any) { + logger.error("인덱스 토글 오류:", error); + + // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 + const errorMsg = error.message?.includes("duplicate key") + ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." + : "인덱스 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +export async function toggleColumnNullable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { nullable } = req.body; + + if (!tableName || !columnName || typeof nullable !== "boolean") { + res.status(400).json({ + success: false, + message: "tableName, columnName, nullable(boolean)이 필요합니다.", + }); + return; + } + + if (nullable) { + // NOT NULL 해제 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; + logger.info(`NOT NULL 해제: ${sql}`); + await query(sql); + } else { + // NOT NULL 설정 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; + logger.info(`NOT NULL 설정: ${sql}`); + await query(sql); + } + + res.status(200).json({ + success: true, + message: nullable + ? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.` + : `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`, + }); + } catch (error: any) { + logger.error("NOT NULL 토글 오류:", error); + + // NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내 + const errorMsg = error.message?.includes("contains null values") + ? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요." + : "NOT NULL 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index b9cf43c5..d02a5615 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -28,6 +28,10 @@ import { multiTableSave, // 🆕 범용 다중 테이블 저장 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 + getTableConstraints, // 🆕 PK/인덱스 상태 조회 + setTablePrimaryKey, // 🆕 PK 설정 + toggleTableIndex, // 🆕 인덱스 토글 + toggleColumnNullable, // 🆕 NOT NULL 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -133,6 +137,30 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings); */ router.get("/tables/:tableName/schema", getTableSchema); +/** + * PK/인덱스 제약조건 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +router.get("/tables/:tableName/constraints", getTableConstraints); + +/** + * PK 설정 (기존 PK DROP 후 재생성) + * PUT /api/table-management/tables/:tableName/primary-key + */ +router.put("/tables/:tableName/primary-key", setTablePrimaryKey); + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +router.post("/tables/:tableName/indexes", toggleTableIndex); + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); + /** * 테이블 존재 여부 확인 * GET /api/table-management/tables/:tableName/exists diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 17c52897..3a159700 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -145,6 +145,14 @@ export default function TableManagementPage() { const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); + // PK/인덱스 관리 상태 + const [constraints, setConstraints] = useState<{ + primaryKey: { name: string; columns: string[] }; + indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>; + }>({ primaryKey: { name: "", columns: [] }, indexes: [] }); + const [pkDialogOpen, setPkDialogOpen] = useState(false); + const [pendingPkColumns, setPendingPkColumns] = useState([]); + // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); @@ -397,6 +405,19 @@ export default function TableManagementPage() { } }, []); + // PK/인덱스 제약조건 로드 + const loadConstraints = useCallback(async (tableName: string) => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`); + if (response.data.success) { + setConstraints(response.data.data); + } + } catch (error) { + console.error("제약조건 로드 실패:", error); + setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] }); + } + }, []); + // 테이블 선택 const handleTableSelect = useCallback( (tableName: string) => { @@ -410,8 +431,9 @@ export default function TableManagementPage() { setTableDescription(tableInfo?.description || ""); loadColumnTypes(tableName, 1, pageSize); + loadConstraints(tableName); }, - [loadColumnTypes, pageSize, tables], + [loadColumnTypes, loadConstraints, pageSize, tables], ); // 입력 타입 변경 @@ -1000,6 +1022,123 @@ export default function TableManagementPage() { } }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); + // PK 체크박스 변경 핸들러 + const handlePkToggle = useCallback( + (columnName: string, checked: boolean) => { + const currentPkCols = [...constraints.primaryKey.columns]; + let newPkCols: string[]; + if (checked) { + newPkCols = [...currentPkCols, columnName]; + } else { + newPkCols = currentPkCols.filter((c) => c !== columnName); + } + // PK 변경은 확인 다이얼로그 표시 + setPendingPkColumns(newPkCols); + setPkDialogOpen(true); + }, + [constraints.primaryKey.columns], + ); + + // PK 변경 확인 + const handlePkConfirm = async () => { + if (!selectedTable) return; + try { + if (pendingPkColumns.length === 0) { + toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다."); + setPkDialogOpen(false); + return; + } + const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, { + columns: pendingPkColumns, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "PK 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다."); + } finally { + setPkDialogOpen(false); + } + }; + + // 인덱스 토글 핸들러 + const handleIndexToggle = useCallback( + async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + if (!selectedTable) return; + const action = checked ? "create" : "drop"; + try { + const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, { + columnName, + indexType, + action, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "인덱스 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다."); + } + }, + [selectedTable, loadConstraints], + ); + + // 컬럼별 인덱스 상태 헬퍼 + const getColumnIndexState = useCallback( + (columnName: string) => { + const isPk = constraints.primaryKey.columns.includes(columnName); + const hasIndex = constraints.indexes.some( + (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + const hasUnique = constraints.indexes.some( + (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + return { isPk, hasIndex, hasUnique }; + }, + [constraints], + ); + + // NOT NULL 토글 핸들러 + const handleNullableToggle = useCallback( + async (columnName: string, currentIsNullable: string) => { + if (!selectedTable) return; + // isNullable이 "YES"면 nullable, "NO"면 NOT NULL + // 체크박스 체크 = NOT NULL 설정 (nullable: false) + // 체크박스 해제 = NOT NULL 해제 (nullable: true) + const isCurrentlyNotNull = currentIsNullable === "NO"; + const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정 + try { + const response = await apiClient.put( + `/table-management/tables/${selectedTable}/columns/${columnName}/nullable`, + { nullable: newNullable }, + ); + if (response.data.success) { + toast.success(response.data.message); + // 컬럼 상태 로컬 업데이트 + setColumns((prev) => + prev.map((col) => + col.columnName === columnName + ? { ...col, isNullable: newNullable ? "YES" : "NO" } + : col, + ), + ); + } else { + toast.error(response.data.message || "NOT NULL 설정 실패"); + } + } catch (error: any) { + toast.error( + error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.", + ); + } + }, + [selectedTable], + ); + // 테이블 삭제 확인 const handleDeleteTableClick = (tableName: string) => { setTableToDelete(tableName); @@ -1391,12 +1530,16 @@ export default function TableManagementPage() { {/* 컬럼 헤더 (고정) */}
-
컬럼명
-
라벨
+
라벨
+
컬럼명
입력 타입
설명
+
Primary
+
NotNull
+
Index
+
Unique
{/* 컬럼 리스트 (스크롤 영역) */} @@ -1410,16 +1553,15 @@ export default function TableManagementPage() { } }} > - {columns.map((column, index) => ( + {columns.map((column, index) => { + const idxState = getColumnIndexState(column.columnName); + return (
-
-
{column.columnName}
-
-
+
handleLabelChange(column.columnName, e.target.value)} @@ -1427,6 +1569,9 @@ export default function TableManagementPage() { className="h-8 text-xs" />
+
+
{column.columnName}
+
{/* 입력 타입 선택 */} @@ -1689,141 +1834,11 @@ export default function TableManagementPage() {
)} - {/* 표시 컬럼 - 검색 가능한 Combobox */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && ( -
- - - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: open, - }, - })) - } - > - - - - - - - - - 컬럼을 찾을 수 없습니다. - - - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - "none", - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - - -- 선택 안함 -- - - {referenceTableColumns[column.referenceTable]?.map((refCol) => ( - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - refCol.columnName, - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - -
- {refCol.columnName} - {refCol.columnLabel && ( - - {refCol.columnLabel} - - )} -
-
- ))} -
-
-
-
-
-
- )} - {/* 설정 완료 표시 */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( + column.referenceColumn !== "none" && (
설정 완료 @@ -1953,8 +1968,49 @@ export default function TableManagementPage() { className="h-8 w-full text-xs" />
+ {/* PK 체크박스 */} +
+ + handlePkToggle(column.columnName, checked as boolean) + } + aria-label={`${column.columnName} PK 설정`} + /> +
+ {/* NN (NOT NULL) 체크박스 */} +
+ + handleNullableToggle(column.columnName, column.isNullable) + } + aria-label={`${column.columnName} NOT NULL 설정`} + /> +
+ {/* IDX 체크박스 */} +
+ + handleIndexToggle(column.columnName, "index", checked as boolean) + } + aria-label={`${column.columnName} 인덱스 설정`} + /> +
+ {/* UQ 체크박스 */} +
+ + handleIndexToggle(column.columnName, "unique", checked as boolean) + } + aria-label={`${column.columnName} 유니크 설정`} + /> +
- ))} + ); + })} {/* 로딩 표시 */} {columnsLoading && ( @@ -2120,6 +2176,52 @@ export default function TableManagementPage() { )} + {/* PK 변경 확인 다이얼로그 */} + + + + PK 변경 확인 + + PK를 변경하면 기존 제약조건이 삭제되고 새로 생성됩니다. +
데이터 무결성에 영향을 줄 수 있습니다. +
+
+ +
+
+

변경될 PK 컬럼:

+ {pendingPkColumns.length > 0 ? ( +
+ {pendingPkColumns.map((col) => ( + + {col} + + ))} +
+ ) : ( +

PK가 모두 제거됩니다

+ )} +
+
+ + + + + +
+
+ {/* Scroll to Top 버튼 */}