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..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)')); @@ -110,6 +111,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)) {