mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
448fce93f7
commit
d2842e0751
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user