feat: implement database schema discovery and management services with frontend integration

This commit is contained in:
Ümit Tunç
2026-04-24 13:46:38 +03:00
parent 53b40e95e5
commit a447d5a08e
8 changed files with 372 additions and 11 deletions
@@ -48,4 +48,34 @@ interface DatabaseDriverInterface
* Get database metadata (charset, collation, size, etc.)
*/
public function getDatabaseMetadata(string $database): array;
/**
* Get table metadata.
*/
public function getTableMetadata(string $database, string $table): array;
/**
* Get metadata for all tables in a database.
*/
public function getTablesMetadata(string $database): array;
/**
* Truncate a table.
*/
public function truncateTable(string $table): bool;
/**
* Drop a table.
*/
public function dropTable(string $table): bool;
/**
* Optimize a table.
*/
public function optimizeTable(string $table): bool;
/**
* Perform batch update on a table.
*/
public function batchUpdate(string $table, array $changes): bool;
}
@@ -248,4 +248,19 @@ class SchemaController extends Controller
return Response::json(['error' => $e->getMessage()], 400);
}
}
public function batchUpdate(Request $request, $table)
{
$request->validate([
'changes' => 'required|array',
]);
try {
$this->initializeDriver($request);
$this->databaseService->batchUpdate($table, $request->changes);
return Response::json(['message' => 'Batch update successful']);
} catch (\Exception $e) {
return Response::json(['error' => $e->getMessage()], 400);
}
}
}
@@ -302,4 +302,41 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface
return $this->query($sql, [$database]);
}
public function batchUpdate(string $table, array $changes): bool
{
$connection = $this->getConnection();
// Find primary key
$schema = $this->getTableSchema($table);
$primaryKey = 'id'; // default
foreach ($schema as $col) {
if (($col->Key ?? '') === 'PRI') {
$primaryKey = $col->Field;
break;
}
}
$connection->beginTransaction();
try {
foreach ($changes as $change) {
if (!isset($change[$primaryKey])) {
continue;
}
$id = $change[$primaryKey];
$updateData = $change;
unset($updateData[$primaryKey]);
if (empty($updateData)) continue;
$connection->table($table)->where($primaryKey, $id)->update($updateData);
}
$connection->commit();
return true;
} catch (\Exception $e) {
$connection->rollBack();
throw $e;
}
}
}
+8
View File
@@ -156,4 +156,12 @@ class DatabaseService
{
return $this->getDriver()->getTablesMetadata($database);
}
/**
* Perform batch update on a table.
*/
public function batchUpdate(string $table, array $changes): bool
{
return $this->getDriver()->batchUpdate($table, $changes);
}
}
+1
View File
@@ -20,6 +20,7 @@ Route::prefix('schema')->group(function () {
Route::post('/bulk-action', [SchemaController::class, 'bulkAction']);
Route::get('/{table}', [SchemaController::class, 'schema']);
Route::get('/{table}/data', [SchemaController::class, 'data']);
Route::post('/{table}/batch-update', [SchemaController::class, 'batchUpdate']);
Route::post('/execute', [SchemaController::class, 'execute']);
Route::post('/export', [SchemaController::class, 'export']);
Route::post('/import', [SchemaController::class, 'import']);
+274 -10
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Paper,
@@ -8,17 +8,21 @@ import {
Alert,
AlertTitle,
Tabs,
Tab
Tab,
Button
} from '@mui/material';
import {
TableChart,
Terminal,
CloudDownload,
CloudUpload,
Info
Info,
Save,
Undo
} from '@mui/icons-material';
import { DataGrid, type GridPaginationModel } from '@mui/x-data-grid';
import { DataGrid, type GridPaginationModel, type GridRowModel } from '@mui/x-data-grid';
import { useAppStore } from '../store/useAppStore';
import { SchemaService } from '../services/api';
import TransferContent from './TransferContent';
import DatabaseTablesGrid from './DatabaseTablesGrid';
import TechnicalOverview from './TechnicalOverview';
@@ -42,11 +46,180 @@ const MainContent: React.FC = () => {
severity: 'error'
});
const { columns, loading: loadingSchema } = useTableSchema(activeTable, activeDatabase);
const { rows, rowCount, loading: loadingData } = useTableData(activeTable, activeDatabase, paginationModel);
const [pendingChanges, setPendingChanges] = useState<Record<string, any>>({});
const [isSaving, setIsSaving] = useState(false);
const [cellFocus, setCellFocus] = useState<{ id: string | number, field: string } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<{ id: string | number, field: string } | null>(null);
const [dragCurrent, setDragCurrent] = useState<{ id: string | number, field: string } | null>(null);
const { columns, loading: loadingSchema, primaryKey } = useTableSchema(activeTable, activeDatabase);
const { rows, rowCount, loading: loadingData, refetch } = useTableData(activeTable, activeDatabase, paginationModel);
const handleRowUpdate = (newRow: GridRowModel, oldRow: GridRowModel) => {
const hasChanged = Object.keys(newRow).some(key => newRow[key] !== oldRow[key]);
if (hasChanged) {
const rowId = newRow[primaryKey];
setPendingChanges(prev => ({
...prev,
[rowId]: {
...(prev[rowId] || {}),
...newRow
}
}));
}
return newRow;
};
const handleBatchSave = async () => {
if (!activeTable) return;
setIsSaving(true);
try {
const changes = Object.values(pendingChanges);
await SchemaService.batchUpdate(activeTable, changes);
setNotification({
open: true,
title: 'Batch Update Success',
message: `Successfully updated ${changes.length} records.`,
severity: 'success'
});
setPendingChanges({});
refetch(); // Refresh data from server to be sure
} catch (error: any) {
setNotification({
open: true,
title: 'Update Failed',
message: error.response?.data?.error || error.message,
severity: 'error'
});
} finally {
setIsSaving(false);
}
};
const handlePaste = useCallback((event: React.ClipboardEvent) => {
if (!cellFocus || !activeTable) return;
const pasteData = event.clipboardData.getData('text');
const rows_data = pasteData.split(/\r?\n/).filter(line => line.length > 0).map(line => line.split('\t'));
if (rows_data.length === 0) return;
const updatedChanges = { ...pendingChanges };
const focusedRowIndex = rows.findIndex(r => r[primaryKey] === cellFocus.id);
const focusedColIndex = columns.findIndex(c => c.field === cellFocus.field);
if (focusedRowIndex === -1 || focusedColIndex === -1) return;
rows_data.forEach((rowData, rIdx) => {
const targetRowIndex = focusedRowIndex + rIdx;
if (targetRowIndex >= rows.length) return;
const targetRow = rows[targetRowIndex];
const targetRowId = targetRow[primaryKey];
const newRowData = { ...(updatedChanges[targetRowId] || {}), [primaryKey]: targetRowId };
rowData.forEach((cellData, cIdx) => {
const targetColIndex = focusedColIndex + cIdx;
if (targetColIndex >= columns.length) return;
const targetCol = columns[targetColIndex];
if (targetCol.editable !== false && targetCol.field !== primaryKey) {
newRowData[targetCol.field] = cellData;
}
});
updatedChanges[targetRowId] = newRowData;
});
setPendingChanges(updatedChanges);
event.preventDefault();
}, [cellFocus, rows, columns, primaryKey, pendingChanges, activeTable]);
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
// Excel Ctrl+D support (Fill Down)
if (event.ctrlKey && event.key === 'd') {
if (!cellFocus || !activeTable) return;
const focusedRowIndex = rows.findIndex(r => r[primaryKey] === cellFocus.id);
if (focusedRowIndex === -1 || focusedRowIndex + 1 >= rows.length) return;
const valueToCopy = (pendingChanges[cellFocus.id]?.[cellFocus.field]) ?? rows[focusedRowIndex][cellFocus.field];
const nextRow = rows[focusedRowIndex + 1];
const nextRowId = nextRow[primaryKey];
setPendingChanges(prev => ({
...prev,
[nextRowId]: {
...(prev[nextRowId] || {}),
[primaryKey]: nextRowId,
[cellFocus.field]: valueToCopy
}
}));
event.preventDefault();
}
}, [cellFocus, rows, pendingChanges, primaryKey, activeTable]);
const handleCellMouseDown = useCallback((params: any, event: React.MouseEvent) => {
// Only start drag if clicking near the bottom-right corner (simulating fill handle)
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Check if click is in the bottom-right corner (approx 10x10px)
if (x > rect.width - 12 && y > rect.height - 12) {
setIsDragging(true);
setDragStart({ id: params.id, field: params.field });
setDragCurrent({ id: params.id, field: params.field });
event.stopPropagation();
event.preventDefault();
}
}, []);
const handleCellMouseEnter = useCallback((params: any) => {
if (isDragging) {
setDragCurrent({ id: params.id, field: params.field });
}
}, [isDragging]);
const handleMouseUp = useCallback(() => {
if (isDragging && dragStart && dragCurrent && dragStart.field === dragCurrent.field) {
const startIndex = rows.findIndex(r => r[primaryKey] === dragStart.id);
const endIndex = rows.findIndex(r => r[primaryKey] === dragCurrent.id);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const valueToFill = (pendingChanges[dragStart.id]?.[dragStart.field]) ?? rows[startIndex][dragStart.field];
const updatedChanges = { ...pendingChanges };
for (let i = start; i <= end; i++) {
const targetRowId = rows[i][primaryKey];
updatedChanges[targetRowId] = {
...(updatedChanges[targetRowId] || {}),
[primaryKey]: targetRowId,
[dragStart.field]: valueToFill
};
}
setPendingChanges(updatedChanges);
}
}
setIsDragging(false);
setDragStart(null);
setDragCurrent(null);
}, [isDragging, dragStart, dragCurrent, rows, primaryKey, pendingChanges]);
useEffect(() => {
window.addEventListener('mouseup', handleMouseUp);
return () => window.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseUp]);
useEffect(() => {
setPaginationModel({ page: 0, pageSize: 100 });
setPendingChanges({});
}, [activeTable]);
const handleCloseNotification = () => setNotification({ ...notification, open: false });
@@ -116,6 +289,33 @@ const MainContent: React.FC = () => {
<Typography variant="caption" sx={{ fontWeight: 700 }}>{rowCount} rows found</Typography>
</Box>
</Box>
{Object.keys(pendingChanges).length > 0 && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
color="success"
size="small"
startIcon={<Save />}
onClick={handleBatchSave}
disabled={isSaving}
sx={{ borderRadius: 2, fontWeight: 700 }}
>
{isSaving ? <CircularProgress size={20} color="inherit" /> : `Save ${Object.keys(pendingChanges).length} Changes`}
</Button>
<Button
variant="outlined"
color="inherit"
size="small"
startIcon={<Undo />}
onClick={() => setPendingChanges({})}
disabled={isSaving}
sx={{ borderRadius: 2 }}
>
Discard
</Button>
</Box>
)}
</Box>
<Paper sx={{
@@ -135,7 +335,11 @@ const MainContent: React.FC = () => {
</Box>
) : (
<DataGrid
rows={rows}
rows={rows.map(row => ({
...row,
...(pendingChanges[row[primaryKey]] || {})
}))}
getRowId={(row) => row[primaryKey]}
columns={columns}
rowCount={rowCount}
loading={loadingData}
@@ -143,14 +347,43 @@ const MainContent: React.FC = () => {
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[25, 50, 100]}
processRowUpdate={handleRowUpdate}
onProcessRowUpdateError={(error) => {
console.error('Row update error:', error);
}}
onCellFocusIn={(params) => setCellFocus({ id: params.id, field: params.field })}
onCellMouseDown={handleCellMouseDown}
onCellMouseEnter={handleCellMouseEnter}
slotProps={{
root: {
onPaste: handlePaste,
onKeyDown: handleKeyDown,
}
}}
density="compact"
sx={{
border: 'none',
'& .MuiDataGrid-columnHeaderTitle': {
fontWeight: 800,
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.5px'
},
'& .MuiDataGrid-cell': {
fontSize: '0.8rem',
px: 1,
borderRight: '1px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-row': {
minHeight: '32px !important',
maxHeight: '32px !important',
},
'& .MuiDataGrid-row:nth-of-type(even)': {
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
},
'& .MuiDataGrid-cell': {
borderRight: '1px solid',
borderColor: 'divider',
'& .MuiDataGrid-cell--editable': {
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0, 255, 0, 0.02)' : 'rgba(0, 255, 0, 0.01)',
},
'& .MuiDataGrid-columnHeader': {
borderRight: '1px solid',
@@ -161,6 +394,37 @@ const MainContent: React.FC = () => {
borderColor: 'divider',
},
'& .MuiDataGrid-row:hover': { bgcolor: 'rgba(0, 97, 255, 0.04)' },
'& .MuiDataGrid-cell:focus-within': {
outline: '2px solid #0061ff',
outlineOffset: '-2px',
position: 'relative',
'&::after': {
content: '""',
position: 'absolute',
bottom: -1,
right: -1,
width: 8,
height: 8,
bgcolor: '#0061ff',
border: '1px solid white',
cursor: 'crosshair',
zIndex: 10,
}
},
'& .MuiDataGrid-cell--dragging': {
bgcolor: 'rgba(0, 97, 255, 0.08) !important',
}
}}
getCellClassName={(params) => {
if (isDragging && dragStart && dragCurrent && params.field === dragStart.field) {
const startIndex = rows.findIndex(r => r[primaryKey] === dragStart.id);
const endIndex = rows.findIndex(r => r[primaryKey] === dragCurrent.id);
const currentIndex = rows.findIndex(r => r[primaryKey] === params.id);
if (currentIndex >= Math.min(startIndex, endIndex) && currentIndex <= Math.max(startIndex, endIndex)) {
return 'MuiDataGrid-cell--dragging';
}
}
return '';
}}
/>
)}
+6 -1
View File
@@ -14,6 +14,7 @@ const mapSqlTypeToMuiType = (sqlType: string): 'string' | 'number' | 'date' | 'd
export const useTableSchema = (activeTable: string | null, activeDatabase: string | null) => {
const [columns, setColumns] = useState<GridColDef[]>([]);
const [loading, setLoading] = useState(false);
const [primaryKey, setPrimaryKey] = useState<string>('id');
useEffect(() => {
const fetchSchema = async () => {
@@ -22,7 +23,9 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin
setLoading(true);
try {
const schemaRes = await SchemaService.getTableSchema(activeTable, activeDatabase);
let pk = 'id';
const cols: GridColDef[] = schemaRes.data.map((col: any) => {
if (col.Key === 'PRI') pk = col.Field;
const type = mapSqlTypeToMuiType(col.Type);
return {
field: col.Field,
@@ -30,6 +33,7 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin
type: type,
width: 200,
minWidth: 100,
editable: col.Key !== 'PRI', // Enable editing if not primary key
valueGetter: (value: any) => {
if ((type === 'date' || type === 'dateTime') && value && typeof value === 'string') {
return new Date(value);
@@ -39,6 +43,7 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin
};
});
setColumns(cols);
setPrimaryKey(pk);
} catch (error) {
console.error('Failed to fetch table schema', error);
} finally {
@@ -49,5 +54,5 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin
fetchSchema();
}, [activeTable, activeDatabase]);
return { columns, loading };
return { columns, loading, primaryKey };
};
+1
View File
@@ -34,6 +34,7 @@ export const SchemaService = {
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),
batchUpdate: (table: string, changes: any[]) => api.post(`/schema/${table}/batch-update`, { changes }),
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, {