new file: backend/src/controllers/schemaController.ts

new file:   backend/src/migrations/008_add_database_schemas.sql
	modified:   backend/src/routes/sqlInterface.ts
	modified:   frontend/package.json
	modified:   frontend/src/App.tsx
	modified:   frontend/src/components/Sidebar.tsx
	new file:   frontend/src/pages/DatabaseSchema.tsx
	modified:   frontend/src/services/api.ts
This commit is contained in:
2026-01-27 23:42:25 +03:00
parent a873e18d35
commit 89b5a86bda
8 changed files with 525 additions and 1 deletions

View File

@@ -0,0 +1,186 @@
import { Request, Response } from 'express';
import { mainPool } from '../config/database';
import { databasePoolManager } from '../services/DatabasePoolManager';
interface ColumnInfo {
name: string;
type: string;
nullable: boolean;
default_value: string | null;
is_primary: boolean;
comment: string | null;
}
interface ForeignKey {
column: string;
references_table: string;
references_column: string;
constraint_name: string;
}
interface TableInfo {
name: string;
schema: string;
comment: string | null;
columns: ColumnInfo[];
foreign_keys: ForeignKey[];
}
interface SchemaData {
tables: TableInfo[];
updated_at: string;
}
// Parse PostgreSQL schema
async function parsePostgresSchema(databaseId: string): Promise<SchemaData> {
const pool = databasePoolManager.getPool(databaseId);
if (!pool) {
throw new Error('Database not found or not active');
}
// Get all tables
const tablesResult = await pool.query(`
SELECT
t.table_schema,
t.table_name,
obj_description((t.table_schema || '.' || t.table_name)::regclass, 'pg_class') as table_comment
FROM information_schema.tables t
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
AND t.table_type = 'BASE TABLE'
ORDER BY t.table_schema, t.table_name
`);
const tables: TableInfo[] = [];
for (const table of tablesResult.rows) {
// Get columns for each table
const columnsResult = await pool.query(`
SELECT
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary,
col_description((c.table_schema || '.' || c.table_name)::regclass, c.ordinal_position) as column_comment
FROM information_schema.columns c
LEFT JOIN (
SELECT ku.column_name, ku.table_schema, ku.table_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage ku
ON tc.constraint_name = ku.constraint_name
AND tc.table_schema = ku.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
) pk ON c.column_name = pk.column_name
AND c.table_schema = pk.table_schema
AND c.table_name = pk.table_name
WHERE c.table_schema = $1 AND c.table_name = $2
ORDER BY c.ordinal_position
`, [table.table_schema, table.table_name]);
// Get foreign keys
const fkResult = await pool.query(`
SELECT
kcu.column_name,
ccu.table_name AS references_table,
ccu.column_name AS references_column,
tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = $1
AND tc.table_name = $2
`, [table.table_schema, table.table_name]);
tables.push({
name: table.table_name,
schema: table.table_schema,
comment: table.table_comment,
columns: columnsResult.rows.map(col => ({
name: col.column_name,
type: col.data_type,
nullable: col.is_nullable === 'YES',
default_value: col.column_default,
is_primary: col.is_primary,
comment: col.column_comment,
})),
foreign_keys: fkResult.rows.map(fk => ({
column: fk.column_name,
references_table: fk.references_table,
references_column: fk.references_column,
constraint_name: fk.constraint_name,
})),
});
}
return {
tables,
updated_at: new Date().toISOString(),
};
}
// Get cached schema or return null
async function getCachedSchema(databaseId: string): Promise<SchemaData | null> {
const result = await mainPool.query(
'SELECT schema_data FROM database_schemas WHERE database_id = $1',
[databaseId]
);
if (result.rows.length > 0) {
return result.rows[0].schema_data as SchemaData;
}
return null;
}
// Save schema to cache
async function saveSchemaToCache(databaseId: string, schemaData: SchemaData): Promise<void> {
await mainPool.query(`
INSERT INTO database_schemas (database_id, schema_data, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (database_id)
DO UPDATE SET schema_data = $2, updated_at = NOW()
`, [databaseId, JSON.stringify(schemaData)]);
}
// GET /api/workbench/schema/:databaseId
export const getSchema = async (req: Request, res: Response) => {
try {
const { databaseId } = req.params;
// Try to get from cache first
let schema = await getCachedSchema(databaseId);
if (!schema) {
// Parse and cache if not exists
schema = await parsePostgresSchema(databaseId);
await saveSchemaToCache(databaseId, schema);
}
res.json({ success: true, data: schema });
} catch (error: any) {
console.error('Error getting schema:', error);
res.status(500).json({ success: false, error: error.message });
}
};
// POST /api/workbench/schema/:databaseId/refresh
export const refreshSchema = async (req: Request, res: Response) => {
try {
const { databaseId } = req.params;
// Parse fresh schema
const schema = await parsePostgresSchema(databaseId);
// Save to cache
await saveSchemaToCache(databaseId, schema);
res.json({ success: true, data: schema });
} catch (error: any) {
console.error('Error refreshing schema:', error);
res.status(500).json({ success: false, error: error.message });
}
};

View File

@@ -0,0 +1,12 @@
-- Database schemas cache table
CREATE TABLE IF NOT EXISTS database_schemas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
database_id UUID NOT NULL REFERENCES databases(id) ON DELETE CASCADE,
schema_data JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(database_id)
);
-- Index for faster lookups
CREATE INDEX IF NOT EXISTS idx_database_schemas_database_id ON database_schemas(database_id);

View File

@@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import { authMiddleware } from '../middleware/auth'; import { authMiddleware } from '../middleware/auth';
import { executeQuery } from '../controllers/sqlInterfaceController'; import { executeQuery } from '../controllers/sqlInterfaceController';
import { getSchema, refreshSchema } from '../controllers/schemaController';
const router = express.Router(); const router = express.Router();
@@ -8,4 +9,8 @@ router.use(authMiddleware);
router.post('/execute', executeQuery); router.post('/execute', executeQuery);
// Schema routes
router.get('/schema/:databaseId', getSchema);
router.post('/schema/:databaseId/refresh', refreshSchema);
export default router; export default router;

View File

@@ -17,6 +17,7 @@
"@hookform/resolvers": "^3.3.3", "@hookform/resolvers": "^3.3.3",
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^5.14.2", "@tanstack/react-query": "^5.14.2",
"@xyflow/react": "^12.0.0",
"axios": "^1.6.2", "axios": "^1.6.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",

View File

@@ -14,6 +14,7 @@ import Folders from '@/pages/Folders';
import Logs from '@/pages/Logs'; import Logs from '@/pages/Logs';
import Settings from '@/pages/Settings'; import Settings from '@/pages/Settings';
import SqlInterface from '@/pages/SqlInterface'; import SqlInterface from '@/pages/SqlInterface';
import DatabaseSchema from '@/pages/DatabaseSchema';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -86,6 +87,16 @@ function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/schema"
element={
<PrivateRoute>
<Layout>
<DatabaseSchema />
</Layout>
</PrivateRoute>
}
/>
<Route <Route
path="/endpoints" path="/endpoints"
element={ element={

View File

@@ -1,10 +1,11 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink, Database } from 'lucide-react'; import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink, Database, GitBranch } from 'lucide-react';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
const navItems = [ const navItems = [
{ to: '/', icon: Home, label: 'Главная' }, { to: '/', icon: Home, label: 'Главная' },
{ to: '/workbench', icon: Database, label: 'SQL интерфейс' }, { to: '/workbench', icon: Database, label: 'SQL интерфейс' },
{ to: '/schema', icon: GitBranch, label: 'Схема БД' },
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' }, { to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
{ to: '/folders', icon: Folder, label: 'Папки' }, { to: '/folders', icon: Folder, label: 'Папки' },
{ to: '/api-keys', icon: Key, label: 'API Ключи' }, { to: '/api-keys', icon: Key, label: 'API Ключи' },

View File

@@ -0,0 +1,270 @@
import { useState, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ReactFlow,
Node,
Edge,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
MarkerType,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Database as DatabaseIcon, RefreshCw, Loader2, Key, Link } from 'lucide-react';
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
import { Database } from '@/types';
// Custom node for table
function TableNode({ data }: { data: TableInfo }) {
return (
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 overflow-hidden">
{/* Table header */}
<div className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm">
<div className="flex items-center gap-2">
<DatabaseIcon size={14} />
<span>{data.schema}.{data.name}</span>
</div>
{data.comment && (
<div className="text-xs text-primary-200 mt-1 font-normal">{data.comment}</div>
)}
</div>
{/* Columns */}
<div className="divide-y divide-gray-100">
{data.columns.map((col) => (
<div
key={col.name}
className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50"
>
{col.is_primary && <Key size={12} className="text-yellow-500 flex-shrink-0" />}
{data.foreign_keys.some(fk => fk.column === col.name) && (
<Link size={12} className="text-blue-500 flex-shrink-0" />
)}
<span className={`font-medium ${col.is_primary ? 'text-yellow-700' : 'text-gray-700'}`}>
{col.name}
</span>
<span className="text-gray-400 flex-1">{col.type}</span>
{!col.nullable && <span className="text-red-400 text-[10px]">NOT NULL</span>}
</div>
))}
{data.columns.length === 0 && (
<div className="px-3 py-2 text-xs text-gray-400 italic">No columns</div>
)}
</div>
{/* Column comments tooltip would go here in a more advanced version */}
</div>
);
}
const nodeTypes = {
table: TableNode,
};
function generateNodesAndEdges(schema: SchemaData): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = [];
const edges: Edge[] = [];
const tablePositions = new Map<string, { x: number; y: number }>();
// Calculate positions in a grid layout
const cols = Math.ceil(Math.sqrt(schema.tables.length));
const nodeWidth = 280;
const nodeHeight = 200;
const gapX = 100;
const gapY = 80;
schema.tables.forEach((table, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
const x = col * (nodeWidth + gapX);
const y = row * (nodeHeight + gapY);
const fullName = `${table.schema}.${table.name}`;
tablePositions.set(table.name, { x, y });
tablePositions.set(fullName, { x, y });
nodes.push({
id: fullName,
type: 'table',
position: { x, y },
data: table,
});
});
// Create edges for foreign keys
schema.tables.forEach((table) => {
const sourceId = `${table.schema}.${table.name}`;
table.foreign_keys.forEach((fk) => {
// Try to find target table
let targetId = schema.tables.find(t => t.name === fk.references_table);
if (targetId) {
edges.push({
id: `${fk.constraint_name}`,
source: sourceId,
target: `${targetId.schema}.${targetId.name}`,
sourceHandle: fk.column,
targetHandle: fk.references_column,
type: 'smoothstep',
animated: true,
style: { stroke: '#3b82f6', strokeWidth: 2 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#3b82f6',
},
label: fk.column,
labelStyle: { fontSize: 10, fill: '#666' },
labelBgStyle: { fill: 'white', fillOpacity: 0.8 },
});
}
});
});
return { nodes, edges };
}
export default function DatabaseSchema() {
const queryClient = useQueryClient();
const [selectedDbId, setSelectedDbId] = useState<string>('');
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
queryKey: ['databases'],
queryFn: async () => {
const { data } = await databasesApi.getAll();
return data.filter((db: Database) => db.type !== 'aql');
},
});
const { data: schemaResponse, isLoading: isLoadingSchema } = useQuery({
queryKey: ['schema', selectedDbId],
queryFn: async () => {
const { data } = await schemaApi.getSchema(selectedDbId);
return data;
},
enabled: !!selectedDbId,
});
const refreshMutation = useMutation({
mutationFn: () => schemaApi.refreshSchema(selectedDbId),
onSuccess: (response) => {
queryClient.setQueryData(['schema', selectedDbId], response.data);
},
});
const schema = schemaResponse?.data;
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
if (!schema) return { nodes: [], edges: [] };
return generateNodesAndEdges(schema);
}, [schema]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
// Update nodes when schema changes
useMemo(() => {
if (initialNodes.length > 0) {
setNodes(initialNodes);
setEdges(initialEdges);
}
}, [initialNodes, initialEdges, setNodes, setEdges]);
if (isLoadingDatabases) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="animate-spin text-primary-600" size={32} />
</div>
);
}
return (
<div className="flex flex-col -m-8 bg-white" style={{ height: 'calc(100vh - 65px)' }}>
{/* Toolbar */}
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
<div className="flex items-center gap-2">
<DatabaseIcon size={18} className="text-gray-500" />
<select
value={selectedDbId}
onChange={(e) => setSelectedDbId(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-48"
>
<option value="">Выберите базу данных</option>
{databases.map((db: Database) => (
<option key={db.id} value={db.id}>
{db.name} ({db.type})
</option>
))}
</select>
</div>
{selectedDbId && (
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className="flex items-center gap-2 px-4 py-1.5 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50 text-sm font-medium"
>
{refreshMutation.isPending ? (
<Loader2 className="animate-spin" size={16} />
) : (
<RefreshCw size={16} />
)}
Обновить схему
</button>
)}
{schema && (
<div className="text-sm text-gray-500 ml-auto">
{schema.tables.length} таблиц | Обновлено: {new Date(schema.updated_at).toLocaleString()}
</div>
)}
</div>
{/* Schema visualization */}
<div className="flex-1">
{!selectedDbId && (
<div className="flex items-center justify-center h-full text-gray-400">
Выберите базу данных для просмотра схемы
</div>
)}
{selectedDbId && isLoadingSchema && (
<div className="flex items-center justify-center h-full">
<Loader2 className="animate-spin text-primary-600" size={32} />
</div>
)}
{selectedDbId && !isLoadingSchema && schema && (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
minZoom={0.1}
maxZoom={2}
defaultEdgeOptions={{
type: 'smoothstep',
}}
>
<Background color="#e5e7eb" gap={16} />
<Controls />
<MiniMap
nodeColor="#6366f1"
maskColor="rgba(0, 0, 0, 0.1)"
style={{ background: '#f3f4f6' }}
/>
</ReactFlow>
)}
{selectedDbId && !isLoadingSchema && schema && schema.tables.length === 0 && (
<div className="flex items-center justify-center h-full text-gray-400">
В базе данных нет таблиц
</div>
)}
</div>
</div>
);
}

View File

@@ -187,4 +187,42 @@ export const sqlInterfaceApi = {
api.post<SqlQueryResult>('/workbench/execute', { database_id: databaseId, query }), api.post<SqlQueryResult>('/workbench/execute', { database_id: databaseId, query }),
}; };
// Schema API
export interface ColumnInfo {
name: string;
type: string;
nullable: boolean;
default_value: string | null;
is_primary: boolean;
comment: string | null;
}
export interface ForeignKey {
column: string;
references_table: string;
references_column: string;
constraint_name: string;
}
export interface TableInfo {
name: string;
schema: string;
comment: string | null;
columns: ColumnInfo[];
foreign_keys: ForeignKey[];
}
export interface SchemaData {
tables: TableInfo[];
updated_at: string;
}
export const schemaApi = {
getSchema: (databaseId: string) =>
api.get<{ success: boolean; data: SchemaData }>(`/workbench/schema/${databaseId}`),
refreshSchema: (databaseId: string) =>
api.post<{ success: boolean; data: SchemaData }>(`/workbench/schema/${databaseId}/refresh`),
};
export default api; export default api;