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.
This commit is contained in:
@@ -145,6 +145,14 @@ export default function TableManagementPage() {
|
||||
const [tableToDelete, setTableToDelete] = useState<string>("");
|
||||
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<string[]>([]);
|
||||
|
||||
// 선택된 테이블 목록 (체크박스)
|
||||
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(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() {
|
||||
{/* 컬럼 헤더 (고정) */}
|
||||
<div
|
||||
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
|
||||
>
|
||||
<div className="pr-4">컬럼명</div>
|
||||
<div className="px-4">라벨</div>
|
||||
<div className="pr-4">라벨</div>
|
||||
<div className="px-4">컬럼명</div>
|
||||
<div className="pr-6">입력 타입</div>
|
||||
<div className="pl-4">설명</div>
|
||||
<div className="text-center text-xs">Primary</div>
|
||||
<div className="text-center text-xs">NotNull</div>
|
||||
<div className="text-center text-xs">Index</div>
|
||||
<div className="text-center text-xs">Unique</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 리스트 (스크롤 영역) */}
|
||||
@@ -1410,16 +1553,15 @@ export default function TableManagementPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
{columns.map((column, index) => {
|
||||
const idxState = getColumnIndexState(column.columnName);
|
||||
return (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
|
||||
>
|
||||
<div className="pt-1 pr-4">
|
||||
<div className="font-mono text-sm">{column.columnName}</div>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<div className="pr-4">
|
||||
<Input
|
||||
value={column.displayName || ""}
|
||||
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
||||
@@ -1427,6 +1569,9 @@ export default function TableManagementPage() {
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pt-1">
|
||||
<div className="font-mono text-sm">{column.columnName}</div>
|
||||
</div>
|
||||
<div className="pr-6">
|
||||
<div className="space-y-3">
|
||||
{/* 입력 타입 선택 */}
|
||||
@@ -1689,141 +1834,11 @@ export default function TableManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 표시 컬럼 - 검색 가능한 Combobox */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" && (
|
||||
<div className="w-56">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">표시 컬럼</label>
|
||||
<Popover
|
||||
open={entityComboboxOpen[column.columnName]?.displayColumn || false}
|
||||
onOpenChange={(open) =>
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
displayColumn: open,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={
|
||||
entityComboboxOpen[column.columnName]?.displayColumn || false
|
||||
}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
disabled={
|
||||
!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0
|
||||
}
|
||||
>
|
||||
{!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||
로딩중...
|
||||
</span>
|
||||
) : column.displayColumn && column.displayColumn !== "none" ? (
|
||||
column.displayColumn
|
||||
) : (
|
||||
"컬럼 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
컬럼을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_display_column",
|
||||
"none",
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
displayColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.displayColumn === "none" || !column.displayColumn
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
</CommandItem>
|
||||
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
|
||||
<CommandItem
|
||||
key={refCol.columnName}
|
||||
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_display_column",
|
||||
refCol.columnName,
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
displayColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.displayColumn === refCol.columnName
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
{refCol.columnLabel && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{refCol.columnLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정 완료 표시 */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
column.referenceColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
|
||||
<Check className="h-3 w-3" />
|
||||
<span className="truncate">설정 완료</span>
|
||||
@@ -1953,8 +1968,49 @@ export default function TableManagementPage() {
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* PK 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={idxState.isPk}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePkToggle(column.columnName, checked as boolean)
|
||||
}
|
||||
aria-label={`${column.columnName} PK 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* NN (NOT NULL) 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={column.isNullable === "NO"}
|
||||
onCheckedChange={() =>
|
||||
handleNullableToggle(column.columnName, column.isNullable)
|
||||
}
|
||||
aria-label={`${column.columnName} NOT NULL 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* IDX 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={idxState.hasIndex}
|
||||
onCheckedChange={(checked) =>
|
||||
handleIndexToggle(column.columnName, "index", checked as boolean)
|
||||
}
|
||||
aria-label={`${column.columnName} 인덱스 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* UQ 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={idxState.hasUnique}
|
||||
onCheckedChange={(checked) =>
|
||||
handleIndexToggle(column.columnName, "unique", checked as boolean)
|
||||
}
|
||||
aria-label={`${column.columnName} 유니크 설정`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 로딩 표시 */}
|
||||
{columnsLoading && (
|
||||
@@ -2120,6 +2176,52 @@ export default function TableManagementPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PK 변경 확인 다이얼로그 */}
|
||||
<Dialog open={pkDialogOpen} onOpenChange={setPkDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">PK 변경 확인</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
PK를 변경하면 기존 제약조건이 삭제되고 새로 생성됩니다.
|
||||
<br />데이터 무결성에 영향을 줄 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">변경될 PK 컬럼:</p>
|
||||
{pendingPkColumns.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{pendingPkColumns.map((col) => (
|
||||
<Badge key={col} variant="secondary" className="font-mono text-xs">
|
||||
{col}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-destructive mt-2 text-sm">PK가 모두 제거됩니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPkDialogOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePkConfirm}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
변경
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user