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 (
+
+ );
+}
+
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
+
+ {/* schema graph tab */}
+
+ Building graph…
}
+ >
+ 0}
+ fallback={
+
+ {edgesNote() || "No tables in scope — pick a database first."}
+
+ }
+ >
+ {
+ setTab("browse");
+ void describe(t);
+ }}
+ />
+
+ {edges().length} foreign key{edges().length === 1 ? "" : "s"} · click a table to inspect it
+ · {edgesNote()}
+
+
+
+
+
{/* query tab */}
diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts
index fbe0c33..9ee8847 100644
--- a/packages/api-client/src/generated/index.ts
+++ b/packages/api-client/src/generated/index.ts
@@ -40,6 +40,8 @@ export type { DBConnectionListResponse } from './models/DBConnectionListResponse
export type { DBConnectionTestResult } from './models/DBConnectionTestResult';
export type { DBDatabaseListResponse } from './models/DBDatabaseListResponse';
export type { DBDescribeResponse } from './models/DBDescribeResponse';
+export type { DBForeignKeyEdge } from './models/DBForeignKeyEdge';
+export type { DBForeignKeysResponse } from './models/DBForeignKeysResponse';
export type { DBMDTable } from './models/DBMDTable';
export type { DBObject } from './models/DBObject';
export type { DBQueryRequest } from './models/DBQueryRequest';
@@ -118,6 +120,8 @@ export { $DBConnectionListResponse } from './schemas/$DBConnectionListResponse';
export { $DBConnectionTestResult } from './schemas/$DBConnectionTestResult';
export { $DBDatabaseListResponse } from './schemas/$DBDatabaseListResponse';
export { $DBDescribeResponse } from './schemas/$DBDescribeResponse';
+export { $DBForeignKeyEdge } from './schemas/$DBForeignKeyEdge';
+export { $DBForeignKeysResponse } from './schemas/$DBForeignKeysResponse';
export { $DBMDTable } from './schemas/$DBMDTable';
export { $DBObject } from './schemas/$DBObject';
export { $DBQueryRequest } from './schemas/$DBQueryRequest';
diff --git a/packages/api-client/src/generated/models/DBForeignKeyEdge.ts b/packages/api-client/src/generated/models/DBForeignKeyEdge.ts
new file mode 100644
index 0000000..8aa6fad
--- /dev/null
+++ b/packages/api-client/src/generated/models/DBForeignKeyEdge.ts
@@ -0,0 +1,13 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type DBForeignKeyEdge = {
+ schema?: string;
+ table: string;
+ column: string;
+ ref_schema?: string;
+ ref_table: string;
+ ref_column: string;
+};
+
diff --git a/packages/api-client/src/generated/models/DBForeignKeysResponse.ts b/packages/api-client/src/generated/models/DBForeignKeysResponse.ts
new file mode 100644
index 0000000..f940403
--- /dev/null
+++ b/packages/api-client/src/generated/models/DBForeignKeysResponse.ts
@@ -0,0 +1,11 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { DBForeignKeyEdge } from './DBForeignKeyEdge';
+export type DBForeignKeysResponse = {
+ edges: Array;
+ raw?: string;
+ note?: string;
+};
+
diff --git a/packages/api-client/src/generated/schemas/$DBForeignKeyEdge.ts b/packages/api-client/src/generated/schemas/$DBForeignKeyEdge.ts
new file mode 100644
index 0000000..72def1a
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$DBForeignKeyEdge.ts
@@ -0,0 +1,30 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $DBForeignKeyEdge = {
+ properties: {
+ schema: {
+ type: 'string',
+ },
+ table: {
+ type: 'string',
+ isRequired: true,
+ },
+ column: {
+ type: 'string',
+ isRequired: true,
+ },
+ ref_schema: {
+ type: 'string',
+ },
+ ref_table: {
+ type: 'string',
+ isRequired: true,
+ },
+ ref_column: {
+ type: 'string',
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$DBForeignKeysResponse.ts b/packages/api-client/src/generated/schemas/$DBForeignKeysResponse.ts
new file mode 100644
index 0000000..6fb4f02
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$DBForeignKeysResponse.ts
@@ -0,0 +1,21 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $DBForeignKeysResponse = {
+ properties: {
+ edges: {
+ type: 'array',
+ contains: {
+ type: 'DBForeignKeyEdge',
+ },
+ isRequired: true,
+ },
+ raw: {
+ type: 'string',
+ },
+ note: {
+ type: 'string',
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/services/DatabasesService.ts b/packages/api-client/src/generated/services/DatabasesService.ts
index 321fb66..b908ab0 100644
--- a/packages/api-client/src/generated/services/DatabasesService.ts
+++ b/packages/api-client/src/generated/services/DatabasesService.ts
@@ -8,6 +8,7 @@ import type { DBConnectionListResponse } from '../models/DBConnectionListRespons
import type { DBConnectionTestResult } from '../models/DBConnectionTestResult';
import type { DBDatabaseListResponse } from '../models/DBDatabaseListResponse';
import type { DBDescribeResponse } from '../models/DBDescribeResponse';
+import type { DBForeignKeysResponse } from '../models/DBForeignKeysResponse';
import type { DBQueryRequest } from '../models/DBQueryRequest';
import type { DBQueryResponse } from '../models/DBQueryResponse';
import type { DBRedisRequest } from '../models/DBRedisRequest';
@@ -244,6 +245,38 @@ export class DatabasesService {
},
});
}
+ /**
+ * @returns DBForeignKeysResponse Foreign key edges for the schema graph
+ * @throws ApiError
+ */
+ public static listDbForeignKeys({
+ projectId,
+ connectionId,
+ database,
+ schema,
+ }: {
+ projectId: string,
+ connectionId: string,
+ database?: string,
+ schema?: string,
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/projects/{projectID}/db-connections/{connectionID}/foreign-keys',
+ path: {
+ 'projectID': projectId,
+ 'connectionID': connectionId,
+ },
+ query: {
+ 'database': database,
+ 'schema': schema,
+ },
+ errors: {
+ 404: `Error response`,
+ 502: `Error response`,
+ },
+ });
+ }
/**
* @returns DBQueryResponse Query result (at most 100 rows)
* @throws ApiError