feat(db-manager): add a schema diagram view

The database manager could only show one table at a time, so nothing in it
answered "how do these tables relate". This adds a Diagram mode beside the
data view, for PostgreSQL: the sidebar's checkbox tree picks the tables, and
the canvas draws them as cards on a pan/zoom surface.

There are deliberately no edges. A schema of any size turns an edge-drawn ERD
into a hairball, so the relationships surface on hover instead: hovering a
table lights every table it is linked to and the columns tying them together,
hovering a column lights only the column it is actually paired with.

Foreign keys come from a new ALL_FOREIGN_KEYS marker that reads the whole
database in one query. The existing FOREIGN_KEYS marker is per table, which
would be one job per card. It reads pg_constraint rather than
information_schema, whose constraint_column_usage loses which column of a
composite key pairs with which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP
This commit is contained in:
Diego Imbert
2026-09-08 18:23:58 +02:00
co-authored by Claude Opus 5
parent 448fce93f7
commit d2842e0751
12 changed files with 904 additions and 19 deletions
+1 -1
View File
@@ -1 +1 @@
e092518ee60e33160fee9ae91a4d109566f7b0ee
e42edf09f90bc65a0a6f94e471b22071e014b5b8
@@ -332,6 +332,7 @@ pub fn try_expand_internal_db_query(
// Metadata queries
"LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type),
"FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql),
"ALL_FOREIGN_KEYS" => expand_all_foreign_keys(db_type).map(ExpandedQuery::sql),
"PRIMARY_KEY_CONSTRAINT" => {
expand_primary_key_constraint(json_str, db_type).map(ExpandedQuery::sql)
}
@@ -2710,6 +2711,47 @@ fn expand_foreign_keys(json_str: &str, db_type: DbType) -> Result<String, String
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
}
fn expand_all_foreign_keys(db_type: DbType) -> Result<String, String> {
make_all_foreign_keys_query(db_type)
}
/// Every foreign key of the database, one row per referencing column.
///
/// Read from `pg_constraint` rather than `information_schema`: the latter's
/// `constraint_column_usage` loses the pairing of a composite key's columns,
/// which the diagram needs to line a source column up with the column it points
/// at. `unnest(conkey, confkey)` keeps them zipped in declaration order.
fn make_all_foreign_keys_query(db_type: DbType) -> Result<String, String> {
if db_type != DbType::Postgresql {
return Err(format!(
"The schema diagram is only supported for PostgreSQL, not {:?}",
db_type
));
}
Ok(String::from(
"SELECT
con.conname as fk_constraint_name,
src_ns.nspname as source_schema,
src.relname as source_table,
src_att.attname as source_column,
tgt_ns.nspname as target_schema,
tgt.relname as target_table,
tgt_att.attname as target_column,
k.ord as ordinal
FROM pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace
JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(src_attnum, tgt_attnum, ord) ON true
JOIN pg_catalog.pg_attribute src_att ON src_att.attrelid = src.oid AND src_att.attnum = k.src_attnum
JOIN pg_catalog.pg_attribute tgt_att ON tgt_att.attrelid = tgt.oid AND tgt_att.attnum = k.tgt_attnum
WHERE con.contype = 'f'
AND src_ns.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY src_ns.nspname, src.relname, con.conname, k.ord;",
))
}
fn expand_primary_key_constraint(json_str: &str, db_type: DbType) -> Result<String, String> {
let p: PrimaryKeyConstraintPayload = serde_json::from_str(json_str)
.map_err(|e| format!("Invalid PRIMARY_KEY_CONSTRAINT payload: {}", e))?;
@@ -4686,6 +4728,26 @@ mod tests {
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
}
// -----------------------------------------------------------------------
// ALL_FOREIGN_KEYS
// -----------------------------------------------------------------------
#[test]
fn test_expand_all_foreign_keys_postgresql() {
let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#;
let sql = expand_code(marker, &ScriptLang::Postgresql);
assert!(sql.contains("con.contype = 'f'"));
// Columns of a composite key must stay zipped with the ones they point at.
assert!(sql.contains("unnest(con.conkey, con.confkey) WITH ORDINALITY"));
}
#[test]
fn test_expand_all_foreign_keys_rejects_non_postgres() {
let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#;
let result = try_expand_internal_db_query(marker, &ScriptLang::Mysql);
assert!(result.unwrap().is_err());
}
// -----------------------------------------------------------------------
// PRIMARY_KEY_CONSTRAINT
// -----------------------------------------------------------------------
+105 -16
View File
@@ -5,6 +5,7 @@
EditIcon,
Loader2,
Plus,
Network,
Table2,
Trash2Icon,
UploadIcon
@@ -23,7 +24,7 @@
import Portal from './Portal.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
import type { Snippet } from 'svelte'
import { untrack, type Snippet } from 'svelte'
import {
dbSupportsTransactionalDdl,
diffTableEditorValues
@@ -33,6 +34,11 @@
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
import Star from './Star.svelte'
import type { Asset } from '$lib/gen'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import DbSchemaDiagram from './dbdiagram/DbSchemaDiagram.svelte'
import type { DbRelation } from './dbRelations'
import { logFeatureUsage } from '$lib/utils/featureUsage'
/** Represents a selected table with its schema */
export interface SelectedTable {
@@ -40,6 +46,9 @@
table: string
}
/** The right pane's content: the rows of one table, or the schema diagram. */
export type DbManagerViewMode = 'data' | 'diagram'
type Props = {
dbType: DbType
dbSchema: DBSchema
@@ -91,25 +100,48 @@
onImport
}: Props = $props()
// Helper to check if a table is selected in multi-select mode
function isTableSelected(schema: string, table: string): boolean {
return selectedTables.some((t) => t.schema === schema && t.table === table)
let viewMode = $state<DbManagerViewMode>('data')
// PostgreSQL is the only database whose foreign keys can be read for the whole
// database in one query; the others would need one job per table. A caller
// already using the sidebar checkboxes to collect tables keeps them.
let supportsDiagram = $derived(dbType === 'postgresql' && !multiSelectMode)
// The tables drawn on the diagram. Kept apart from `selectedTables` so that
// checking a table to see it in the diagram can never add it to whatever the
// caller's multi-select is collecting.
let diagramTables = $state<SelectedTable[]>([])
let showsCheckboxes = $derived(multiSelectMode || viewMode === 'diagram')
let checkedTables = $derived(viewMode === 'diagram' ? diagramTables : selectedTables)
function setCheckedTables(tables: SelectedTable[]) {
if (viewMode === 'diagram') diagramTables = tables
else selectedTables = tables
}
// Helper to check if a table is disabled (already added)
// Helper to check if a table is selected in multi-select mode
function isTableSelected(schema: string, table: string): boolean {
return checkedTables.some((t) => t.schema === schema && t.table === table)
}
// Helper to check if a table is disabled (already added). Only the caller's
// selection has tables that are already spoken for.
function isTableDisabled(schema: string, table: string): boolean {
return disabledTables.some((t) => t.schema === schema && t.table === table)
return (
viewMode !== 'diagram' && disabledTables.some((t) => t.schema === schema && t.table === table)
)
}
// Toggle table selection in multi-select mode
function toggleTableSelection(schema: string, table: string) {
if (isTableDisabled(schema, table)) return
const idx = selectedTables.findIndex((t) => t.schema === schema && t.table === table)
const idx = checkedTables.findIndex((t) => t.schema === schema && t.table === table)
if (idx >= 0) {
selectedTables = selectedTables.filter((_, i) => i !== idx)
setCheckedTables(checkedTables.filter((_, i) => i !== idx))
} else {
selectedTables = [...selectedTables, { schema, table }]
setCheckedTables([...checkedTables, { schema, table }])
}
}
@@ -146,13 +178,13 @@
if (isSchemaFullySelected(schema)) {
// Deselect all selectable tables in this schema
selectedTables = selectedTables.filter((t) => t.schema !== schema)
setCheckedTables(checkedTables.filter((t) => t.schema !== schema))
} else {
// Select all selectable tables in this schema
const newSelections = selectableTables
.filter((t) => !isTableSelected(schema, t))
.map((t) => ({ schema, table: t }))
selectedTables = [...selectedTables, ...newSelections]
setCheckedTables([...checkedTables, ...newSelections])
}
}
@@ -334,6 +366,38 @@
schemaKeys.map((s) => s.toLowerCase()).includes(sanitizedNewSchemaName.toLowerCase())
)
// Fetched once for the whole database rather than per table: the diagram needs
// every relation at once, and the per-table query would be one job each.
let relationsError = $state<string | undefined>(undefined)
let relations = resource(
[() => viewMode, () => colDefs],
async ([mode]): Promise<DbRelation[]> => {
// Keeps what was fetched when leaving the diagram, so coming back to it
// doesn't queue the query again.
if (mode !== 'diagram') return relations.current ?? []
relationsError = undefined
try {
return await dbSchemaOps.onFetchAllForeignKeys()
} catch (e) {
relationsError = (e as any)?.body ?? (e as Error)?.message ?? String(e)
return []
}
}
)
// Opening the diagram on an empty canvas would make it look broken, so the
// current schema is drawn to start with — unless it is big enough that drawing
// all of it is a choice the user should make.
const DIAGRAM_AUTOSELECT_LIMIT = 40
$effect(() => {
if (viewMode !== 'diagram' || diagramTables.length) return
const schemaKey = untrack(() => selected.schemaKey)
if (!schemaKey) return
const tables = Object.keys(dbSchema.schema[schemaKey] ?? {})
if (tables.length > DIAGRAM_AUTOSELECT_LIMIT) return
diagramTables = tables.map((table) => ({ schema: schemaKey, table }))
})
let _dbTable: DBTable | undefined = $state()
export const dbTable = () => _dbTable
</script>
@@ -344,7 +408,19 @@
{#if dbSelector}
{@render dbSelector()}
{/if}
{#if dbSupportsSchemas && !multiSelectMode}
{#if supportsDiagram}
<ToggleButtonGroup
bind:selected={viewMode}
noWFull
onSelected={(v) => logFeatureUsage('db_manager', 'view_mode', { key: v })}
>
{#snippet children({ item })}
<ToggleButton value="data" label="Data" icon={Table2} {item} />
<ToggleButton value="diagram" label="Diagram" icon={Network} {item} />
{/snippet}
</ToggleButtonGroup>
{/if}
{#if dbSupportsSchemas && !showsCheckboxes}
<Select
bind:value={selected.schemaKey}
items={safeSelectItems(schemaKeys)}
@@ -379,7 +455,7 @@
<ClearableInput bind:value={search} placeholder="Search table..." />
</div>
<div class="overflow-x-clip overflow-y-auto relative mt-3 border-y flex-1">
{#if multiSelectMode}
{#if showsCheckboxes}
<!-- Multi-select mode: show all schemas with their tables -->
{#if dbSupportsSchemas}
<!-- New schema button -->
@@ -608,7 +684,7 @@
{/each}
{/if}
</div>
{#if !multiSelectMode}
{#if !showsCheckboxes}
<Button
on:click={() => (dbTableEditorState = { open: true })}
wrapperClasses="mx-2 my-2 text-sm"
@@ -619,8 +695,21 @@
</Button>
{/if}
</Pane>
<Pane class="p-3 pt-1">
{#if tableKey && colDefs?.[tableKey]?.length}
<Pane class={viewMode === 'diagram' ? '' : 'p-3 pt-1'}>
{#if viewMode === 'diagram'}
<DbSchemaDiagram
{dbSchema}
{colDefs}
selectedTables={diagramTables}
relations={relations.current ?? []}
loading={relations.loading}
error={relationsError}
onOpenTable={({ schema, table }) => {
viewMode = 'data'
selectTable(schema, table)
}}
/>
{:else if tableKey && colDefs?.[tableKey]?.length}
{@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey, whereClause })}
<DBTable
{dbTableOps}
@@ -1078,7 +1078,8 @@
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, and how data tables and their migrations are set up and used, last 30
the flow editor, whether the database manager is opened on data or on the schema
diagram, and how data tables and their migrations are set up and used, last 30
days)</li
>
<li
@@ -1140,7 +1141,8 @@
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, and how data tables and their migrations are set up and used, last 30
the flow editor, whether the database manager is opened on data or on the schema
diagram, and how data tables and their migrations are set up and used, last 30
days)</li
>
<li
+16
View File
@@ -23,6 +23,7 @@ import {
transformSnowflakeForeignKeys,
type RawForeignKey
} from './apps/components/display/dbtable/queries/relationalKeys'
import { groupForeignKeyRows, type DbRelation, type RawAllForeignKeyRow } from './dbRelations'
export type IDbTableOps = {
dbType: DbType
@@ -255,6 +256,10 @@ export type IDbSchemaOps = {
table: string
schema?: string
}) => Promise<TableEditorForeignKey[]>
/** Every foreign key of the database in one query, for the schema diagram.
* PostgreSQL only — the other databases either don't expose foreign keys or
* can only be asked one table at a time. */
onFetchAllForeignKeys: () => Promise<DbRelation[]>
}
/** Thrown by a schema op when the user declines the out-of-order run warning.
@@ -443,6 +448,16 @@ export function dbSchemaOpsWithPreviewScripts({
return []
}
async function fetchAllForeignKeys(): Promise<DbRelation[]> {
if (dbType !== 'postgresql') return []
const content = makeMarker('ALL_FOREIGN_KEYS', {})
const rows = (await runScriptAndPollResult({
workspace,
requestBody: { args: dbArg, content, language, tag }
})) as RawAllForeignKeyRow[]
return Array.isArray(rows) ? groupForeignKeyRows(rows) : []
}
return {
onDelete: async ({ tableKey, schema }) => {
const content = makeMarker('DROP_TABLE', { table: tableKey, schema })
@@ -502,6 +517,7 @@ export function dbSchemaOpsWithPreviewScripts({
await applyDdl(migrationName('drop_schema', schema), content, downContent)
},
onFetchForeignKeys: fetchForeignKeys,
onFetchAllForeignKeys: fetchAllForeignKeys,
onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => {
const foreignKeys = await fetchForeignKeys({ table, schema })
let pk_constraint_name: string | undefined
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { buildRelationIndex, groupForeignKeyRows, type RawAllForeignKeyRow } from './dbRelations'
function row(over: Partial<RawAllForeignKeyRow>): RawAllForeignKeyRow {
return {
fk_constraint_name: 'fk',
source_schema: 'shop',
source_table: 'orders',
source_column: 'customer_id',
target_schema: 'shop',
target_table: 'customers',
target_column: 'id',
ordinal: 1,
...over
}
}
describe('groupForeignKeyRows', () => {
it('pairs a composite key by ordinal, whatever order the rows arrive in', () => {
const relations = groupForeignKeyRows([
row({
fk_constraint_name: 'returns_order_item_fkey',
source_table: 'returns',
source_column: 'sku',
target_table: 'order_items',
target_column: 'sku',
ordinal: 2
}),
row({
fk_constraint_name: 'returns_order_item_fkey',
source_table: 'returns',
source_column: 'order_id',
target_table: 'order_items',
target_column: 'order_id',
ordinal: 1
})
])
expect(relations).toHaveLength(1)
expect(relations[0].from.columns).toEqual(['order_id', 'sku'])
expect(relations[0].to.columns).toEqual(['order_id', 'sku'])
})
it('keeps same-named constraints on different tables apart', () => {
const relations = groupForeignKeyRows([
row({ fk_constraint_name: 'owner_fkey', source_table: 'orders' }),
row({ fk_constraint_name: 'owner_fkey', source_table: 'addresses' })
])
expect(relations.map((r) => r.from.table).sort()).toEqual(['addresses', 'orders'])
})
})
describe('buildRelationIndex', () => {
const index = buildRelationIndex(
groupForeignKeyRows([
row({ source_table: 'orders', source_column: 'customer_id', target_column: 'id' }),
row({
fk_constraint_name: 'items_order_fkey',
source_table: 'order_items',
source_column: 'order_id',
target_table: 'orders',
target_column: 'id'
})
])
)
it('links a column only to the column it is actually paired with', () => {
// `order_items.order_id` points at `orders.id`, not at the same-named
// column of every other table taking part in a relation.
expect([...(index.linkedColumns.get('shop.order_items.order_id') ?? [])]).toEqual([
'shop.orders.id'
])
})
it('relates tables in both directions', () => {
expect([...(index.relatedTables.get('shop.orders') ?? [])].sort()).toEqual([
'shop.customers',
'shop.order_items'
])
})
})
+136
View File
@@ -0,0 +1,136 @@
/** One row of the `ALL_FOREIGN_KEYS` marker: a single referencing column. */
export type RawAllForeignKeyRow = {
fk_constraint_name: string
source_schema: string
source_table: string
source_column: string
target_schema: string
target_table: string
target_column: string
ordinal: number | string
}
export type DbTableRef = { schema: string; table: string }
/** A foreign key, with its columns paired to the ones they point at by index. */
export type DbRelation = {
constraint: string
from: DbTableRef & { columns: string[] }
to: DbTableRef & { columns: string[] }
}
export function tableKeyOf(ref: DbTableRef): string {
return `${ref.schema}.${ref.table}`
}
export function columnKeyOf(ref: DbTableRef, column: string): string {
return columnKeyIn(tableKeyOf(ref), column)
}
export function columnKeyIn(tableKey: string, column: string): string {
return `${tableKey}.${column}`
}
/** Groups the flat rows into one relation per constraint, keeping the column
* pairs in the constraint's declaration order (the query's `ordinal`). */
export function groupForeignKeyRows(rows: RawAllForeignKeyRow[]): DbRelation[] {
const byConstraint = new Map<string, { relation: DbRelation; pairs: RawAllForeignKeyRow[] }>()
for (const raw of rows) {
const row = lowercaseKeys(raw)
if (!row.source_table || !row.target_table) continue
const key = `${row.source_schema}.${row.source_table}.${row.fk_constraint_name}`
let entry = byConstraint.get(key)
if (!entry) {
entry = {
relation: {
constraint: row.fk_constraint_name,
from: { schema: row.source_schema, table: row.source_table, columns: [] },
to: { schema: row.target_schema, table: row.target_table, columns: [] }
},
pairs: []
}
byConstraint.set(key, entry)
}
entry.pairs.push(row)
}
return Array.from(byConstraint.values()).map(({ relation, pairs }) => {
pairs.sort((a, b) => Number(a.ordinal) - Number(b.ordinal))
relation.from.columns = pairs.map((p) => p.source_column)
relation.to.columns = pairs.map((p) => p.target_column)
return relation
})
}
function lowercaseKeys(row: RawAllForeignKeyRow): RawAllForeignKeyRow {
const out: any = {}
for (const key of Object.keys(row)) out[key.toLowerCase()] = (row as any)[key]
return out as RawAllForeignKeyRow
}
/** Everything the diagram needs to answer "what lights up when this is hovered".
* Built once per fetched relation set, then read on every hover. */
export type RelationIndex = {
/** Tables reachable from a table by one foreign key, either direction. */
relatedTables: Map<string, Set<string>>
/** Columns a column is paired with by a foreign key, either direction. */
linkedColumns: Map<string, Set<string>>
/** Every column taking part in a relation of a table, keyed by table. Both
* the table's own referencing/referenced columns and the far ends. */
relatedColumns: Map<string, Set<string>>
/** Columns that reference another table — rendered with a link marker. */
referencingColumns: Set<string>
/** Columns another table points at. */
referencedColumns: Set<string>
/** The table a column key belongs to. Both parts are dot-joined names, so
* splitting a column key back apart is not safe. */
columnTable: Map<string, string>
}
export function buildRelationIndex(relations: DbRelation[]): RelationIndex {
const index: RelationIndex = {
relatedTables: new Map(),
linkedColumns: new Map(),
relatedColumns: new Map(),
referencingColumns: new Set(),
referencedColumns: new Set(),
columnTable: new Map()
}
for (const relation of relations) {
const fromTable = tableKeyOf(relation.from)
const toTable = tableKeyOf(relation.to)
add(index.relatedTables, fromTable, toTable)
add(index.relatedTables, toTable, fromTable)
const pairCount = Math.min(relation.from.columns.length, relation.to.columns.length)
for (let i = 0; i < pairCount; i++) {
const fromColumn = columnKeyOf(relation.from, relation.from.columns[i])
const toColumn = columnKeyOf(relation.to, relation.to.columns[i])
index.referencingColumns.add(fromColumn)
index.referencedColumns.add(toColumn)
index.columnTable.set(fromColumn, fromTable)
index.columnTable.set(toColumn, toTable)
add(index.linkedColumns, fromColumn, toColumn)
add(index.linkedColumns, toColumn, fromColumn)
// A self-referencing key would otherwise register only once.
for (const table of [fromTable, toTable]) {
add(index.relatedColumns, table, fromColumn)
add(index.relatedColumns, table, toColumn)
}
}
}
return index
}
function add(map: Map<string, Set<string>>, key: string, value: string) {
let set = map.get(key)
if (!set) map.set(key, (set = new Set()))
set.add(value)
}
@@ -0,0 +1,150 @@
<script lang="ts">
import { SvelteFlow, Controls, SvelteFlowProvider, type Node } from '@xyflow/svelte'
import '@xyflow/svelte/dist/base.css'
import { SvelteSet } from 'svelte/reactivity'
import { Loader2 } from 'lucide-svelte'
import Alert from '../common/alert/Alert.svelte'
import GraphZoomControls from '../graph/GraphZoomControls.svelte'
import DbTableNode from './DbTableNode.svelte'
import DiagramAutoFit from './DiagramAutoFit.svelte'
import { buildRelationIndex, type DbRelation } from '../dbRelations'
import { DbDiagramHighlight, setDbDiagramHighlight } from './dbDiagramHighlight.svelte'
import { buildDiagramTables, cardHeight, layoutTables, CARD_WIDTH } from './dbDiagramModel'
import type { DBSchema } from '$lib/stores'
import type { ColumnDef } from '../apps/components/display/dbtable/utils'
import type { SelectedTable } from '../DBManager.svelte'
interface Props {
dbSchema: DBSchema
colDefs: Record<string, ColumnDef[]> | undefined
selectedTables: SelectedTable[]
relations: DbRelation[]
loading?: boolean
error?: string | undefined
/** Opens a table in the data view. */
onOpenTable?: (table: SelectedTable) => void
}
let {
dbSchema,
colDefs,
selectedTables,
relations,
loading = false,
error = undefined,
onOpenTable
}: Props = $props()
let index = $derived(buildRelationIndex(relations))
const highlight = new DbDiagramHighlight(() => index)
setDbDiagramHighlight(highlight)
let expanded = new SvelteSet<string>()
let tables = $derived(buildDiagramTables(dbSchema, colDefs, selectedTables))
// Laid out from scratch on every change to the selection or to a card's
// height. There are no edges to keep stable, so a fresh packing reads better
// than leaving holes where deselected tables were.
let nodes = $state.raw<Node[]>([])
$effect(() => {
const positions = layoutTables(tables, expanded)
nodes = tables.map((table) => ({
id: table.key,
type: 'dbTable',
position: positions[table.key],
width: CARD_WIDTH,
height: cardHeight(table, expanded.has(table.key)),
data: {
table,
expanded: expanded.has(table.key),
index,
onToggleExpand: () => {
if (!expanded.delete(table.key)) expanded.add(table.key)
},
onOpenTable: () => onOpenTable?.({ schema: table.schema, table: table.table })
}
}))
})
let fitKey = $derived(tables.map((t) => t.key).join('\n'))
const nodeTypes = { dbTable: DbTableNode }
</script>
<div class="h-full w-full relative">
{#if error}
<div class="absolute inset-x-0 top-0 z-10 p-2">
<Alert type="error" title="Could not load the relationships" size="xs">{error}</Alert>
</div>
{/if}
{#if tables.length === 0}
<div class="h-full w-full center-center">
<span class="text-sm text-hint">Check tables in the sidebar to draw them here</span>
</div>
{:else}
<SvelteFlowProvider>
<SvelteFlow
bind:nodes
edges={[]}
{nodeTypes}
minZoom={0.1}
maxZoom={1.6}
proOptions={{ hideAttribution: true }}
elementsSelectable={false}
zoomOnDoubleClick={false}
deleteKey={null}
onpaneclick={() => highlight.clear()}
>
<div class="absolute inset-0 !bg-surface-secondary h-full"></div>
<DiagramAutoFit key={fitKey} />
<Controls
class="wm-db-diagram-controls"
position="bottom-right"
orientation="horizontal"
showLock={false}
showZoom={false}
showFitView={false}
>
<GraphZoomControls />
</Controls>
</SvelteFlow>
</SvelteFlowProvider>
{/if}
{#if loading}
<div
class="absolute top-2 left-2 z-10 flex items-center gap-2 text-xs text-tertiary bg-surface-secondary rounded px-2 py-1"
>
<Loader2 size={12} class="animate-spin" />
Loading relationships
</div>
{/if}
</div>
<style lang="postcss">
/* xy-flow's own control rules are nested and match ours exactly, so scoping by
the class passed to <Controls> outranks them rather than racing load order. */
:global(.svelte-flow__controls.wm-db-diagram-controls) {
@apply overflow-hidden rounded-md border border-border-light bg-surface;
box-shadow: none;
}
:global(.wm-db-diagram-controls .svelte-flow__controls-button) {
@apply bg-surface text-primary border-r border-border-light;
width: 32px;
height: 30px;
padding: 8px;
}
:global(.wm-db-diagram-controls .svelte-flow__controls-button:last-child) {
@apply border-r-0;
}
:global(.wm-db-diagram-controls .svelte-flow__controls-button:hover) {
@apply bg-surface-hover;
}
/* The glyphs are lucide, so undo xy-flow's `fill: currentColor` and its 12px cap. */
:global(.wm-db-diagram-controls .svelte-flow__controls-button svg) {
max-width: 16px;
max-height: 16px;
fill: none;
stroke: currentColor;
}
</style>
@@ -0,0 +1,118 @@
<script lang="ts">
import { ChevronDown, ChevronUp, KeyRound, Link2, Table2 } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { columnKeyIn, type RelationIndex } from '../dbRelations'
import { getDbDiagramHighlight } from './dbDiagramHighlight.svelte'
import {
CARD_WIDTH,
HEADER_HEIGHT,
ROW_HEIGHT,
hasExpandToggle,
hiddenColumnCount,
visibleColumns,
type DiagramTable
} from './dbDiagramModel'
interface Props {
data: {
table: DiagramTable
expanded: boolean
index: RelationIndex
onToggleExpand: () => void
onOpenTable: () => void
}
}
let { data }: Props = $props()
const highlight = getDbDiagramHighlight()
let table = $derived(data.table)
let columns = $derived(visibleColumns(table, data.expanded))
let hidden = $derived(hiddenColumnCount(table, data.expanded))
let isTarget = $derived(highlight.target?.table === table.key)
let lit = $derived(highlight.tables?.has(table.key) ?? true)
let dimmed = $derived(!!highlight.tables && !lit)
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'rounded-md border bg-surface-tertiary shadow-sm transition-opacity duration-150 overflow-hidden',
dimmed ? 'opacity-20' : 'opacity-100',
isTarget
? 'border-border-accent'
: lit && highlight.tables
? 'border-border-selected'
: 'border-border-light'
)}
style="width: {CARD_WIDTH}px"
onmouseenter={() => highlight.hover({ table: table.key })}
onmouseleave={() => highlight.hover(undefined)}
onclick={() => highlight.togglePin({ table: table.key })}
ondblclick={data.onOpenTable}
role="button"
tabindex="-1"
>
<div
class={twMerge(
'flex items-center gap-1.5 px-2 border-b border-border-light',
isTarget ? 'bg-surface-accent-selected' : 'bg-surface-secondary'
)}
style="height: {HEADER_HEIGHT}px"
title={table.key}
>
<Table2 size={13} class="shrink-0 text-secondary" />
<span class="truncate text-xs font-semibold text-emphasis">{table.table}</span>
<span class="truncate text-2xs text-tertiary ml-auto">{table.schema}</span>
</div>
{#each columns as column (column.name)}
{@const columnKey = columnKeyIn(table.key, column.name)}
{@const isLit = highlight.columns?.has(columnKey)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'flex items-center gap-1.5 px-2 text-2xs',
isLit ? 'bg-surface-accent-selected text-accent font-medium' : 'text-primary'
)}
style="height: {ROW_HEIGHT}px"
onmouseenter={() => highlight.hover({ table: table.key, column: column.name })}
onmouseleave={() => highlight.hover({ table: table.key })}
onclick={(e) => {
e.stopPropagation()
highlight.togglePin({ table: table.key, column: column.name })
}}
>
{#if column.isPrimaryKey}
<KeyRound size={11} class="shrink-0 text-yellow-500" />
{:else if data.index.referencingColumns.has(columnKey)}
<Link2 size={11} class="shrink-0 text-secondary" />
{:else}
<span class="shrink-0 w-[11px]"></span>
{/if}
<span class="truncate">{column.name}</span>
<span class="truncate text-hint ml-auto pl-2">{column.datatype}</span>
</div>
{/each}
{#if hasExpandToggle(table)}
<button
class="flex items-center gap-1 px-2 w-full text-2xs text-tertiary hover:bg-surface-hover"
style="height: {ROW_HEIGHT}px"
onclick={(e) => {
e.stopPropagation()
data.onToggleExpand()
}}
>
{#if data.expanded}
<ChevronUp size={11} class="shrink-0" />
<span>Show less</span>
{:else}
<ChevronDown size={11} class="shrink-0" />
<span>{hidden} more</span>
{/if}
</button>
{/if}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { useSvelteFlow } from '@xyflow/svelte'
import { tick } from 'svelte'
interface Props {
/** Refits whenever this changes — the set of drawn tables, not their
* heights, so expanding a card doesn't move the view under the pointer. */
key: string
}
let { key }: Props = $props()
// Its own component so the hook runs inside the flow, as GraphZoomControls does.
const { fitView } = useSvelteFlow()
$effect(() => {
key
tick().then(() => fitView({ maxZoom: 1 }))
})
</script>
@@ -0,0 +1,80 @@
import { getContext, setContext } from 'svelte'
import { columnKeyIn, type RelationIndex } from '../dbRelations'
/** What the pointer is on: a whole table, or one of its columns. */
export type HighlightTarget = { table: string; column?: string }
const EMPTY: ReadonlySet<string> = new Set()
/**
* The diagram draws no edges, so the relationships only exist while something is
* highlighted. This owns that: what is hovered (or pinned by a click), and which
* tables and columns it lights up.
*/
export class DbDiagramHighlight {
#getIndex: () => RelationIndex
hovered = $state<HighlightTarget | undefined>(undefined)
/** Set by clicking, so the relations can be read without holding the pointer
* still. A hover takes precedence while it lasts. */
pinned = $state<HighlightTarget | undefined>(undefined)
target = $derived(this.hovered ?? this.pinned)
/** Tables that stay lit. Undefined means nothing is highlighted and every
* table renders at full strength. */
tables = $derived.by<ReadonlySet<string> | undefined>(() => {
const target = this.target
if (!target) return undefined
if (target.column) {
const columnTable = this.#getIndex().columnTable
const tables = new Set([target.table])
for (const column of this.columns ?? EMPTY) {
const table = columnTable.get(column)
if (table) tables.add(table)
}
return tables
}
return new Set([target.table, ...(this.#getIndex().relatedTables.get(target.table) ?? EMPTY)])
})
/** Columns that stay lit inside the highlighted tables. */
columns = $derived.by<ReadonlySet<string> | undefined>(() => {
const target = this.target
if (!target) return undefined
const index = this.#getIndex()
if (target.column) {
const key = columnKeyIn(target.table, target.column)
return new Set([key, ...(index.linkedColumns.get(key) ?? EMPTY)])
}
return index.relatedColumns.get(target.table) ?? EMPTY
})
constructor(getIndex: () => RelationIndex) {
this.#getIndex = getIndex
}
hover(target: HighlightTarget | undefined) {
this.hovered = target
}
/** Clicking the already-pinned target unpins it. */
togglePin(target: HighlightTarget) {
const current = this.pinned
this.pinned =
current && current.table === target.table && current.column === target.column
? undefined
: target
}
clear() {
this.hovered = undefined
this.pinned = undefined
}
}
const contextKey = 'DbDiagramHighlight'
export const setDbDiagramHighlight = (highlight: DbDiagramHighlight) =>
setContext(contextKey, highlight)
export const getDbDiagramHighlight = () => getContext<DbDiagramHighlight>(contextKey)
@@ -0,0 +1,131 @@
import type { DBSchema } from '$lib/stores'
import type { ColumnDef } from '../apps/components/display/dbtable/utils'
import type { SelectedTable } from '../DBManager.svelte'
import { tableKeyOf } from '../dbRelations'
export type DiagramColumn = {
name: string
datatype: string
isPrimaryKey: boolean
}
export type DiagramTable = {
/** `schema.table`, the key relations and highlights are addressed by. */
key: string
schema: string
table: string
columns: DiagramColumn[]
}
/** Card geometry. The node component must render exactly the rows the layout
* budgeted for, or cards overlap. */
export const CARD_WIDTH = 248
export const HEADER_HEIGHT = 34
export const ROW_HEIGHT = 22
const CARD_PADDING = 6
const GAP_X = 32
const GAP_Y = 20
/** Rows shown before a card collapses into a "+N more" row. */
export const COLLAPSED_ROW_LIMIT = 12
/** Cards are packed towards this width/height ratio, so the canvas opens on
* something screen-shaped rather than one very long column. */
const TARGET_ASPECT = 1.7
export function visibleColumns(table: DiagramTable, expanded: boolean): DiagramColumn[] {
return expanded ? table.columns : table.columns.slice(0, COLLAPSED_ROW_LIMIT)
}
export function hiddenColumnCount(table: DiagramTable, expanded: boolean): number {
return table.columns.length - visibleColumns(table, expanded).length
}
/** Whether the card carries a row to expand or collapse its column list. */
export function hasExpandToggle(table: DiagramTable): boolean {
return table.columns.length > COLLAPSED_ROW_LIMIT
}
export function cardHeight(table: DiagramTable, expanded: boolean): number {
const rows = visibleColumns(table, expanded).length + (hasExpandToggle(table) ? 1 : 0)
return HEADER_HEIGHT + rows * ROW_HEIGHT + CARD_PADDING
}
/** The tables to draw, in the order they are laid out. */
export function buildDiagramTables(
dbSchema: DBSchema,
colDefs: Record<string, ColumnDef[]> | undefined,
selected: SelectedTable[]
): DiagramTable[] {
if (dbSchema.lang === 'graphql') return []
const tables: DiagramTable[] = []
for (const { schema, table } of selected) {
const key = tableKeyOf({ schema, table })
const fromColDefs = colDefs?.[key]
let columns: DiagramColumn[]
if (fromColDefs?.length) {
columns = fromColDefs.map((col) => ({
name: col.field ?? '',
datatype: col.datatype ?? '',
isPrimaryKey: !!col.isprimarykey
}))
} else {
// The cached schema has no primary-key flag; it only stands in until the
// column metadata query lands.
const schemaColumns = dbSchema.schema[schema]?.[table]
if (!schemaColumns) continue
columns = Object.entries(schemaColumns).map(([name, col]) => ({
name,
datatype: col.type,
isPrimaryKey: false
}))
}
tables.push({ key, schema, table, columns })
}
tables.sort((a, b) => a.schema.localeCompare(b.schema) || a.table.localeCompare(b.table))
return tables
}
/** Packs the cards into balanced columns, in reading order. Runs again on every
* change to the selection, so it must be deterministic: the same tables always
* land in the same place. */
export function layoutTables(
tables: DiagramTable[],
expanded: Set<string>
): Record<string, { x: number; y: number }> {
const heights = tables.map((t) => cardHeight(t, expanded.has(t.key)))
const stacked = heights.reduce((a, b) => a + b, 0) + GAP_Y * Math.max(0, tables.length - 1)
let columnCount = 1
let bestScore = Infinity
for (let c = 1; c <= tables.length; c++) {
const width = c * CARD_WIDTH + (c - 1) * GAP_X
const height = stacked / c
// Compared in log space so twice-too-wide and twice-too-tall score alike.
const score = Math.abs(Math.log(width / height / TARGET_ASPECT))
if (score < bestScore) {
bestScore = score
columnCount = c
}
}
const target = stacked / columnCount
const positions: Record<string, { x: number; y: number }> = {}
let column = 0
let columnHeight = 0
tables.forEach((table, i) => {
const height = heights[i]
// Overshooting the target by more than stopping short of it costs less in
// the next column.
const overshoot = columnHeight + height - target
if (column < columnCount - 1 && columnHeight > 0 && overshoot > target - columnHeight) {
column++
columnHeight = 0
}
positions[table.key] = { x: column * (CARD_WIDTH + GAP_X), y: columnHeight }
columnHeight += height + GAP_Y
})
return positions
}