From 19e47d5fd9223b965ce3e7b2c87c008fd0b92413 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 04:55:42 +0300 Subject: [PATCH 1/2] refactor(erd): ErdSource per connection, schema-aware catalog, neighbourhood load (#1147) ErdSource gives the whole schema or the tables within a depth of one table. The PostgreSQL catalog names tables outside the current schema schema.table and reads foreign keys across user schemas, so a reference into another schema is drawn. The neighbourhood walks foreign keys both ways up to the depth, stops on cycles and self references, and loads columns only for the tables it found. ErdView takes an optional source and a focused table. Extension SQL workspaces hide the Diagram button. --- lib/features/erd/erd_catalog.dart | 97 ++++++++++++++++--- lib/features/erd/erd_source.dart | 34 +++++++ lib/features/erd/erd_view.dart | 29 +++++- .../extensions/extension_sql_workspace.dart | 1 + .../workspace/generic_sql_workspace.dart | 8 +- test/features/erd/erd_test.dart | 58 +++++++++++ 6 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 lib/features/erd/erd_source.dart diff --git a/lib/features/erd/erd_catalog.dart b/lib/features/erd/erd_catalog.dart index cd6290bb..e2207707 100644 --- a/lib/features/erd/erd_catalog.dart +++ b/lib/features/erd/erd_catalog.dart @@ -5,28 +5,39 @@ import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; /// Catalog queries per dialect. Column query yields `table, column, type, /// isPk, isNullable`; FK query yields `table, column, refTable, refColumn`. /// +/// Tables outside the current schema (PostgreSQL) are named `schema.table`, +/// so relations between schemas keep the same keys as the rest of the diagram. +/// /// Every output column has a distinct alias: the SQLite driver returns rows as /// maps keyed by column name, so two `name` columns would collapse into one. class ErdCatalog { ErdCatalog._(); - static String columnsSql(SqlDialect d) => switch (d) { + /// PostgreSQL name of relation alias [rel] in namespace alias [ns]: + /// `table` for the current schema, `schema.table` otherwise. + static String _pgQualified(String rel, String ns) => + "CASE WHEN $ns.nspname = current_schema() THEN $rel.relname " + "ELSE $ns.nspname || '.' || $rel.relname END"; + + /// Columns of [schema] (PostgreSQL) or of the connected database. [schema] + /// `null` means the current schema. + static String columnsSql(SqlDialect d, {String? schema}) => switch (d) { // pg_catalog is readable by every role, unlike information_schema, // which hides keys of tables the current role has only SELECT on. SqlDialect.postgres => ''' -SELECT c.relname AS table_name, a.attname AS column_name, +SELECT ${_pgQualified('c', 'ns')} AS table_name, a.attname AS column_name, format_type(a.atttypid, a.atttypmod) AS data_type, CASE WHEN pk.oid IS NULL THEN 0 ELSE 1 END AS is_pk, CASE WHEN a.attnotnull THEN 0 ELSE 1 END AS is_nullable FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid -JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace +JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace LEFT JOIN pg_catalog.pg_constraint pk ON pk.conrelid = c.oid AND pk.contype = 'p' AND a.attnum = ANY (pk.conkey) -WHERE n.nspname = current_schema() +WHERE ns.nspname = ${schema == null ? 'current_schema()' : "'${_lit(schema)}'"} AND c.relkind IN ('r', 'p') AND a.attnum > 0 AND NOT a.attisdropped -ORDER BY c.relname, a.attnum''', +ORDER BY table_name, a.attnum''', SqlDialect.mysql => ''' SELECT c.table_name, c.column_name, c.column_type, IF(c.column_key = 'PRI', 1, 0), @@ -46,16 +57,17 @@ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid''', }; + /// Foreign keys of every user schema. A reference across schemas is named + /// `schema.table` on the side that lives outside the current schema. static String foreignKeysSql(SqlDialect d) => switch (d) { // conkey and confkey are paired by position, so a composite key yields - // one row per column pair. References outside the current schema are - // skipped: their table names would otherwise match same-named tables. + // one row per column pair. SqlDialect.postgres => ''' -SELECT src.relname AS table_name, sa.attname AS column_name, - ref.relname AS ref_table, ra.attname AS ref_column +SELECT ${_pgQualified('src', 'ns')} AS table_name, sa.attname AS column_name, + ${_pgQualified('ref', 'rn')} AS ref_table, ra.attname AS ref_column FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_class src ON src.oid = con.conrelid -JOIN pg_catalog.pg_namespace n ON n.oid = src.relnamespace +JOIN pg_catalog.pg_namespace ns ON ns.oid = src.relnamespace JOIN pg_catalog.pg_class ref ON ref.oid = con.confrelid JOIN pg_catalog.pg_namespace rn ON rn.oid = ref.relnamespace CROSS JOIN LATERAL unnest(con.conkey, con.confkey) AS k(src_attnum, ref_attnum) @@ -64,7 +76,9 @@ JOIN pg_catalog.pg_attribute sa JOIN pg_catalog.pg_attribute ra ON ra.attrelid = con.confrelid AND ra.attnum = k.ref_attnum WHERE con.contype = 'f' - AND n.nspname = current_schema() AND rn.nspname = current_schema()''', + AND ns.nspname NOT IN ('pg_catalog', 'information_schema') + AND ns.nspname NOT LIKE 'pg_toast%' + AND rn.nspname NOT IN ('pg_catalog', 'information_schema')''', SqlDialect.mysql => ''' SELECT table_name, column_name, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage @@ -77,6 +91,15 @@ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ''', }; + static String _lit(String s) => s.replaceAll("'", "''"); + + /// Schema part of a diagram name: `sales` for `sales.orders`, `null` for a + /// table of the current schema. + static String? schemaOf(String name) { + final dot = name.indexOf('.'); + return dot < 0 ? null : name.substring(0, dot); + } + /// Loads the schema of the current database through [delegate]. static Future load( SqlExecutionDelegate delegate, @@ -86,4 +109,56 @@ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' final fks = await delegate.executeQuery(foreignKeysSql(dialect)); return ErdSchema.fromCatalog(columnRows: cols.rows, fkRows: fks.rows); } + + /// Tables within [depth] foreign keys of [table] in either direction, with + /// their columns. Cycles and self references end the walk. + static Future loadNeighbourhood( + SqlExecutionDelegate delegate, + SqlDialect dialect, { + required String table, + int depth = 1, + }) async { + final fks = (await delegate.executeQuery(foreignKeysSql(dialect))).rows; + final names = neighbourhood(fks, table, depth); + + final cols = >[]; + final schemas = {for (final n in names) schemaOf(n)}; + for (final s in schemas) { + final rows = await delegate.executeQuery( + columnsSql(dialect, schema: dialect == SqlDialect.postgres ? s : null), + ); + cols.addAll(rows.rows.where((r) => r.isNotEmpty && names.contains(r[0]))); + } + return ErdSchema.fromCatalog(columnRows: cols, fkRows: [ + for (final r in fks) + if (r.length >= 4 && names.contains(r[0]) && names.contains(r[2])) r, + ]); + } + + /// Names of the tables within [depth] foreign keys of [table], including + /// [table] itself. Pure, for tests. + static Set neighbourhood( + List> fkRows, + String table, + int depth, + ) { + final adjacent = >{}; + for (final r in fkRows) { + if (r.length < 4) continue; + adjacent.putIfAbsent(r[0], () => {}).add(r[2]); + adjacent.putIfAbsent(r[2], () => {}).add(r[0]); + } + final seen = {table}; + var frontier = {table}; + for (var i = 0; i < depth && frontier.isNotEmpty; i++) { + final next = {}; + for (final t in frontier) { + for (final n in adjacent[t] ?? const {}) { + if (seen.add(n)) next.add(n); + } + } + frontier = next; + } + return seen; + } } diff --git a/lib/features/erd/erd_source.dart b/lib/features/erd/erd_source.dart new file mode 100644 index 00000000..10e2fc47 --- /dev/null +++ b/lib/features/erd/erd_source.dart @@ -0,0 +1,34 @@ +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/erd/erd_catalog.dart'; +import 'package:querya_desktop/features/erd/erd_model.dart'; +import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; + +/// Where the diagram reads its tables from: the whole schema, or the tables +/// around one table. Names are as the diagram shows them (`schema.table` +/// outside the current schema). +abstract interface class ErdSource { + Future loadSchema(); + + /// [table] and the tables within [depth] foreign keys of it, either way. + Future loadNeighbourhood(String table, {int depth = 1}); +} + +/// Reads the catalog through a query runner for one dialect. +class SqlErdSource implements ErdSource { + const SqlErdSource({required this.delegate, required this.dialect}); + + final SqlExecutionDelegate delegate; + final SqlDialect dialect; + + @override + Future loadSchema() => ErdCatalog.load(delegate, dialect); + + @override + Future loadNeighbourhood(String table, {int depth = 1}) => + ErdCatalog.loadNeighbourhood( + delegate, + dialect, + table: table, + depth: depth, + ); +} diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index c0fdb316..3c5b4f3f 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -13,7 +13,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/erd/erd_canvas_controls.dart'; -import 'package:querya_desktop/features/erd/erd_catalog.dart'; +import 'package:querya_desktop/features/erd/erd_source.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; import 'package:querya_desktop/features/erd/erd_geometry.dart'; import 'package:querya_desktop/features/erd/erd_layout.dart'; @@ -54,6 +54,8 @@ class ErdView extends material.StatefulWidget { required this.delegate, required this.dialect, this.databaseName = '', + this.source, + this.focusTable, this.onOpenTable, this.onSaveFile, }); @@ -61,6 +63,12 @@ class ErdView extends material.StatefulWidget { final SqlExecutionDelegate delegate; final SqlDialect dialect; + /// Where the tables come from; the delegate and dialect when omitted. + final ErdSource? source; + + /// Table to pick and centre once the schema is drawn. + final String? focusTable; + /// Names the exported files: `-erd.svg`. final String databaseName; @@ -132,15 +140,28 @@ class _ErdViewState extends material.State { _error = null; }); try { - final schema = await ErdCatalog.load(widget.delegate, widget.dialect); + final schema = await (widget.source ?? + SqlErdSource(delegate: widget.delegate, dialect: widget.dialect)) + .loadSchema(); if (!mounted) return; setState(() { _schema = schema; _setLayout(ErdLayout.compute(_visibleOf(schema))); _loading = false; }); - // The viewport is measured after this frame; fit the diagram then. - material.WidgetsBinding.instance.addPostFrameCallback((_) => _fit()); + // The viewport is measured after this frame: fit the diagram, then pick + // and centre the focused table. + material.WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _fit(); + final focus = widget.focusTable; + final full = _schema; + if (focus != null && + full != null && + full.tables.any((t) => t.name == focus)) { + _selectTable(_visibleOf(full), focus); + } + }); } catch (e) { if (!mounted) return; setState(() { diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index 7af42076..4103a659 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -227,6 +227,7 @@ class _ExtensionSqlWorkspaceState extends material.State initialTabTitle: widget.selectedObject?.name, supportsAutocommit: false, supportsStmtTimeout: false, + showDiagram: false, effectiveDatabaseName: () => widget.connectionRow.databaseName ?? '', headerBadge: material.Row( mainAxisSize: material.MainAxisSize.min, diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 1c0daba2..15e7d48b 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -44,6 +44,7 @@ class GenericSqlWorkspace extends material.StatefulWidget { this.initialTabTitle, this.transactionOpenNotifier, this.isReadOnly = false, + this.showDiagram = true, this.supportsAutocommit = false, this.initialAutocommit = true, this.onAutocommitChanged, @@ -65,6 +66,10 @@ class GenericSqlWorkspace extends material.StatefulWidget { final material.ValueNotifier? transactionOpenNotifier; final bool isReadOnly; + /// Whether the Diagram tab is offered. Extension drivers have no diagram + /// source yet, so their workspace turns it off. + final bool showDiagram; + final bool supportsAutocommit; final bool initialAutocommit; final void Function(bool value)? onAutocommitChanged; @@ -990,7 +995,8 @@ class GenericSqlWorkspaceState extends material.State { tooltip: 'Interrupt the running query', onPressed: () => cancelRunning(session), ), - QueryaActionButton( + if (widget.showDiagram) + QueryaActionButton( key: const material.ValueKey('open_diagram_tab'), label: 'Diagram', onPressed: openDiagramTab, diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 084cfe7e..7e7d60aa 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -110,6 +110,64 @@ void main() { expect(s.tables[1].columns[1].isNullable, isTrue); }); + test('postgres names tables of other schemas schema.table', () { + expect(ErdCatalog.foreignKeysSql(SqlDialect.postgres), + contains("|| '.' ||")); + expect(ErdCatalog.columnsSql(SqlDialect.postgres, schema: 'sales'), + contains("nspname = 'sales'")); + }); + + test('neighbourhood follows foreign keys up to the requested depth', () { + final fks = [ + ['b', 'a_id', 'a', 'id'], + ['c', 'b_id', 'b', 'id'], + ['d', 'x', 'z', 'id'], + ]; + expect(ErdCatalog.neighbourhood(fks, 'a', 1), {'a', 'b'}); + expect(ErdCatalog.neighbourhood(fks, 'a', 2), {'a', 'b', 'c'}); + expect(ErdCatalog.neighbourhood(fks, 'z', 5), {'z', 'd'}); + }); + + test('cycles and self references end the walk', () { + final fks = [ + ['a', 'b_id', 'b', 'id'], + ['b', 'a_id', 'a', 'id'], + ['a', 'parent', 'a', 'id'], + ]; + expect(ErdCatalog.neighbourhood(fks, 'a', 9), {'a', 'b'}); + }); + + test('a table without relations is its own neighbourhood', () { + expect(ErdCatalog.neighbourhood(const [], 'lonely', 2), {'lonely'}); + }); + + test('loadNeighbourhood loads only the tables around the one asked for', + () async { + final delegate = FakeSqlExecutionDelegate(onExecute: (sql) { + if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + return const SqlExecutionResult(rows: [ + ['a', 'id', 'INTEGER', '1'], + ['b', 'id', 'INTEGER', '1'], + ['b', 'a_id', 'INTEGER', '0'], + ['c', 'id', 'INTEGER', '1'], + ]); + } + if (sql == ErdCatalog.foreignKeysSql(SqlDialect.sqlite)) { + return const SqlExecutionResult(rows: [ + ['b', 'a_id', 'a', 'id'], + ]); + } + return const SqlExecutionResult(); + }); + final s = await ErdCatalog.loadNeighbourhood( + delegate, + SqlDialect.sqlite, + table: 'a', + ); + expect(s.tables.map((t) => t.name), ['a', 'b']); + expect(s.relations.length, 1); + }); + test('load runs both queries through the delegate', () async { final delegate = FakeSqlExecutionDelegate(onExecute: (sql) { if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { From 8e66816b57b8eef05491db515ac59534155e127e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 05:02:16 +0300 Subject: [PATCH 2/2] test(erd): forbid information_schema views, not the schema name (#1147) --- test/features/erd/erd_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 7e7d60aa..e68e3132 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -58,7 +58,8 @@ void main() { ErdCatalog.foreignKeysSql(SqlDialect.postgres), ]) { expect(sql, contains('pg_catalog.')); - expect(sql, isNot(contains('information_schema'))); + // Schema names may still mention information_schema to exclude it. + expect(sql, isNot(contains('information_schema.'))); } expect(ErdCatalog.foreignKeysSql(SqlDialect.postgres), contains('unnest(con.conkey, con.confkey)'));