From 23f8eeb560506c4025ee6bf23eb8dc36e81a36d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Cmit=20Tun=C3=A7?= Date: Fri, 24 Apr 2026 12:52:01 +0300 Subject: [PATCH] feat: implement dynamic database management via MySQL driver and API controllers --- .../Http/Controllers/Api/SchemaController.php | 24 +++++- backend/app/Services/Database/MySqlDriver.php | 39 ++++++++- backend/app/Services/DatabaseService.php | 16 ++++ backend/routes/api.php | 2 + frontend/src/components/MainContent.tsx | 84 +++++++++++++++---- frontend/src/components/TransferContent.tsx | 24 +++--- frontend/src/services/api.ts | 4 +- frontend/src/store/useAppStore.ts | 4 +- 8 files changed, 164 insertions(+), 33 deletions(-) diff --git a/backend/app/Http/Controllers/Api/SchemaController.php b/backend/app/Http/Controllers/Api/SchemaController.php index a8f6e7b..9b4a9c4 100644 --- a/backend/app/Http/Controllers/Api/SchemaController.php +++ b/backend/app/Http/Controllers/Api/SchemaController.php @@ -109,7 +109,7 @@ class SchemaController extends Controller { try { $this->initializeDriver($request); - $config = $request->only(['host', 'username', 'password', 'database', 'port']); + $config = $request->only(['host', 'username', 'password', 'database', 'port', 'table']); $filePath = $this->databaseService->export($config); return Response::download($filePath)->deleteFileAfterSend(true); @@ -154,4 +154,26 @@ class SchemaController extends Controller return Response::json(['error' => $e->getMessage()], 400); } } + + public function tableMetadata(Request $request, $database, $table) + { + try { + $request->merge(['database' => $database]); + $this->initializeDriver($request); + return Response::json($this->databaseService->getTableMetadata($database, $table)); + } catch (\Exception $e) { + return Response::json(['error' => $e->getMessage()], 400); + } + } + + public function truncate(Request $request, $table) + { + try { + $this->initializeDriver($request); + $this->databaseService->truncateTable($table); + return Response::json(['message' => "Table '{$table}' truncated successfully"]); + } catch (\Exception $e) { + return Response::json(['error' => $e->getMessage()], 400); + } + } } diff --git a/backend/app/Services/Database/MySqlDriver.php b/backend/app/Services/Database/MySqlDriver.php index e60664e..797ce15 100644 --- a/backend/app/Services/Database/MySqlDriver.php +++ b/backend/app/Services/Database/MySqlDriver.php @@ -114,10 +114,16 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface $host = $config['host'] ?? '127.0.0.1'; $port = $config['port'] ?? '3306'; $database = $config['database'] ?? ''; + $table = $config['table'] ?? ''; // Build command with flags for a complete and resilient export $passwordPart = !empty($password) ? "-p" . escapeshellarg($password) : ""; - $dbPart = !empty($database) ? escapeshellarg($database) : "--all-databases"; + + if (!empty($table) && !empty($database)) { + $dbPart = escapeshellarg($database) . " " . escapeshellarg($table); + } else { + $dbPart = !empty($database) ? escapeshellarg($database) : "--all-databases"; + } // --single-transaction: for InnoDB tables, ensures consistency without locking // --skip-lock-tables: avoids issues with views/definers that might prevent locking @@ -225,4 +231,35 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface $results = $this->query($sql, [$database]); return (array) ($results[0] ?? []); } + + public function getTableMetadata(string $database, string $table): array + { + $sql = " + SELECT + ENGINE as engine, + TABLE_ROWS as rows, + DATA_LENGTH as data_length, + INDEX_LENGTH as index_length, + DATA_FREE as data_free, + AUTO_INCREMENT as auto_increment, + CREATE_TIME as create_time, + UPDATE_TIME as update_time, + TABLE_COLLATION as collation, + TABLE_COMMENT as comment + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = ? AND + TABLE_NAME = ? + "; + + $results = $this->query($sql, [$database, $table]); + return (array) ($results[0] ?? []); + } + + public function truncateTable(string $table): bool + { + DB::connection($this->connectionName)->statement("TRUNCATE TABLE `{$table}`"); + return true; + } } diff --git a/backend/app/Services/DatabaseService.php b/backend/app/Services/DatabaseService.php index 7c2c66a..e49eb6b 100644 --- a/backend/app/Services/DatabaseService.php +++ b/backend/app/Services/DatabaseService.php @@ -122,4 +122,20 @@ class DatabaseService { return $this->getDriver()->getDatabaseMetadata($database); } + + /** + * Get table metadata. + */ + public function getTableMetadata(string $database, string $table): array + { + return $this->getDriver()->getTableMetadata($database, $table); + } + + /** + * Truncate a table. + */ + public function truncateTable(string $table): bool + { + return $this->getDriver()->truncateTable($table); + } } diff --git a/backend/routes/api.php b/backend/routes/api.php index fc47972..1a0dea1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -12,6 +12,8 @@ Route::prefix('schema')->group(function () { Route::get('/databases', [SchemaController::class, 'databases']); Route::get('/tables/{database}', [SchemaController::class, 'tables']); Route::get('/metadata/{database}', [SchemaController::class, 'metadata']); + Route::get('/metadata/{database}/{table}', [SchemaController::class, 'tableMetadata']); + Route::post('/truncate/{table}', [SchemaController::class, 'truncate']); 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 57e1c5c..500038c 100644 --- a/frontend/src/components/MainContent.tsx +++ b/frontend/src/components/MainContent.tsx @@ -196,29 +196,63 @@ const MainContent: React.FC = () => { setDbTab(newValue); }; - const DatabaseOverview = ({ database }: { database: string }) => { + 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 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(() => { - const fetchMeta = async () => { - setLoadingMeta(true); - try { - const res = await SchemaService.getDatabaseMetadata(database); - setMeta(res.data); - } catch (e) { - console.error(e); - } finally { - setLoadingMeta(false); - } - }; fetchMeta(); - }, [database]); + }, [fetchMeta]); + + const handleTruncate = async () => { + if (!table || !window.confirm(`Are you sure you want to truncate table "${table}"? This will delete all data!`)) return; + + setTruncating(true); + try { + await SchemaService.truncateTable(table); + setErrorInfo({ + open: true, + title: 'Success', + message: `Table "${table}" has been truncated.` + }); + fetchMeta(); + } catch (error: any) { + setErrorInfo({ + open: true, + title: 'Truncate Error', + message: error.response?.data?.error || error.message + }); + } finally { + setTruncating(false); + } + }; if (loadingMeta) return ; if (!meta) return Could not load metadata; - const stats = [ + 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: }, @@ -228,7 +262,23 @@ const MainContent: React.FC = () => { return ( - Technical Specifications: {database} + + + Technical Specifications: {table ? `${database}.${table}` : database} + + {table && ( + + )} + {stats.map((stat, i) => ( { {stat.label} {stat.icon} - {stat.value} + {stat.value || 'N/A'} ))} @@ -399,7 +449,7 @@ const MainContent: React.FC = () => { {dbTab === 'export' && } {/* Technical Info View */} - {dbTab === 'info' && } + {dbTab === 'info' && } {/* Custom Alert (Snackbar) */} diff --git a/frontend/src/components/TransferContent.tsx b/frontend/src/components/TransferContent.tsx index 275b1fb..35eb7de 100644 --- a/frontend/src/components/TransferContent.tsx +++ b/frontend/src/components/TransferContent.tsx @@ -26,7 +26,7 @@ interface TransferContentProps { } const TransferContent: React.FC = ({ mode = 'both' }) => { - const { activeDatabase, darkMode } = useAppStore(); + const { activeDatabase, activeTable, darkMode } = useAppStore(); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(null); const [error, setError] = useState(null); @@ -37,15 +37,16 @@ const TransferContent: React.FC = ({ mode = 'both' }) => { setError(null); setSuccess(null); try { - const response = await SchemaService.exportDatabase(activeDatabase || undefined); + const response = await SchemaService.exportDatabase(activeDatabase || undefined, activeTable || undefined); const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement('a'); link.href = url; - const filename = `backup-${activeDatabase || 'all'}-${new Date().toISOString().split('T')[0]}.sql`; + const contextName = activeTable ? `${activeDatabase}-${activeTable}` : (activeDatabase || 'all'); + const filename = `backup-${contextName}-${new Date().toISOString().split('T')[0]}.sql`; link.setAttribute('download', filename); document.body.appendChild(link); link.click(); - setSuccess('Database exported successfully!'); + setSuccess(`${activeTable ? 'Table' : 'Database'} exported successfully!`); } catch (err: any) { setError('Export failed: ' + (err.response?.data?.error || err.message)); } finally { @@ -68,9 +69,10 @@ const TransferContent: React.FC = ({ mode = 'both' }) => { const formData = new FormData(); formData.append('file', file); if (activeDatabase) formData.append('database', activeDatabase); + if (activeTable) formData.append('table', activeTable); await SchemaService.importDatabase(formData); - setSuccess('Database imported successfully!'); + setSuccess(`${activeTable ? 'Table' : 'Database'} imported successfully!`); setFile(null); } catch (err: any) { setError('Import failed: ' + (err.response?.data?.error || err.message)); @@ -83,9 +85,9 @@ const TransferContent: React.FC = ({ mode = 'both' }) => { - Database Transfer + {activeTable ? 'Table' : 'Database'} Transfer - Export your database to a SQL file or import an existing SQL dump using mysqldump. + Export your {activeTable ? 'table' : 'database'} to a SQL file or import an existing SQL dump using mysqldump. @@ -128,9 +130,9 @@ const TransferContent: React.FC = ({ mode = 'both' }) => { - Export Database + Export {activeTable ? 'Table' : 'Database'} - Create a full backup of the current database: {activeDatabase || 'All Databases'} + Create a full backup of the current {activeTable ? 'table' : 'database'}: {activeTable ? `${activeDatabase}.${activeTable}` : (activeDatabase || 'All Databases')}