feat: implement database management API with MySQL driver and schema operations
This commit is contained in:
@@ -83,4 +83,14 @@ interface DatabaseDriverInterface
|
|||||||
* Create a new database.
|
* Create a new database.
|
||||||
*/
|
*/
|
||||||
public function createDatabase(string $name, string $charset = 'utf8mb4', string $collation = 'utf8mb4_unicode_ci'): bool;
|
public function createDatabase(string $name, string $charset = 'utf8mb4', string $collation = 'utf8mb4_unicode_ci'): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a database.
|
||||||
|
*/
|
||||||
|
public function dropDatabase(string $name): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a database.
|
||||||
|
*/
|
||||||
|
public function renameDatabase(string $oldName, string $newName): bool;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,4 +287,31 @@ class SchemaController extends Controller
|
|||||||
return Response::json(['error' => $e->getMessage()], 400);
|
return Response::json(['error' => $e->getMessage()], 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function dropDatabase(Request $request, $database)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->initializeDriver($request);
|
||||||
|
$this->databaseService->dropDatabase($database);
|
||||||
|
return Response::json(['message' => "Database '{$database}' dropped successfully"]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return Response::json(['error' => $e->getMessage()], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renameDatabase(Request $request, $database)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'newName' => 'required|string|max:64',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->initializeDriver($request);
|
||||||
|
$newName = $request->get('newName');
|
||||||
|
$this->databaseService->renameDatabase($database, $newName);
|
||||||
|
return Response::json(['message' => "Database '{$database}' renamed to '{$newName}' successfully"]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return Response::json(['error' => $e->getMessage()], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,4 +454,39 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface
|
|||||||
DB::connection($this->connectionName)->statement("CREATE DATABASE `{$name}` CHARACTER SET {$charset} COLLATE {$collation}");
|
DB::connection($this->connectionName)->statement("CREATE DATABASE `{$name}` CHARACTER SET {$charset} COLLATE {$collation}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function dropDatabase(string $name): bool
|
||||||
|
{
|
||||||
|
DB::connection($this->connectionName)->statement("DROP DATABASE `{$name}`");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renameDatabase(string $oldName, string $newName): bool
|
||||||
|
{
|
||||||
|
// 1. Create new database
|
||||||
|
$this->createDatabase($newName);
|
||||||
|
|
||||||
|
// 2. Get tables from old database
|
||||||
|
$tables = $this->query("SHOW TABLES FROM `{$oldName}`");
|
||||||
|
$key = "Tables_in_{$oldName}";
|
||||||
|
|
||||||
|
if (!empty($tables)) {
|
||||||
|
$renameQueries = [];
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$tableName = $table->$key;
|
||||||
|
$renameQueries[] = "`{$oldName}`.`{$tableName}` TO `{$newName}`.`{$tableName}`";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Rename all tables
|
||||||
|
if (!empty($renameQueries)) {
|
||||||
|
$sql = "RENAME TABLE " . implode(', ', $renameQueries);
|
||||||
|
DB::connection($this->connectionName)->statement($sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Drop old database
|
||||||
|
$this->dropDatabase($oldName);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,4 +172,20 @@ class DatabaseService
|
|||||||
{
|
{
|
||||||
return $this->getDriver()->createDatabase($name, $charset, $collation);
|
return $this->getDriver()->createDatabase($name, $charset, $collation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a database.
|
||||||
|
*/
|
||||||
|
public function dropDatabase(string $name): bool
|
||||||
|
{
|
||||||
|
return $this->getDriver()->dropDatabase($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a database.
|
||||||
|
*/
|
||||||
|
public function renameDatabase(string $oldName, string $newName): bool
|
||||||
|
{
|
||||||
|
return $this->getDriver()->renameDatabase($oldName, $newName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Route::get('/user', function (Request $request) {
|
|||||||
Route::prefix('schema')->group(function () {
|
Route::prefix('schema')->group(function () {
|
||||||
Route::get('/databases', [SchemaController::class, 'databases']);
|
Route::get('/databases', [SchemaController::class, 'databases']);
|
||||||
Route::post('/databases', [SchemaController::class, 'createDatabase']);
|
Route::post('/databases', [SchemaController::class, 'createDatabase']);
|
||||||
|
Route::delete('/databases/{database}', [SchemaController::class, 'dropDatabase']);
|
||||||
|
Route::post('/databases/{database}/rename', [SchemaController::class, 'renameDatabase']);
|
||||||
Route::get('/tables/{database}', [SchemaController::class, 'tables']);
|
Route::get('/tables/{database}', [SchemaController::class, 'tables']);
|
||||||
Route::get('/metadata/{database}', [SchemaController::class, 'metadata']);
|
Route::get('/metadata/{database}', [SchemaController::class, 'metadata']);
|
||||||
Route::get('/metadata/{database}/tables', [SchemaController::class, 'tablesMetadata']);
|
Route::get('/metadata/{database}/tables', [SchemaController::class, 'tablesMetadata']);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Typography, Divider, CircularProgress, Stack, TextField, InputAdornment, IconButton, Tooltip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Alert, Snackbar, AlertTitle } from '@mui/material';
|
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Typography, Divider, CircularProgress, Stack, TextField, InputAdornment, IconButton, Tooltip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Alert, Snackbar, AlertTitle, Menu, MenuItem } from '@mui/material';
|
||||||
import { Storage, Search, FilterList, ChevronRight, ExpandMore, TableChart, Folder, Add } from '@mui/icons-material';
|
import { Storage, Search, FilterList, ChevronRight, ExpandMore, TableChart, Folder, Add, MoreVert, Delete, Edit } from '@mui/icons-material';
|
||||||
import { useAppStore } from '../store/useAppStore';
|
import { useAppStore } from '../store/useAppStore';
|
||||||
import { SchemaService } from '../services/api';
|
import { SchemaService } from '../services/api';
|
||||||
import type { MainNotification } from '../types/database';
|
import type { MainNotification } from '../types/database';
|
||||||
@@ -19,7 +19,19 @@ const Sidebar: React.FC = () => {
|
|||||||
|
|
||||||
const [openNewDbDialog, setOpenNewDbDialog] = useState(false);
|
const [openNewDbDialog, setOpenNewDbDialog] = useState(false);
|
||||||
const [newDbName, setNewDbName] = useState('');
|
const [newDbName, setNewDbName] = useState('');
|
||||||
const [isCreatingDb, setIsCreatingDb] = useState(false);
|
|
||||||
|
const [openRenameDialog, setOpenRenameDialog] = useState(false);
|
||||||
|
const [dbToRename, setDbToRename] = useState('');
|
||||||
|
const [renamedDbName, setRenamedDbName] = useState('');
|
||||||
|
|
||||||
|
const [openDeleteConfirm, setOpenDeleteConfirm] = useState(false);
|
||||||
|
const [dbToDelete, setDbToDelete] = useState('');
|
||||||
|
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const [selectedDbForMenu, setSelectedDbForMenu] = useState('');
|
||||||
|
|
||||||
const [notification, setNotification] = useState<MainNotification>({
|
const [notification, setNotification] = useState<MainNotification>({
|
||||||
open: false,
|
open: false,
|
||||||
message: '',
|
message: '',
|
||||||
@@ -102,7 +114,7 @@ const Sidebar: React.FC = () => {
|
|||||||
const handleCreateDatabase = async () => {
|
const handleCreateDatabase = async () => {
|
||||||
if (!newDbName.trim()) return;
|
if (!newDbName.trim()) return;
|
||||||
|
|
||||||
setIsCreatingDb(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await SchemaService.createDatabase(newDbName);
|
await SchemaService.createDatabase(newDbName);
|
||||||
setNotification({
|
setNotification({
|
||||||
@@ -122,10 +134,95 @@ const Sidebar: React.FC = () => {
|
|||||||
severity: 'error'
|
severity: 'error'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingDb(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRenameDatabase = async () => {
|
||||||
|
if (!renamedDbName.trim() || !dbToRename) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
await SchemaService.renameDatabase(dbToRename, renamedDbName);
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
title: 'Success',
|
||||||
|
message: `Database '${dbToRename}' renamed to '${renamedDbName}' successfully.`,
|
||||||
|
severity: 'success'
|
||||||
|
});
|
||||||
|
if (activeDatabase === dbToRename) {
|
||||||
|
setActiveDatabase(renamedDbName);
|
||||||
|
}
|
||||||
|
setOpenRenameDialog(false);
|
||||||
|
setDbToRename('');
|
||||||
|
setRenamedDbName('');
|
||||||
|
fetchDatabases();
|
||||||
|
} catch (error: any) {
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
title: 'Error',
|
||||||
|
message: error.response?.data?.error || error.message,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDatabase = async () => {
|
||||||
|
if (!dbToDelete) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
await SchemaService.dropDatabase(dbToDelete);
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
title: 'Success',
|
||||||
|
message: `Database '${dbToDelete}' deleted successfully.`,
|
||||||
|
severity: 'success'
|
||||||
|
});
|
||||||
|
if (activeDatabase === dbToDelete) {
|
||||||
|
setActiveDatabase(null);
|
||||||
|
}
|
||||||
|
setOpenDeleteConfirm(false);
|
||||||
|
setDbToDelete('');
|
||||||
|
fetchDatabases();
|
||||||
|
} catch (error: any) {
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
title: 'Error',
|
||||||
|
message: error.response?.data?.error || error.message,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, db: string) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setMenuAnchorEl(event.currentTarget);
|
||||||
|
setSelectedDbForMenu(db);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
setMenuAnchorEl(null);
|
||||||
|
setSelectedDbForMenu('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuRename = () => {
|
||||||
|
setDbToRename(selectedDbForMenu);
|
||||||
|
setRenamedDbName(selectedDbForMenu);
|
||||||
|
setOpenRenameDialog(true);
|
||||||
|
handleMenuClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuDelete = () => {
|
||||||
|
setDbToDelete(selectedDbForMenu);
|
||||||
|
setOpenDeleteConfirm(true);
|
||||||
|
handleMenuClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: 280, borderRight: 1, borderColor: 'divider', display: 'flex', flexDirection: 'column', bgcolor: 'background.paper', zIndex: 10 }}>
|
<Box sx={{ width: 280, borderRight: 1, borderColor: 'divider', display: 'flex', flexDirection: 'column', bgcolor: 'background.paper', zIndex: 10 }}>
|
||||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
@@ -186,6 +283,9 @@ const Sidebar: React.FC = () => {
|
|||||||
<ListItemText
|
<ListItemText
|
||||||
primary={<Typography variant="body2" sx={{ fontWeight: activeDatabase === db ? 600 : 400, fontSize: '0.85rem' }}>{db}</Typography>}
|
primary={<Typography variant="body2" sx={{ fontWeight: activeDatabase === db ? 600 : 400, fontSize: '0.85rem' }}>{db}</Typography>}
|
||||||
/>
|
/>
|
||||||
|
<IconButton size="small" onClick={(e) => handleMenuOpen(e, db)} sx={{ opacity: 0, '&:hover': { opacity: 1 }, '.MuiListItemButton-root:hover &': { opacity: 0.5 } }}>
|
||||||
|
<MoreVert fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -290,24 +390,99 @@ const Sidebar: React.FC = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={newDbName}
|
value={newDbName}
|
||||||
onChange={(e) => setNewDbName(e.target.value)}
|
onChange={(e) => setNewDbName(e.target.value)}
|
||||||
disabled={isCreatingDb}
|
disabled={isProcessing}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') handleCreateDatabase();
|
if (e.key === 'Enter') handleCreateDatabase();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ p: 2, pt: 0 }}>
|
<DialogActions sx={{ p: 2, pt: 0 }}>
|
||||||
<Button onClick={() => setOpenNewDbDialog(false)} disabled={isCreatingDb}>Cancel</Button>
|
<Button onClick={() => setOpenNewDbDialog(false)} disabled={isProcessing}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateDatabase}
|
onClick={handleCreateDatabase}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={isCreatingDb || !newDbName.trim()}
|
disabled={isProcessing || !newDbName.trim()}
|
||||||
>
|
>
|
||||||
{isCreatingDb ? <CircularProgress size={24} /> : 'Create'}
|
{isProcessing ? <CircularProgress size={24} /> : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Rename Database Dialog */}
|
||||||
|
<Dialog open={openRenameDialog} onClose={() => setOpenRenameDialog(false)} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 800 }}>Rename Database</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
||||||
|
Renaming database <strong>{dbToRename}</strong>.
|
||||||
|
This will create a new database and move all tables.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="New Database Name"
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={renamedDbName}
|
||||||
|
onChange={(e) => setRenamedDbName(e.target.value)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleRenameDatabase();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2, pt: 0 }}>
|
||||||
|
<Button onClick={() => setOpenRenameDialog(false)} disabled={isProcessing}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleRenameDatabase}
|
||||||
|
variant="contained"
|
||||||
|
disabled={isProcessing || !renamedDbName.trim() || renamedDbName === dbToRename}
|
||||||
|
>
|
||||||
|
{isProcessing ? <CircularProgress size={24} /> : 'Rename'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Database Confirm */}
|
||||||
|
<Dialog open={openDeleteConfirm} onClose={() => setOpenDeleteConfirm(false)}>
|
||||||
|
<DialogTitle sx={{ fontWeight: 800, color: 'error.main' }}>Delete Database?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Are you sure you want to delete database <strong>{dbToDelete}</strong>?
|
||||||
|
This action cannot be undone and all data will be lost.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2, pt: 0 }}>
|
||||||
|
<Button onClick={() => setOpenDeleteConfirm(false)} disabled={isProcessing}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteDatabase}
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? <CircularProgress size={24} color="inherit" /> : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Database Item Menu */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchorEl}
|
||||||
|
open={Boolean(menuAnchorEl)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleMenuRename}>
|
||||||
|
<ListItemIcon><Edit fontSize="small" /></ListItemIcon>
|
||||||
|
<ListItemText>Rename</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleMenuDelete} sx={{ color: 'error.main' }}>
|
||||||
|
<ListItemIcon><Delete fontSize="small" sx={{ color: 'error.main' }} /></ListItemIcon>
|
||||||
|
<ListItemText>Delete</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
{/* Local Notification */}
|
{/* Local Notification */}
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={notification.open}
|
open={notification.open}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export default api;
|
|||||||
export const SchemaService = {
|
export const SchemaService = {
|
||||||
getDatabases: () => api.get('/schema/databases'),
|
getDatabases: () => api.get('/schema/databases'),
|
||||||
createDatabase: (name: string, charset?: string, collation?: string) => api.post('/schema/databases', { name, charset, collation }),
|
createDatabase: (name: string, charset?: string, collation?: string) => api.post('/schema/databases', { name, charset, collation }),
|
||||||
|
dropDatabase: (db: string) => api.delete(`/schema/databases/${db}`, { params: { database: db } }),
|
||||||
|
renameDatabase: (db: string, newName: string) => api.post(`/schema/databases/${db}/rename`, { newName }, { params: { database: db } }),
|
||||||
getTables: (db: string) => api.get(`/schema/tables/${db}`, { params: { database: db } }),
|
getTables: (db: string) => api.get(`/schema/tables/${db}`, { params: { database: db } }),
|
||||||
getDatabaseMetadata: (db: string) => api.get(`/schema/metadata/${db}`, { params: { database: db } }),
|
getDatabaseMetadata: (db: string) => api.get(`/schema/metadata/${db}`, { params: { database: db } }),
|
||||||
getTablesMetadata: (db: string) => api.get(`/schema/metadata/${db}/tables`, { params: { database: db } }),
|
getTablesMetadata: (db: string) => api.get(`/schema/metadata/${db}/tables`, { params: { database: db } }),
|
||||||
|
|||||||
Reference in New Issue
Block a user