feat: implement database schema discovery and data browser interface

This commit is contained in:
Ümit Tunç
2026-04-24 07:53:50 +03:00
parent 14abf1223f
commit cd34cc6412
6 changed files with 244 additions and 78 deletions
@@ -84,4 +84,24 @@ class SchemaController extends Controller
return Response::json(['error' => $e->getMessage()], 400); return Response::json(['error' => $e->getMessage()], 400);
} }
} }
public function execute(Request $request)
{
try {
$this->initializeDriver($request);
$sql = $request->get('query');
if (empty($sql)) {
return Response::json(['error' => 'Query is empty'], 400);
}
$results = $this->databaseService->executeQuery($sql);
return Response::json([
'data' => $results,
'count' => count($results)
]);
} catch (\Exception $e) {
return Response::json(['error' => $e->getMessage()], 400);
}
}
} }
+8
View File
@@ -90,4 +90,12 @@ class DatabaseService
{ {
return $this->getDriver()->getTableCount($table); return $this->getDriver()->getTableCount($table);
} }
/**
* Execute a raw SQL query.
*/
public function executeQuery(string $sql, array $bindings = []): array
{
return $this->getDriver()->query($sql, $bindings);
}
} }
+1
View File
@@ -13,4 +13,5 @@ Route::prefix('schema')->group(function () {
Route::get('/tables/{database}', [SchemaController::class, 'tables']); Route::get('/tables/{database}', [SchemaController::class, 'tables']);
Route::get('/{table}', [SchemaController::class, 'schema']); Route::get('/{table}', [SchemaController::class, 'schema']);
Route::get('/{table}/data', [SchemaController::class, 'data']); Route::get('/{table}/data', [SchemaController::class, 'data']);
Route::post('/execute', [SchemaController::class, 'execute']);
}); });
+150 -56
View File
@@ -1,17 +1,20 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { Box, Paper, Typography, CircularProgress } from '@mui/material'; import { Box, Paper, Typography, CircularProgress, Button, Divider, IconButton, Tooltip } from '@mui/material';
import { PlayArrow, History, Save, CleaningServices } from '@mui/icons-material';
import { DataGrid } from '@mui/x-data-grid'; import { DataGrid } from '@mui/x-data-grid';
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid'; import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
import Editor from '@monaco-editor/react';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import { SchemaService } from '../services/api'; import { SchemaService } from '../services/api';
const MainContent: React.FC = () => { const MainContent: React.FC = () => {
const { activeTable, activeDatabase } = useAppStore(); const { activeTable, activeDatabase, darkMode } = useAppStore();
const [columns, setColumns] = useState<GridColDef[]>([]); const [columns, setColumns] = useState<GridColDef[]>([]);
const [rows, setRows] = useState<any[]>([]); const [rows, setRows] = useState<any[]>([]);
const [rowCount, setRowCount] = useState(0); const [rowCount, setRowCount] = useState(0);
const [loadingSchema, setLoadingSchema] = useState(false); const [loadingSchema, setLoadingSchema] = useState(false);
const [loadingData, setLoadingData] = useState(false); const [loadingData, setLoadingData] = useState(false);
const [sqlQuery, setSqlQuery] = useState('');
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({ const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0, page: 0,
pageSize: 100, pageSize: 100,
@@ -27,6 +30,13 @@ const MainContent: React.FC = () => {
return 'string'; return 'string';
}; };
// Set initial SQL query when table changes
useEffect(() => {
if (activeTable && activeDatabase) {
setSqlQuery(`SELECT * FROM ${activeDatabase}.${activeTable} LIMIT 1000;`);
}
}, [activeTable, activeDatabase]);
// Fetch Schema // Fetch Schema
useEffect(() => { useEffect(() => {
const fetchSchema = async () => { const fetchSchema = async () => {
@@ -52,8 +62,6 @@ const MainContent: React.FC = () => {
}; };
}); });
setColumns(cols); setColumns(cols);
// Reset pagination when table changes
setPaginationModel({ page: 0, pageSize: 100 }); setPaginationModel({ page: 0, pageSize: 100 });
} catch (error) { } catch (error) {
console.error('Failed to fetch table schema', error); console.error('Failed to fetch table schema', error);
@@ -80,8 +88,6 @@ const MainContent: React.FC = () => {
const response = await SchemaService.getTableData(activeTable, params); const response = await SchemaService.getTableData(activeTable, params);
// MUI X Data Grid needs a unique id for each row
// If the table doesn't have an 'id' column, we might need to generate one
const dataWithIds = response.data.data.map((row: any, index: number) => ({ const dataWithIds = response.data.data.map((row: any, index: number) => ({
id: row.id || row.ID || `row-${index}-${paginationModel.page}`, id: row.id || row.ID || `row-${index}-${paginationModel.page}`,
...row, ...row,
@@ -100,6 +106,43 @@ const MainContent: React.FC = () => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
const handleExecute = async () => {
if (!sqlQuery) return;
setLoadingData(true);
try {
const response = await SchemaService.executeQuery(sqlQuery);
const rawData = response.data.data;
if (rawData && rawData.length > 0) {
// Generate dynamic columns from the result set
const fields = Object.keys(rawData[0]);
const newCols: GridColDef[] = fields.map(field => ({
field,
headerName: field,
width: 150,
flex: fields.length < 6 ? 1 : 0
}));
setColumns(newCols);
const dataWithIds = rawData.map((row: any, index: number) => ({
id: row.id || row.ID || `row-${index}`,
...row,
}));
setRows(dataWithIds);
setRowCount(response.data.count);
} else {
setRows([]);
setRowCount(0);
}
} catch (error: any) {
console.error('Execution error', error);
// In a real app, we'd use a nicer toast or error panel
alert(error.response?.data?.error || 'Execution failed');
} finally {
setLoadingData(false);
}
};
if (!activeTable) { if (!activeTable) {
return ( return (
<Box sx={{ flexGrow: 1, p: 3, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'background.default' }}> <Box sx={{ flexGrow: 1, p: 3, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'background.default' }}>
@@ -109,58 +152,109 @@ const MainContent: React.FC = () => {
} }
return ( return (
<Box sx={{ flexGrow: 1, p: 3, bgcolor: 'background.default', display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0, overflow: 'hidden' }}> <Box sx={{ flexGrow: 1, p: 3, bgcolor: 'background.default', display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0, overflow: 'hidden', gap: 2 }}>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> {/* SQL Editor Section */}
<Typography variant="h5" sx={{ fontWeight: 700 }}> <Paper elevation={0} sx={{
{activeDatabase}.{activeTable} borderRadius: 2,
</Typography> overflow: 'hidden',
</Box> border: 1,
borderColor: 'divider',
<Paper sx={{ flexGrow: 1, borderRadius: 2, overflow: 'hidden', display: 'flex', flexDirection: 'column', width: '100%' }}> bgcolor: darkMode ? 'rgba(30, 41, 59, 0.5)' : 'rgba(255, 255, 255, 0.5)',
{loadingSchema ? ( backdropFilter: 'blur(8px)'
<Box sx={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}> }}>
<CircularProgress /> <Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'rgba(0,0,0,0.02)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, opacity: 0.7 }}>SQL EDITOR</Typography>
<Divider orientation="vertical" flexItem sx={{ height: 16, my: 'auto' }} />
<Typography variant="caption" sx={{ opacity: 0.5 }}>{activeDatabase}.sql</Typography>
</Box> </Box>
) : ( <Box sx={{ display: 'flex', gap: 1 }}>
<DataGrid <Tooltip title="Clear Editor">
rows={rows} <IconButton size="small" onClick={() => setSqlQuery('')}><CleaningServices fontSize="small" /></IconButton>
columns={columns} </Tooltip>
rowCount={rowCount} <Tooltip title="Save Query">
loading={loadingData} <IconButton size="small"><Save fontSize="small" /></IconButton>
paginationMode="server" </Tooltip>
paginationModel={paginationModel} <Tooltip title="Query History">
onPaginationModelChange={setPaginationModel} <IconButton size="small"><History fontSize="small" /></IconButton>
pageSizeOptions={[25, 50, 100]} </Tooltip>
sx={{ <Button
border: 'none', variant="contained"
width: '100%', size="small"
height: '100%', onClick={handleExecute}
'& .MuiDataGrid-cell:focus': { disabled={loadingData}
outline: 'none', startIcon={loadingData ? <CircularProgress size={16} color="inherit" /> : <PlayArrow />}
}, sx={{ ml: 1, px: 2, borderRadius: 1.5, textTransform: 'none', fontWeight: 700 }}
'& .MuiDataGrid-columnHeader:focus': { >
outline: 'none', Execute
}, </Button>
'& .MuiDataGrid-row:nth-of-type(even)': { </Box>
bgcolor: (theme) => theme.palette.mode === 'light' </Box>
? 'rgba(0, 0, 0, 0.03)' <Editor
: 'rgba(255, 255, 255, 0.03)', height="200px"
}, defaultLanguage="sql"
'& .MuiDataGrid-row:hover': { theme={darkMode ? 'vs-dark' : 'light'}
bgcolor: (theme) => theme.palette.mode === 'light' value={sqlQuery}
? 'rgba(0, 97, 255, 0.08)' onChange={(value) => setSqlQuery(value || '')}
: 'rgba(0, 97, 255, 0.15)', options={{
}, minimap: { enabled: false },
}} fontSize: 13,
slotProps={{ fontFamily: "'Fira Code', 'Cascadia Code', Consolas, monospace",
loadingOverlay: { lineNumbers: 'on',
variant: 'linear-progress', scrollBeyondLastLine: false,
noRowsVariant: 'linear-progress', automaticLayout: true,
} padding: { top: 10, bottom: 10 }
}} }}
/> />
)}
</Paper> </Paper>
{/* Data Section */}
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
<Box sx={{ mb: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Results</Typography>
<Typography variant="caption" sx={{ opacity: 0.5, mt: 0.5 }}>{rowCount} rows found</Typography>
</Box>
</Box>
<Paper sx={{ flexGrow: 1, borderRadius: 2, overflow: 'hidden', display: 'flex', flexDirection: 'column', width: '100%', border: 1, borderColor: 'divider' }}>
{loadingSchema ? (
<Box sx={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
) : (
<DataGrid
rows={rows}
columns={columns}
rowCount={rowCount}
loading={loadingData}
paginationMode="server"
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[25, 50, 100]}
sx={{
border: 'none',
width: '100%',
height: '100%',
'& .MuiDataGrid-cell:focus': { outline: 'none' },
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
'& .MuiDataGrid-row:nth-of-type(even)': {
bgcolor: (theme) => theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.02)' : 'rgba(255, 255, 255, 0.02)',
},
'& .MuiDataGrid-row:hover': {
bgcolor: (theme) => theme.palette.mode === 'light' ? 'rgba(0, 97, 255, 0.04)' : 'rgba(0, 97, 255, 0.08)',
},
}}
slotProps={{
loadingOverlay: {
variant: 'linear-progress',
noRowsVariant: 'linear-progress',
}
}}
/>
)}
</Paper>
</Box>
</Box> </Box>
); );
}; };
+64 -22
View File
@@ -23,37 +23,79 @@ const NavigationRail: React.FC<NavigationRailProps> = ({ activeTab, onTabChange
return ( return (
<Box sx={{ <Box sx={{
width: 60, width: 72,
height: '100vh', height: '100vh',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
py: 2, py: 4,
bgcolor: 'rgba(0,0,0,0.1)', bgcolor: (theme) => theme.palette.mode === 'dark' ? '#0a0f1d' : '#ffffff',
borderRight: 1, borderRight: 1,
borderColor: 'divider' borderColor: 'divider',
zIndex: 1200,
boxShadow: (theme) => theme.palette.mode === 'dark' ? 'none' : '4px 0 10px rgba(0,0,0,0.02)'
}}> }}>
<Stack spacing={2}> <Stack spacing={2.5} sx={{ width: '100%' }}>
{tabs.map((tab) => ( {tabs.map((tab) => {
<Tooltip key={tab.id} title={tab.label} placement="right"> const isActive = activeTab === tab.id;
<IconButton return (
onClick={() => onTabChange(tab.id)} <Box key={tab.id} sx={{ position: 'relative', width: '100%', display: 'flex', justifyContent: 'center' }}>
sx={{ {/* Active Indicator - Elegant Pill Style */}
color: activeTab === tab.id ? 'primary.main' : 'text.secondary', {isActive && (
bgcolor: activeTab === tab.id ? 'rgba(0, 97, 255, 0.1)' : 'transparent', <Box
borderRadius: 2, sx={{
'&:hover': { bgcolor: 'rgba(0, 97, 255, 0.05)' } position: 'absolute',
}} left: 4,
> top: '20%',
{tab.icon} height: '60%',
</IconButton> width: 4,
</Tooltip> bgcolor: 'primary.main',
))} borderRadius: 4,
boxShadow: '0 0 12px rgba(0, 97, 255, 0.4)'
}}
/>
)}
<Tooltip title={tab.label} placement="right" arrow>
<IconButton
onClick={() => onTabChange(tab.id)}
sx={{
width: 48,
height: 48,
color: isActive ? 'primary.main' : 'text.secondary',
bgcolor: isActive ? 'rgba(0, 97, 255, 0.05)' : 'transparent',
borderRadius: '8px',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: isActive ? 'rgba(0, 97, 255, 0.1)' : 'rgba(0, 0, 0, 0.03)',
color: isActive ? 'primary.main' : 'primary.main',
'& .MuiSvgIcon-root': {
transform: 'scale(1.1)',
}
},
'& .MuiSvgIcon-root': {
fontSize: 26,
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}
}}
>
{tab.icon}
</IconButton>
</Tooltip>
</Box>
);
})}
</Stack> </Stack>
<Box sx={{ mt: 'auto' }}> <Box sx={{ mt: 'auto' }}>
<Tooltip title="Settings" placement="right"> <Tooltip title="Settings" placement="right" arrow>
<IconButton sx={{ color: 'text.secondary' }}> <IconButton sx={{
width: 48,
height: 48,
color: 'text.secondary',
transition: 'all 0.2s',
'&:hover': { color: 'primary.main', bgcolor: 'rgba(0, 0, 0, 0.03)' }
}}>
<Settings /> <Settings />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
+1
View File
@@ -27,4 +27,5 @@ export const SchemaService = {
getTables: (db: string) => api.get(`/schema/tables/${db}`, { params: { database: db } }), getTables: (db: string) => api.get(`/schema/tables/${db}`, { params: { database: db } }),
getTableSchema: (table: string, database?: string) => api.get(`/schema/${table}`, { params: { database } }), getTableSchema: (table: string, database?: string) => api.get(`/schema/${table}`, { params: { database } }),
getTableData: (table: string, params: any) => api.get(`/schema/${table}/data`, { params }), getTableData: (table: string, params: any) => api.get(`/schema/${table}/data`, { params }),
executeQuery: (query: string) => api.post('/schema/execute', { query }),
}; };