From 89b5a86bda126140dee146c198b44eba30adfe10 Mon Sep 17 00:00:00 2001 From: eshmeshek Date: Tue, 27 Jan 2026 23:42:25 +0300 Subject: [PATCH] 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 --- backend/src/controllers/schemaController.ts | 186 ++++++++++++ .../migrations/008_add_database_schemas.sql | 12 + backend/src/routes/sqlInterface.ts | 5 + frontend/package.json | 1 + frontend/src/App.tsx | 11 + frontend/src/components/Sidebar.tsx | 3 +- frontend/src/pages/DatabaseSchema.tsx | 270 ++++++++++++++++++ frontend/src/services/api.ts | 38 +++ 8 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 backend/src/controllers/schemaController.ts create mode 100644 backend/src/migrations/008_add_database_schemas.sql create mode 100644 frontend/src/pages/DatabaseSchema.tsx diff --git a/backend/src/controllers/schemaController.ts b/backend/src/controllers/schemaController.ts new file mode 100644 index 0000000..a0aeab0 --- /dev/null +++ b/backend/src/controllers/schemaController.ts @@ -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 { + 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 { + 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 { + 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 }); + } +}; diff --git a/backend/src/migrations/008_add_database_schemas.sql b/backend/src/migrations/008_add_database_schemas.sql new file mode 100644 index 0000000..028f01c --- /dev/null +++ b/backend/src/migrations/008_add_database_schemas.sql @@ -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); diff --git a/backend/src/routes/sqlInterface.ts b/backend/src/routes/sqlInterface.ts index e0a0195..6e2dad9 100644 --- a/backend/src/routes/sqlInterface.ts +++ b/backend/src/routes/sqlInterface.ts @@ -1,6 +1,7 @@ import express from 'express'; import { authMiddleware } from '../middleware/auth'; import { executeQuery } from '../controllers/sqlInterfaceController'; +import { getSchema, refreshSchema } from '../controllers/schemaController'; const router = express.Router(); @@ -8,4 +9,8 @@ router.use(authMiddleware); router.post('/execute', executeQuery); +// Schema routes +router.get('/schema/:databaseId', getSchema); +router.post('/schema/:databaseId/refresh', refreshSchema); + export default router; diff --git a/frontend/package.json b/frontend/package.json index d0c2a16..c11f130 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@hookform/resolvers": "^3.3.3", "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.14.2", + "@xyflow/react": "^12.0.0", "axios": "^1.6.2", "clsx": "^2.0.0", "cmdk": "^0.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7304e54..d5e625d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import Folders from '@/pages/Folders'; import Logs from '@/pages/Logs'; import Settings from '@/pages/Settings'; import SqlInterface from '@/pages/SqlInterface'; +import DatabaseSchema from '@/pages/DatabaseSchema'; const queryClient = new QueryClient({ defaultOptions: { @@ -86,6 +87,16 @@ function App() { } /> + + + + + + } + /> + {/* Table header */} +
+
+ + {data.schema}.{data.name} +
+ {data.comment && ( +
{data.comment}
+ )} +
+ + {/* Columns */} +
+ {data.columns.map((col) => ( +
+ {col.is_primary && } + {data.foreign_keys.some(fk => fk.column === col.name) && ( + + )} + + {col.name} + + {col.type} + {!col.nullable && NOT NULL} +
+ ))} + {data.columns.length === 0 && ( +
No columns
+ )} +
+ + {/* Column comments tooltip would go here in a more advanced version */} + + ); +} + +const nodeTypes = { + table: TableNode, +}; + +function generateNodesAndEdges(schema: SchemaData): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = []; + const edges: Edge[] = []; + const tablePositions = new Map(); + + // 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(''); + + 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ + +
+ + {selectedDbId && ( + + )} + + {schema && ( +
+ {schema.tables.length} таблиц | Обновлено: {new Date(schema.updated_at).toLocaleString()} +
+ )} +
+ + {/* Schema visualization */} +
+ {!selectedDbId && ( +
+ Выберите базу данных для просмотра схемы +
+ )} + + {selectedDbId && isLoadingSchema && ( +
+ +
+ )} + + {selectedDbId && !isLoadingSchema && schema && ( + + + + + + )} + + {selectedDbId && !isLoadingSchema && schema && schema.tables.length === 0 && ( +
+ В базе данных нет таблиц +
+ )} +
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c7b3f59..bfc28c8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -187,4 +187,42 @@ export const sqlInterfaceApi = { api.post('/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;