diff --git a/apps/backend/internal/handlers/databases.go b/apps/backend/internal/handlers/databases.go index a9c51cd..8e1c487 100644 --- a/apps/backend/internal/handlers/databases.go +++ b/apps/backend/internal/handlers/databases.go @@ -150,6 +150,20 @@ func (h *HTTPHandler) dbxSchema(c *gin.Context) { c.JSON(http.StatusOK, result) } +func (h *HTTPHandler) dbxForeignKeys(c *gin.Context) { + actor, projectID, connectionID, ok := h.actorProjectConn(c) + if !ok { + return + } + result, err := h.Platform.ListDBXForeignKeys(c.Request.Context(), actor, projectID, connectionID, + strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema"))) + if err != nil { + h.dbxError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + func (h *HTTPHandler) dbxQuery(c *gin.Context) { actor, projectID, connectionID, ok := h.actorProjectConn(c) if !ok { diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index c158709..09344e1 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -79,6 +79,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.GET("/projects/:projectID/db-connections/:connectionID/tables", h.dbxTables) api.GET("/projects/:projectID/db-connections/:connectionID/describe", h.dbxDescribe) api.GET("/projects/:projectID/db-connections/:connectionID/schema", h.dbxSchema) + api.GET("/projects/:projectID/db-connections/:connectionID/foreign-keys", h.dbxForeignKeys) api.POST("/projects/:projectID/db-connections/:connectionID/query", h.dbxQuery) api.POST("/projects/:projectID/db-connections/:connectionID/redis", h.dbxRedis) api.GET("/buckets/:bucketID/objects", h.listObjects) diff --git a/apps/backend/internal/services/databases.go b/apps/backend/internal/services/databases.go index 7a7f2bb..694c033 100644 --- a/apps/backend/internal/services/databases.go +++ b/apps/backend/internal/services/databases.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "regexp" "strings" "github.com/google/uuid" @@ -397,6 +398,93 @@ func (s *PlatformService) ExecuteDBXQuery(ctx context.Context, actor *models.Act return map[string]any{"raw": text}, nil } +// dbxIdent guards identifiers interpolated into introspection SQL — the +// graph queries are built as strings, so only plain names are allowed. +var dbxIdent = regexp.MustCompile(`^[A-Za-z0-9_]+$`) + +func safeIdent(s string) (string, bool) { + if s == "" || !dbxIdent.MatchString(s) { + return "", false + } + return s, true +} + +// foreignKeySQL returns a dialect-appropriate FK introspection query. +// The result columns are always: schema, table, column, ref_schema, ref_table, ref_column. +func foreignKeySQL(dbType, schema, database string) (string, string) { + sch, schOK := safeIdent(schema) + db, _ := safeIdent(database) + switch dbType { + case "postgres", "sqlserver", "duckdb": + q := `SELECT kcu.table_schema, kcu.table_name, kcu.column_name, + ccu.table_schema AS ref_schema, ccu.table_name AS ref_table, ccu.column_name AS ref_column +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 AND tc.table_name = kcu.table_name +JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema +WHERE tc.constraint_type = 'FOREIGN KEY' + AND kcu.table_schema NOT IN ('pg_catalog', 'information_schema')` + if schOK { + q += ` AND kcu.table_schema = '` + sch + `'` + } + return q, db + case "mysql": + where := "kcu.REFERENCED_TABLE_NAME IS NOT NULL" + if schOK { + where += ` AND kcu.TABLE_SCHEMA = '` + sch + `'` + } else { + where += " AND kcu.TABLE_SCHEMA = DATABASE()" + } + return `SELECT kcu.TABLE_SCHEMA, kcu.TABLE_NAME, kcu.COLUMN_NAME, + kcu.REFERENCED_TABLE_SCHEMA, kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME +FROM information_schema.KEY_COLUMN_USAGE kcu WHERE ` + where, db + case "sqlite": + return `SELECT '' AS table_schema, m.name AS table_name, p."from" AS column_name, + '' AS ref_schema, p."table" AS ref_table, p."to" AS ref_column +FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) p ON 1 = 1 +WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'`, db + default: + return "", "" + } +} + +// ListDBXForeignKeys returns FK edges for the schema graph. Introspection +// only — read role is enough even though it rides dbx_execute_query. +func (s *PlatformService) ListDBXForeignKeys(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID, database, schema string) (map[string]any, error) { + row, err := s.connForTool(ctx, actor, projectID, connectionID, false) + if err != nil { + return nil, err + } + query, dbArg := foreignKeySQL(row.DbType, schema, database) + if query == "" { + return map[string]any{"edges": []map[string]string{}, "note": "schema graph not supported for " + row.DbType}, nil + } + args := map[string]any{"connection_name": dbxConnectionName(projectID, row.Name), "sql": query} + if dbArg != "" { + args["database"] = dbArg + } + text, err := s.dbx.Call(ctx, "dbx_execute_query", args) + if err != nil { + return nil, err + } + t, _ := dbx.ParseMDTable(text) + if t == nil { + return map[string]any{"edges": []map[string]string{}, "raw": text}, nil + } + edges := make([]map[string]string, 0, len(t.Rows)) + for _, r := range t.Rows { + if len(r) < 6 { + continue + } + edges = append(edges, map[string]string{ + "schema": r[0], "table": r[1], "column": r[2], + "ref_schema": r[3], "ref_table": r[4], "ref_column": r[5], + }) + } + return map[string]any{"edges": edges}, nil +} + func (s *PlatformService) ExecuteDBXRedis(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID, command string, dbIndex *int) (map[string]any, error) { row, err := s.connForTool(ctx, actor, projectID, connectionID, true) if err != nil { diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index 0a98f77..df6ee1a 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -1014,6 +1014,29 @@ paths: $ref: "#/components/responses/ErrorResponse" "502": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/db-connections/{connectionID}/foreign-keys: + get: + tags: [Databases] + operationId: listDbForeignKeys + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/ConnectionID" + - $ref: "#/components/parameters/DatabaseQuery" + - $ref: "#/components/parameters/SchemaQuery" + responses: + "200": + description: Foreign key edges for the schema graph + content: + application/json: + schema: + $ref: "#/components/schemas/DBForeignKeysResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" /projects/{projectID}/db-connections/{connectionID}/query: post: tags: [Databases] @@ -2014,6 +2037,34 @@ components: context: type: string required: [context] + DBForeignKeyEdge: + type: object + properties: + schema: + type: string + table: + type: string + column: + type: string + ref_schema: + type: string + ref_table: + type: string + ref_column: + type: string + required: [table, column, ref_table, ref_column] + DBForeignKeysResponse: + type: object + properties: + edges: + type: array + items: + $ref: "#/components/schemas/DBForeignKeyEdge" + raw: + type: string + note: + type: string + required: [edges] DBQueryRequest: type: object properties: diff --git a/apps/frontend/src/lib/demo-mode.ts b/apps/frontend/src/lib/demo-mode.ts index 3906688..3d70baf 100644 --- a/apps/frontend/src/lib/demo-mode.ts +++ b/apps/frontend/src/lib/demo-mode.ts @@ -353,7 +353,17 @@ let dbConnections: DBConnection[] = [ }, ]; -const demoTables = ["users", "projects", "api_keys", "audit_logs", "buckets"]; +const demoTables = [ + "users", + "organizations", + "projects", + "api_keys", + "audit_logs", + "buckets", + "bucket_objects", + "collections", + "documents", +]; const demoColumns: Record = { users: [ @@ -833,6 +843,20 @@ class DemoService { }; } + async listDbForeignKeys() { + await this.delay(); + return { + edges: [ + { schema: "core", table: "projects", column: "organization_id", ref_schema: "core", ref_table: "organizations", ref_column: "id" }, + { schema: "core", table: "api_keys", column: "project_id", ref_schema: "core", ref_table: "projects", ref_column: "id" }, + { schema: "core", table: "audit_logs", column: "project_id", ref_schema: "core", ref_table: "projects", ref_column: "id" }, + { schema: "core", table: "audit_logs", column: "organization_id", ref_schema: "core", ref_table: "organizations", ref_column: "id" }, + { schema: "core", table: "bucket_objects", column: "bucket_id", ref_schema: "core", ref_table: "buckets", ref_column: "id" }, + { schema: "core", table: "documents", column: "collection_id", ref_schema: "core", ref_table: "collections", ref_column: "id" }, + ], + }; + } + async getDbSchemaContext() { await this.delay(); return { context: "## users\nType: BASE TABLE\n- id uuid NOT NULL PK\n- email text NOT NULL" }; diff --git a/apps/frontend/src/pages/DatabasesPage.tsx b/apps/frontend/src/pages/DatabasesPage.tsx index 2531edf..afdf50d 100644 --- a/apps/frontend/src/pages/DatabasesPage.tsx +++ b/apps/frontend/src/pages/DatabasesPage.tsx @@ -3,6 +3,7 @@ import { DatabasesService, CreateDBConnectionRequest, type DBConnection, + type DBForeignKeyEdge, type DBMDTable, type DBObject, type DBQueryResponse, @@ -29,7 +30,7 @@ interface DatabasesPageProps { demoMode: boolean; } -type WorkspaceTab = "browse" | "query" | "console"; +type WorkspaceTab = "browse" | "query" | "graph" | "console"; interface ConnForm { name: string; @@ -108,6 +109,126 @@ function ResultTable(props: { table: DBMDTable; onRow?: (row: string[]) => void ); } +/* SchemaGraph renders tables on a circle with FK edges as curved paths. */ +function SchemaGraph(props: { + nodes: string[]; + edges: DBForeignKeyEdge[]; + onPick: (table: string) => void; +}) { + const W = 760; + const H = 460; + const cx = W / 2; + const cy = H / 2; + const [hover, setHover] = createSignal(); + + const positions = createMemo(() => { + const m = new Map(); + const n = props.nodes.length; + const r = Math.min(195, Math.max(120, n * 18)); + props.nodes.forEach((name, i) => { + const a = (i / n) * 2 * Math.PI - Math.PI / 2; + m.set(name, { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }); + }); + return m; + }); + + const adjacent = (e: DBForeignKeyEdge) => + !!hover() && (e.table === hover() || e.ref_table === hover()); + + const nodeW = (name: string) => Math.max(72, name.length * 7.2 + 20); + + return ( + + + + + + + + {(e) => { + const a = () => positions().get(e.table); + const b = () => positions().get(e.ref_table); + const path = () => { + const pa = a(); + const pb = b(); + if (!pa || !pb) return ""; + if (e.table === e.ref_table) { + return `M ${pa.x - 10} ${pa.y - 12} C ${pa.x - 34} ${pa.y - 46} ${pa.x + 34} ${pa.y - 46} ${pa.x + 10} ${pa.y - 12}`; + } + const mx = (pa.x + pb.x) / 2; + const my = (pa.y + pb.y) / 2; + const bx = mx + (my - cy) * 0.18; + const by = my - (mx - cx) * 0.18; + // pull the endpoint back to the node border so the arrow sits on it + const dx = pb.x - bx; + const dy = pb.y - by; + const len = Math.hypot(dx, dy) || 1; + const ex = pb.x - (dx / len) * (nodeW(e.ref_table) / 2 + 6); + const ey = pb.y - (dy / len) * 18; + return `M ${pa.x} ${pa.y} Q ${bx} ${by} ${ex} ${ey}`; + }; + return ( + + + {`${e.table}.${e.column} → ${e.ref_table}.${e.ref_column}`} + + + ); + }} + + + {(name) => { + const p = () => positions().get(name)!; + const w = () => nodeW(name); + return ( + setHover(name)} + onMouseLeave={() => setHover(undefined)} + onClick={() => props.onPick(name)} + > + + + {name} + + + ); + }} + + + ); +} + export function DatabasesPage(props: DatabasesPageProps) { const [status, setStatus] = createSignal(null); const [conns, setConns] = createSignal([]); @@ -127,6 +248,11 @@ export function DatabasesPage(props: DatabasesPageProps) { const [described, setDescribed] = createSignal<{ name: string; table?: DBMDTable; raw?: string }>(); const [describeBusy, setDescribeBusy] = createSignal(false); + const [edges, setEdges] = createSignal([]); + const [edgesNote, setEdgesNote] = createSignal(""); + const [graphBusy, setGraphBusy] = createSignal(false); + const [graphKey, setGraphKey] = createSignal(""); + const [sql, setSql] = createSignal("SELECT 1;"); const [queryResult, setQueryResult] = createSignal(); const [queryBusy, setQueryBusy] = createSignal(false); @@ -195,6 +321,9 @@ export function DatabasesPage(props: DatabasesPageProps) { setDescribed(undefined); setQueryResult(undefined); setConsoleLog([]); + setEdges([]); + setEdgesNote(""); + setGraphKey(""); const conn = conns().find((c) => c.id === id); if (!conn) return; if (!sqlish(conn.db_type)) { @@ -242,6 +371,42 @@ export function DatabasesPage(props: DatabasesPageProps) { } }; + const loadGraph = async () => { + const conn = active(); + if (!conn) return; + const key = `${conn.id}|${selectedDB()}|${appliedSchema()}`; + if (graphKey() === key) return; + setGraphBusy(true); + setError(""); + try { + if (!tables()) { + await loadTables(conn, selectedDB(), appliedSchema()); + } + const r = await svc(props.demoMode).listDbForeignKeys({ + projectId: props.projectID!, + connectionId: conn.id, + database: selectedDB() || undefined, + schema: appliedSchema() || undefined, + }); + setEdges(r.edges ?? []); + setEdgesNote(r.note ?? r.raw ?? ""); + setGraphKey(key); + } catch (e) { + setError(err(e)); + } finally { + setGraphBusy(false); + } + }; + + const graphNodes = createMemo(() => { + const names = new Set((tables() ?? []).map((t) => t.name)); + for (const e of edges()) { + names.add(e.table); + names.add(e.ref_table); + } + return [...names].sort(); + }); + const runQuery = async () => { const conn = active(); if (!conn || !sql().trim()) return; @@ -490,6 +655,15 @@ export function DatabasesPage(props: DatabasesPageProps) { > Query +