From 5d3afeadf27152eabecce289a9343f0d09289e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Cmit=20Tun=C3=A7?= Date: Fri, 24 Apr 2026 13:14:34 +0300 Subject: [PATCH] feat: implement MySQL driver for database management, schema discovery, and CRUD operations --- .../Http/Controllers/Api/SchemaController.php | 61 +++ backend/app/Services/Database/MySqlDriver.php | 12 + backend/app/Services/DatabaseService.php | 10 + backend/routes/api.php | 3 + frontend/src/components/MainContent.tsx | 509 +++++++++++------- frontend/src/services/api.ts | 5 +- 6 files changed, 392 insertions(+), 208 deletions(-) diff --git a/backend/app/Http/Controllers/Api/SchemaController.php b/backend/app/Http/Controllers/Api/SchemaController.php index 65dc5cd..f477cd3 100644 --- a/backend/app/Http/Controllers/Api/SchemaController.php +++ b/backend/app/Http/Controllers/Api/SchemaController.php @@ -177,6 +177,67 @@ class SchemaController extends Controller } } + public function drop(Request $request, $table) + { + try { + $this->initializeDriver($request); + $this->databaseService->dropTable($table); + return Response::json(['message' => "Table '{$table}' dropped successfully"]); + } catch (\Exception $e) { + return Response::json(['error' => $e->getMessage()], 400); + } + } + + public function optimize(Request $request, $table) + { + try { + $this->initializeDriver($request); + $this->databaseService->optimizeTable($table); + return Response::json(['message' => "Table '{$table}' optimized successfully"]); + } catch (\Exception $e) { + return Response::json(['error' => $e->getMessage()], 400); + } + } + + public function bulkAction(Request $request) + { + $request->validate([ + 'tables' => 'required|array', + 'action' => 'required|string|in:truncate,drop,optimize', + 'database' => 'required|string' + ]); + + try { + $this->initializeDriver($request); + $tables = $request->tables; + $action = $request->action; + $results = []; + + foreach ($tables as $table) { + try { + switch ($action) { + case 'truncate': + $this->databaseService->truncateTable($table); + break; + case 'drop': + $this->databaseService->dropTable($table); + break; + case 'optimize': + $this->databaseService->optimizeTable($table); + break; + } + $results[$table] = 'success'; + } catch (\Exception $e) { + $results[$table] = 'error: ' . $e->getMessage(); + } + } + + return Response::json(['message' => 'Bulk operation completed', 'results' => $results]); + } catch (\Exception $e) { + return Response::json(['error' => $e->getMessage()], 400); + } + } + public function tablesMetadata(Request $request, $database) { try { diff --git a/backend/app/Services/Database/MySqlDriver.php b/backend/app/Services/Database/MySqlDriver.php index a066724..00627b4 100644 --- a/backend/app/Services/Database/MySqlDriver.php +++ b/backend/app/Services/Database/MySqlDriver.php @@ -265,6 +265,18 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface return true; } + public function dropTable(string $table): bool + { + DB::connection($this->connectionName)->statement("DROP TABLE `{$table}`"); + return true; + } + + public function optimizeTable(string $table): bool + { + DB::connection($this->connectionName)->statement("OPTIMIZE TABLE `{$table}`"); + return true; + } + public function getTablesMetadata(string $database): array { $sql = " diff --git a/backend/app/Services/DatabaseService.php b/backend/app/Services/DatabaseService.php index 935fcfb..ae534f1 100644 --- a/backend/app/Services/DatabaseService.php +++ b/backend/app/Services/DatabaseService.php @@ -139,6 +139,16 @@ class DatabaseService return $this->getDriver()->truncateTable($table); } + public function dropTable(string $table): bool + { + return $this->getDriver()->dropTable($table); + } + + public function optimizeTable(string $table): bool + { + return $this->getDriver()->optimizeTable($table); + } + /** * Get metadata for all tables in a database. */ diff --git a/backend/routes/api.php b/backend/routes/api.php index a23df24..d9039e1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -15,6 +15,9 @@ Route::prefix('schema')->group(function () { Route::get('/metadata/{database}/tables', [SchemaController::class, 'tablesMetadata']); Route::get('/metadata/{database}/{table}', [SchemaController::class, 'tableMetadata']); Route::post('/truncate/{table}', [SchemaController::class, 'truncate']); + Route::post('/drop/{table}', [SchemaController::class, 'drop']); + Route::post('/optimize/{table}', [SchemaController::class, 'optimize']); + Route::post('/bulk-action', [SchemaController::class, 'bulkAction']); Route::get('/{table}', [SchemaController::class, 'schema']); Route::get('/{table}/data', [SchemaController::class, 'data']); Route::post('/execute', [SchemaController::class, 'execute']); diff --git a/frontend/src/components/MainContent.tsx b/frontend/src/components/MainContent.tsx index 8013357..90d16f2 100644 --- a/frontend/src/components/MainContent.tsx +++ b/frontend/src/components/MainContent.tsx @@ -12,7 +12,9 @@ import { Alert, AlertTitle, Tabs, - Tab + Tab, + Stack, + Checkbox } from '@mui/material'; import { PlayArrow, @@ -34,6 +36,302 @@ import { SchemaService } from '../services/api'; import TransferContent from './TransferContent'; import ConfirmDialog from './ConfirmDialog'; +interface MainNotification { + open: boolean; + message: string; + title: string; + severity: 'success' | 'error' | 'info' | 'warning'; +} + +const DatabaseTablesGrid = ({ database, setNotification }: { database: string, setNotification: (n: MainNotification) => void }) => { + const [tableRows, setTableRows] = useState([]); + const [loading, setLoading] = useState(true); + const [selectionModel, setSelectionModel] = useState([]); + const { setActiveTable } = useAppStore(); + + const fetchTablesMeta = useCallback(async () => { + setLoading(true); + try { + const res = await SchemaService.getTablesMetadata(database); + setTableRows(res.data); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }, [database]); + + useEffect(() => { + fetchTablesMeta(); + }, [fetchTablesMeta]); + + const handleBulkAction = async (action: 'truncate' | 'drop' | 'optimize') => { + if (selectionModel.length === 0) return; + + const confirmMsg = `Are you sure you want to ${action} ${selectionModel.length} selected tables? This action is irreversible!`; + if (!window.confirm(confirmMsg)) return; + + setLoading(true); + try { + await SchemaService.bulkAction({ + tables: selectionModel, + action, + database + }); + setNotification({ + open: true, + title: 'Bulk Action Success', + message: `Successfully performed ${action} on ${selectionModel.length} tables.`, + severity: 'success' + }); + fetchTablesMeta(); + setSelectionModel([]); + } catch (error: any) { + setNotification({ + open: true, + title: 'Bulk Action Error', + message: error.response?.data?.error || error.message, + severity: 'error' + }); + } finally { + setLoading(false); + } + }; + + const toggleRow = (name: string) => { + setSelectionModel(prev => + prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name] + ); + }; + + const toggleAll = () => { + if (selectionModel.length === tableRows.length) { + setSelectionModel([]); + } else { + setSelectionModel(tableRows.map(r => r.name)); + } + }; + + const columns: GridColDef[] = [ + { + field: '__check__', + headerName: '', + width: 50, + sortable: false, + filterable: false, + renderHeader: () => ( + 0 && selectionModel.length < tableRows.length} + checked={tableRows.length > 0 && selectionModel.length === tableRows.length} + onChange={toggleAll} + /> + ), + renderCell: (params) => ( + { + e.stopPropagation(); + toggleRow(params.row.name); + }} + /> + ) + }, + { + field: 'name', + headerName: 'Table Name', + flex: 1, + minWidth: 200, + renderCell: (params) => ( + + ) + }, + { field: 'engine', headerName: 'Engine', width: 120 }, + { field: 'rows', headerName: 'Rows', type: 'number', width: 120 }, + { + field: 'data_length', + headerName: 'Data Size', + width: 130, + valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB` + }, + { + field: 'index_length', + headerName: 'Index Size', + width: 130, + valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB` + }, + { field: 'collation', headerName: 'Collation', width: 180 }, + { field: 'comment', headerName: 'Comment', flex: 1, minWidth: 150 }, + ]; + + return ( + + {selectionModel.length > 0 && ( + + + {selectionModel.length} tables selected: + + + + + + + + + + )} + + row.name} + density="comfortable" + sx={{ border: 'none' }} + /> + + + ); +}; + +const TechnicalOverview = ({ database, table, setNotification }: { database: string, table: string | null, setNotification: (n: MainNotification) => void }) => { + const [meta, setMeta] = useState(null); + const [loadingMeta, setLoadingMeta] = useState(true); + const [truncating, setTruncating] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + const fetchMeta = useCallback(async () => { + setLoadingMeta(true); + try { + const res = table + ? await SchemaService.getTableMetadata(database, table) + : await SchemaService.getDatabaseMetadata(database); + setMeta(res.data); + } catch (e) { + console.error(e); + } finally { + setLoadingMeta(false); + } + }, [database, table]); + + useEffect(() => { + fetchMeta(); + }, [fetchMeta]); + + const handleTruncate = async () => { + setTruncating(true); + setShowConfirm(false); + try { + await SchemaService.truncateTable(table!); + setNotification({ + open: true, + title: 'Success', + message: `Table "${table}" has been truncated.`, + severity: 'success' + }); + fetchMeta(); + } catch (error: any) { + setNotification({ + open: true, + title: 'Truncate Error', + message: error.response?.data?.error || error.message, + severity: 'error' + }); + } finally { + setTruncating(false); + } + }; + + if (loadingMeta) return ; + if (!meta) return Could not load metadata; + + const stats = table ? [ + { label: 'Engine', value: meta.engine, icon: }, + { label: 'Rows', value: meta.rows, icon: }, + { label: 'Collation', value: meta.collation, icon: }, + { label: 'Data Size', value: `${(meta.data_length / 1024 / 1024).toFixed(2)} MB`, icon: }, + { label: 'Index Size', value: `${(meta.index_length / 1024 / 1024).toFixed(2)} MB`, icon: }, + { label: 'Created', value: meta.create_time, icon: }, + ] : [ + { label: 'Character Set', value: meta.charset, icon: }, + { label: 'Collation', value: meta.collation, icon: }, + { label: 'Tables', value: meta.table_count, icon: }, + { label: 'Views', value: meta.view_count, icon: }, + { label: 'Total Size', value: `${(meta.size_bytes / 1024 / 1024).toFixed(2)} MB`, icon: }, + ]; + + return ( + + + + Technical Specifications: {table ? `${database}.${table}` : database} + + {table && ( + + )} + + + setShowConfirm(false)} + onConfirm={handleTruncate} + title="Truncate Table" + message={`Are you sure you want to truncate table "${table}"? This action will permanently delete all ${meta?.rows || ''} records. This cannot be undone.`} + confirmLabel="Truncate Now" + loading={truncating} + /> + + {stats.map((stat, i) => ( + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)', + transition: 'all 0.2s', + '&:hover': { borderColor: 'primary.main', transform: 'translateY(-2px)' } + }}> + + {stat.label} + {stat.icon} + + {stat.value || 'N/A'} + + ))} + + + ); +}; + const MainContent: React.FC = () => { const { activeTable, activeDatabase, darkMode, dbTab, setDbTab } = useAppStore(); const [columns, setColumns] = useState([]); @@ -49,12 +347,7 @@ const MainContent: React.FC = () => { }); // Custom Notification State - const [notification, setNotification] = useState<{ - open: boolean; - message: string; - title: string; - severity: 'success' | 'error' | 'info' | 'warning' - }>({ + const [notification, setNotification] = useState({ open: false, message: '', title: '', @@ -204,204 +497,6 @@ const MainContent: React.FC = () => { setDbTab(newValue); }; - const TechnicalOverview = ({ database, table }: { database: string, table: string | null }) => { - const [meta, setMeta] = useState(null); - const [loadingMeta, setLoadingMeta] = useState(true); - const [truncating, setTruncating] = useState(false); - const [showConfirm, setShowConfirm] = useState(false); - - const fetchMeta = useCallback(async () => { - setLoadingMeta(true); - try { - const res = table - ? await SchemaService.getTableMetadata(database, table) - : await SchemaService.getDatabaseMetadata(database); - setMeta(res.data); - } catch (e) { - console.error(e); - } finally { - setLoadingMeta(false); - } - }, [database, table]); - - useEffect(() => { - fetchMeta(); - }, [fetchMeta]); - - const handleTruncate = async () => { - setTruncating(true); - setShowConfirm(false); - try { - await SchemaService.truncateTable(table!); - setNotification({ - open: true, - title: 'Success', - message: `Table "${table}" has been truncated.`, - severity: 'success' - }); - fetchMeta(); - } catch (error: any) { - setNotification({ - open: true, - title: 'Truncate Error', - message: error.response?.data?.error || error.message, - severity: 'error' - }); - } finally { - setTruncating(false); - } - }; - - if (loadingMeta) return ; - if (!meta) return Could not load metadata; - - const stats = table ? [ - { label: 'Engine', value: meta.engine, icon: }, - { label: 'Rows', value: meta.rows, icon: }, - { label: 'Collation', value: meta.collation, icon: }, - { label: 'Data Size', value: `${(meta.data_length / 1024 / 1024).toFixed(2)} MB`, icon: }, - { label: 'Index Size', value: `${(meta.index_length / 1024 / 1024).toFixed(2)} MB`, icon: }, - { label: 'Created', value: meta.create_time, icon: }, - ] : [ - { label: 'Character Set', value: meta.charset, icon: }, - { label: 'Collation', value: meta.collation, icon: }, - { label: 'Tables', value: meta.table_count, icon: }, - { label: 'Views', value: meta.view_count, icon: }, - { label: 'Total Size', value: `${(meta.size_bytes / 1024 / 1024).toFixed(2)} MB`, icon: }, - ]; - - return ( - - - - Technical Specifications: {table ? `${database}.${table}` : database} - - {table && ( - - )} - - - setShowConfirm(false)} - onConfirm={handleTruncate} - title="Truncate Table" - message={`Are you sure you want to truncate table "${table}"? This action will permanently delete all ${meta?.rows || ''} records. This cannot be undone.`} - confirmLabel="Truncate Now" - loading={truncating} - /> - - {stats.map((stat, i) => ( - theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)', - transition: 'all 0.2s', - '&:hover': { borderColor: 'primary.main', transform: 'translateY(-2px)' } - }}> - - {stat.label} - {stat.icon} - - {stat.value || 'N/A'} - - ))} - - - ); - }; - - const DatabaseTablesGrid = ({ database }: { database: string }) => { - const [tableRows, setTableRows] = useState([]); - const [loading, setLoading] = useState(true); - const { setActiveTable } = useAppStore(); - - useEffect(() => { - const fetchTablesMeta = async () => { - setLoading(true); - try { - const res = await SchemaService.getTablesMetadata(database); - setTableRows(res.data.map((r: any, i: number) => ({ id: r.name || i, ...r }))); - } catch (e) { - console.error(e); - } finally { - setLoading(false); - } - }; - fetchTablesMeta(); - }, [database]); - - const columns: GridColDef[] = [ - { - field: 'name', - headerName: 'Table Name', - flex: 1, - minWidth: 200, - renderCell: (params) => ( - - ) - }, - { field: 'engine', headerName: 'Engine', width: 120 }, - { field: 'rows', headerName: 'Rows', type: 'number', width: 120 }, - { - field: 'data_length', - headerName: 'Data Size', - width: 130, - valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB` - }, - { - field: 'index_length', - headerName: 'Index Size', - width: 130, - valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB` - }, - { field: 'collation', headerName: 'Collation', width: 180 }, - { field: 'comment', headerName: 'Comment', flex: 1, minWidth: 150 }, - ]; - - return ( - - - - ); - }; - return ( {/* Database Navigation Tabs */} @@ -444,7 +539,7 @@ const MainContent: React.FC = () => { {!activeTable ? ( Database Tables: {activeDatabase} - + ) : ( <> @@ -546,7 +641,7 @@ const MainContent: React.FC = () => { {dbTab === 'export' && } {/* Technical Info View */} - {dbTab === 'info' && } + {dbTab === 'info' && } {/* Custom Notification (Snackbar) */} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 16d1fe2..7d30c40 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -30,7 +30,10 @@ export const SchemaService = { getTableMetadata: (db: string, table: string) => api.get(`/schema/metadata/${db}/${table}`, { params: { database: db } }), getTableSchema: (table: string, database?: string) => api.get(`/schema/${table}`, { params: { database } }), getTableData: (table: string, params: any) => api.get(`/schema/${table}/data`, { params }), - truncateTable: (table: string) => api.post(`/schema/truncate/${table}`), + truncateTable: (table: string, database?: string) => api.post(`/schema/truncate/${table}`, { database }), + dropTable: (table: string, database?: string) => api.post(`/schema/drop/${table}`, { database }), + optimizeTable: (table: string, database?: string) => api.post(`/schema/optimize/${table}`, { database }), + bulkAction: (data: { tables: string[], action: string, database: string }) => api.post('/schema/bulk-action', data), executeQuery: (query: string) => api.post('/schema/execute', { query }), exportDatabase: (database?: string, table?: string) => api.post('/schema/export', { database, table }, { responseType: 'blob' }), importDatabase: (formData: FormData) => api.post('/schema/import', formData, {