mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: schema graph via FK introspection
- GET .../db-connections/:id/foreign-keys — per-dialect information_schema/pragma query through dbx_execute_query, identifier-whitelisted, read-role gated - Databases page: Graph tab renders tables as radial SVG nodes with FK edges; hover highlights adjacency, click inspects the table - demo stubs for edges + wider demo table set Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
627bd89d5e
commit
d57f30cd59
12 files changed
+496
-2
No files matched your search
@@ -150,6 +150,20 @@ func (h *HTTPHandler) dbxSchema(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, result)
|
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) {
|
func (h *HTTPHandler) dbxQuery(c *gin.Context) {
|
||||||
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -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/tables", h.dbxTables)
|
||||||
api.GET("/projects/:projectID/db-connections/:connectionID/describe", h.dbxDescribe)
|
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/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/query", h.dbxQuery)
|
||||||
api.POST("/projects/:projectID/db-connections/:connectionID/redis", h.dbxRedis)
|
api.POST("/projects/:projectID/db-connections/:connectionID/redis", h.dbxRedis)
|
||||||
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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
|
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) {
|
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)
|
row, err := s.connForTool(ctx, actor, projectID, connectionID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1014,6 +1014,29 @@ paths:
|
|||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
"502":
|
"502":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$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:
|
/projects/{projectID}/db-connections/{connectionID}/query:
|
||||||
post:
|
post:
|
||||||
tags: [Databases]
|
tags: [Databases]
|
||||||
@@ -2014,6 +2037,34 @@ components:
|
|||||||
context:
|
context:
|
||||||
type: string
|
type: string
|
||||||
required: [context]
|
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:
|
DBQueryRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -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<string, string[][]> = {
|
const demoColumns: Record<string, string[][]> = {
|
||||||
users: [
|
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() {
|
async getDbSchemaContext() {
|
||||||
await this.delay();
|
await this.delay();
|
||||||
return { context: "## users\nType: BASE TABLE\n- id uuid NOT NULL PK\n- email text NOT NULL" };
|
return { context: "## users\nType: BASE TABLE\n- id uuid NOT NULL PK\n- email text NOT NULL" };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DatabasesService,
|
DatabasesService,
|
||||||
CreateDBConnectionRequest,
|
CreateDBConnectionRequest,
|
||||||
type DBConnection,
|
type DBConnection,
|
||||||
|
type DBForeignKeyEdge,
|
||||||
type DBMDTable,
|
type DBMDTable,
|
||||||
type DBObject,
|
type DBObject,
|
||||||
type DBQueryResponse,
|
type DBQueryResponse,
|
||||||
@@ -29,7 +30,7 @@ interface DatabasesPageProps {
|
|||||||
demoMode: boolean;
|
demoMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceTab = "browse" | "query" | "console";
|
type WorkspaceTab = "browse" | "query" | "graph" | "console";
|
||||||
|
|
||||||
interface ConnForm {
|
interface ConnForm {
|
||||||
name: string;
|
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<string>();
|
||||||
|
|
||||||
|
const positions = createMemo(() => {
|
||||||
|
const m = new Map<string, { x: number; y: number }>();
|
||||||
|
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 (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} class="w-full" style="display:block" role="img" aria-label="Schema graph">
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="fk-arrow"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
refX="9"
|
||||||
|
refY="5"
|
||||||
|
markerWidth="7"
|
||||||
|
markerHeight="7"
|
||||||
|
orient="auto-start-reverse"
|
||||||
|
>
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="var(--text-3)" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<For each={props.edges}>
|
||||||
|
{(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 (
|
||||||
|
<Show when={a() && b()}>
|
||||||
|
<path
|
||||||
|
d={path()}
|
||||||
|
fill="none"
|
||||||
|
stroke={adjacent(e) ? "var(--accent)" : "var(--border-strong)"}
|
||||||
|
stroke-width={adjacent(e) ? 2 : 1.2}
|
||||||
|
marker-end="url(#fk-arrow)"
|
||||||
|
opacity={hover() && !adjacent(e) ? 0.2 : 0.9}
|
||||||
|
>
|
||||||
|
<title>{`${e.table}.${e.column} → ${e.ref_table}.${e.ref_column}`}</title>
|
||||||
|
</path>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<For each={props.nodes}>
|
||||||
|
{(name) => {
|
||||||
|
const p = () => positions().get(name)!;
|
||||||
|
const w = () => nodeW(name);
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
transform={`translate(${p().x - w() / 2}, ${p().y - 13})`}
|
||||||
|
style="cursor:pointer"
|
||||||
|
onMouseEnter={() => setHover(name)}
|
||||||
|
onMouseLeave={() => setHover(undefined)}
|
||||||
|
onClick={() => props.onPick(name)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
width={w()}
|
||||||
|
height="26"
|
||||||
|
rx="6"
|
||||||
|
fill={hover() === name ? "var(--accent-muted)" : "var(--surface-2)"}
|
||||||
|
stroke={hover() === name ? "var(--accent)" : "var(--border-strong)"}
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={w() / 2}
|
||||||
|
y="17"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="11"
|
||||||
|
font-family="monospace"
|
||||||
|
fill={hover() === name ? "var(--accent)" : "var(--text-1)"}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DatabasesPage(props: DatabasesPageProps) {
|
export function DatabasesPage(props: DatabasesPageProps) {
|
||||||
const [status, setStatus] = createSignal<DBXStatus | null>(null);
|
const [status, setStatus] = createSignal<DBXStatus | null>(null);
|
||||||
const [conns, setConns] = createSignal<DBConnection[]>([]);
|
const [conns, setConns] = createSignal<DBConnection[]>([]);
|
||||||
@@ -127,6 +248,11 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
|||||||
const [described, setDescribed] = createSignal<{ name: string; table?: DBMDTable; raw?: string }>();
|
const [described, setDescribed] = createSignal<{ name: string; table?: DBMDTable; raw?: string }>();
|
||||||
const [describeBusy, setDescribeBusy] = createSignal(false);
|
const [describeBusy, setDescribeBusy] = createSignal(false);
|
||||||
|
|
||||||
|
const [edges, setEdges] = createSignal<DBForeignKeyEdge[]>([]);
|
||||||
|
const [edgesNote, setEdgesNote] = createSignal("");
|
||||||
|
const [graphBusy, setGraphBusy] = createSignal(false);
|
||||||
|
const [graphKey, setGraphKey] = createSignal("");
|
||||||
|
|
||||||
const [sql, setSql] = createSignal("SELECT 1;");
|
const [sql, setSql] = createSignal("SELECT 1;");
|
||||||
const [queryResult, setQueryResult] = createSignal<DBQueryResponse>();
|
const [queryResult, setQueryResult] = createSignal<DBQueryResponse>();
|
||||||
const [queryBusy, setQueryBusy] = createSignal(false);
|
const [queryBusy, setQueryBusy] = createSignal(false);
|
||||||
@@ -195,6 +321,9 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
|||||||
setDescribed(undefined);
|
setDescribed(undefined);
|
||||||
setQueryResult(undefined);
|
setQueryResult(undefined);
|
||||||
setConsoleLog([]);
|
setConsoleLog([]);
|
||||||
|
setEdges([]);
|
||||||
|
setEdgesNote("");
|
||||||
|
setGraphKey("");
|
||||||
const conn = conns().find((c) => c.id === id);
|
const conn = conns().find((c) => c.id === id);
|
||||||
if (!conn) return;
|
if (!conn) return;
|
||||||
if (!sqlish(conn.db_type)) {
|
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<string>((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 runQuery = async () => {
|
||||||
const conn = active();
|
const conn = active();
|
||||||
if (!conn || !sql().trim()) return;
|
if (!conn || !sql().trim()) return;
|
||||||
@@ -490,6 +655,15 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
|||||||
>
|
>
|
||||||
Query
|
Query
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class={`btn btn-ghost btn-sm ${tab() === "graph" ? "text-accent" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("graph");
|
||||||
|
void loadGraph();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Graph
|
||||||
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!sqlish(active()!.db_type)}>
|
<Show when={!sqlish(active()!.db_type)}>
|
||||||
<button class="btn btn-ghost btn-sm text-accent">
|
<button class="btn btn-ghost btn-sm text-accent">
|
||||||
@@ -608,6 +782,36 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* schema graph tab */}
|
||||||
|
<Show when={tab() === "graph" && sqlish(active()!.db_type)}>
|
||||||
|
<Show
|
||||||
|
when={!graphBusy()}
|
||||||
|
fallback={<p class="p-6 text-xs text-text-3">Building graph…</p>}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={graphNodes().length > 0}
|
||||||
|
fallback={
|
||||||
|
<p class="p-6 text-xs text-text-3">
|
||||||
|
{edgesNote() || "No tables in scope — pick a database first."}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SchemaGraph
|
||||||
|
nodes={graphNodes()}
|
||||||
|
edges={edges()}
|
||||||
|
onPick={(t) => {
|
||||||
|
setTab("browse");
|
||||||
|
void describe(t);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p class="px-4 pb-3 text-xs text-text-3">
|
||||||
|
{edges().length} foreign key{edges().length === 1 ? "" : "s"} · click a table to inspect it
|
||||||
|
<Show when={edgesNote()}> · {edgesNote()}</Show>
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* query tab */}
|
{/* query tab */}
|
||||||
<Show when={tab() === "query" && sqlish(active()!.db_type)}>
|
<Show when={tab() === "query" && sqlish(active()!.db_type)}>
|
||||||
<div class="px-4 pt-3">
|
<div class="px-4 pt-3">
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export type { DBConnectionListResponse } from './models/DBConnectionListResponse
|
|||||||
export type { DBConnectionTestResult } from './models/DBConnectionTestResult';
|
export type { DBConnectionTestResult } from './models/DBConnectionTestResult';
|
||||||
export type { DBDatabaseListResponse } from './models/DBDatabaseListResponse';
|
export type { DBDatabaseListResponse } from './models/DBDatabaseListResponse';
|
||||||
export type { DBDescribeResponse } from './models/DBDescribeResponse';
|
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 { DBMDTable } from './models/DBMDTable';
|
||||||
export type { DBObject } from './models/DBObject';
|
export type { DBObject } from './models/DBObject';
|
||||||
export type { DBQueryRequest } from './models/DBQueryRequest';
|
export type { DBQueryRequest } from './models/DBQueryRequest';
|
||||||
@@ -118,6 +120,8 @@ export { $DBConnectionListResponse } from './schemas/$DBConnectionListResponse';
|
|||||||
export { $DBConnectionTestResult } from './schemas/$DBConnectionTestResult';
|
export { $DBConnectionTestResult } from './schemas/$DBConnectionTestResult';
|
||||||
export { $DBDatabaseListResponse } from './schemas/$DBDatabaseListResponse';
|
export { $DBDatabaseListResponse } from './schemas/$DBDatabaseListResponse';
|
||||||
export { $DBDescribeResponse } from './schemas/$DBDescribeResponse';
|
export { $DBDescribeResponse } from './schemas/$DBDescribeResponse';
|
||||||
|
export { $DBForeignKeyEdge } from './schemas/$DBForeignKeyEdge';
|
||||||
|
export { $DBForeignKeysResponse } from './schemas/$DBForeignKeysResponse';
|
||||||
export { $DBMDTable } from './schemas/$DBMDTable';
|
export { $DBMDTable } from './schemas/$DBMDTable';
|
||||||
export { $DBObject } from './schemas/$DBObject';
|
export { $DBObject } from './schemas/$DBObject';
|
||||||
export { $DBQueryRequest } from './schemas/$DBQueryRequest';
|
export { $DBQueryRequest } from './schemas/$DBQueryRequest';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<DBForeignKeyEdge>;
|
||||||
|
raw?: string;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -8,6 +8,7 @@ import type { DBConnectionListResponse } from '../models/DBConnectionListRespons
|
|||||||
import type { DBConnectionTestResult } from '../models/DBConnectionTestResult';
|
import type { DBConnectionTestResult } from '../models/DBConnectionTestResult';
|
||||||
import type { DBDatabaseListResponse } from '../models/DBDatabaseListResponse';
|
import type { DBDatabaseListResponse } from '../models/DBDatabaseListResponse';
|
||||||
import type { DBDescribeResponse } from '../models/DBDescribeResponse';
|
import type { DBDescribeResponse } from '../models/DBDescribeResponse';
|
||||||
|
import type { DBForeignKeysResponse } from '../models/DBForeignKeysResponse';
|
||||||
import type { DBQueryRequest } from '../models/DBQueryRequest';
|
import type { DBQueryRequest } from '../models/DBQueryRequest';
|
||||||
import type { DBQueryResponse } from '../models/DBQueryResponse';
|
import type { DBQueryResponse } from '../models/DBQueryResponse';
|
||||||
import type { DBRedisRequest } from '../models/DBRedisRequest';
|
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<DBForeignKeysResponse> {
|
||||||
|
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)
|
* @returns DBQueryResponse Query result (at most 100 rows)
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
|
|||||||
Reference in new issue
Block a user