feat: implement database schema discovery and data browser interface
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,14 +152,72 @@ 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',
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: darkMode ? 'rgba(30, 41, 59, 0.5)' : 'rgba(255, 255, 255, 0.5)',
|
||||||
|
backdropFilter: 'blur(8px)'
|
||||||
|
}}>
|
||||||
|
<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 sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Tooltip title="Clear Editor">
|
||||||
|
<IconButton size="small" onClick={() => setSqlQuery('')}><CleaningServices fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Save Query">
|
||||||
|
<IconButton size="small"><Save fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Query History">
|
||||||
|
<IconButton size="small"><History fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={loadingData}
|
||||||
|
startIcon={loadingData ? <CircularProgress size={16} color="inherit" /> : <PlayArrow />}
|
||||||
|
sx={{ ml: 1, px: 2, borderRadius: 1.5, textTransform: 'none', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
Execute
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Editor
|
||||||
|
height="200px"
|
||||||
|
defaultLanguage="sql"
|
||||||
|
theme={darkMode ? 'vs-dark' : 'light'}
|
||||||
|
value={sqlQuery}
|
||||||
|
onChange={(value) => setSqlQuery(value || '')}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "'Fira Code', 'Cascadia Code', Consolas, monospace",
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
padding: { top: 10, bottom: 10 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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>
|
</Box>
|
||||||
|
|
||||||
<Paper sx={{ flexGrow: 1, borderRadius: 2, overflow: 'hidden', display: 'flex', flexDirection: 'column', width: '100%' }}>
|
<Paper sx={{ flexGrow: 1, borderRadius: 2, overflow: 'hidden', display: 'flex', flexDirection: 'column', width: '100%', border: 1, borderColor: 'divider' }}>
|
||||||
{loadingSchema ? (
|
{loadingSchema ? (
|
||||||
<Box sx={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
|
<Box sx={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
@@ -135,21 +236,13 @@ const MainContent: React.FC = () => {
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
'& .MuiDataGrid-cell:focus': {
|
'& .MuiDataGrid-cell:focus': { outline: 'none' },
|
||||||
outline: 'none',
|
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
|
||||||
},
|
|
||||||
'& .MuiDataGrid-columnHeader:focus': {
|
|
||||||
outline: 'none',
|
|
||||||
},
|
|
||||||
'& .MuiDataGrid-row:nth-of-type(even)': {
|
'& .MuiDataGrid-row:nth-of-type(even)': {
|
||||||
bgcolor: (theme) => theme.palette.mode === 'light'
|
bgcolor: (theme) => theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.02)' : 'rgba(255, 255, 255, 0.02)',
|
||||||
? 'rgba(0, 0, 0, 0.03)'
|
|
||||||
: 'rgba(255, 255, 255, 0.03)',
|
|
||||||
},
|
},
|
||||||
'& .MuiDataGrid-row:hover': {
|
'& .MuiDataGrid-row:hover': {
|
||||||
bgcolor: (theme) => theme.palette.mode === 'light'
|
bgcolor: (theme) => theme.palette.mode === 'light' ? 'rgba(0, 97, 255, 0.04)' : 'rgba(0, 97, 255, 0.08)',
|
||||||
? 'rgba(0, 97, 255, 0.08)'
|
|
||||||
: 'rgba(0, 97, 255, 0.15)',
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -162,6 +255,7 @@ const MainContent: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
return (
|
||||||
|
<Box key={tab.id} sx={{ position: 'relative', width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{/* Active Indicator - Elegant Pill Style */}
|
||||||
|
{isActive && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 4,
|
||||||
|
top: '20%',
|
||||||
|
height: '60%',
|
||||||
|
width: 4,
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
borderRadius: 4,
|
||||||
|
boxShadow: '0 0 12px rgba(0, 97, 255, 0.4)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip title={tab.label} placement="right" arrow>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => onTabChange(tab.id)}
|
onClick={() => onTabChange(tab.id)}
|
||||||
sx={{
|
sx={{
|
||||||
color: activeTab === tab.id ? 'primary.main' : 'text.secondary',
|
width: 48,
|
||||||
bgcolor: activeTab === tab.id ? 'rgba(0, 97, 255, 0.1)' : 'transparent',
|
height: 48,
|
||||||
borderRadius: 2,
|
color: isActive ? 'primary.main' : 'text.secondary',
|
||||||
'&:hover': { bgcolor: 'rgba(0, 97, 255, 0.05)' }
|
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}
|
{tab.icon}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user