diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 8340c8d90e..b4ab9a4b87 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -e092518ee60e33160fee9ae91a4d109566f7b0ee +e42edf09f90bc65a0a6f94e471b22071e014b5b8 diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index 60d74f189d..085f0281e2 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -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 Result { + 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 { + 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 { 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 // ----------------------------------------------------------------------- diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 7520cc5013..987b19963b 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -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('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([]) + + 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(undefined) + let relations = resource( + [() => viewMode, () => colDefs], + async ([mode]): Promise => { + // 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 @@ -344,7 +408,19 @@ {#if dbSelector} {@render dbSelector()} {/if} - {#if dbSupportsSchemas && !multiSelectMode} + {#if supportsDiagram} + logFeatureUsage('db_manager', 'view_mode', { key: v })} + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {#if dbSupportsSchemas && !showsCheckboxes}