From 82587d3def7de4c6077ca2d967d1504d1ec9ce88 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 05:09:58 +0300 Subject: [PATCH 1/5] feat(table): Data | Relations view for tables (#1149) Tables with a diagram source get a Data | Relations switch in the table toolbar, for PostgreSQL, MySQL and SQLite. Relations draws the table's neighbourhood (depth 1 or 2) with the table focused, using the table browser's own read-only session through a SqlExecutionDelegate adapter. The grid stays alive in an IndexedStack, so staged edits, page and filter survive the switch. Views and extension tables get no switch. A table without foreign keys shows its empty state with Open full diagram. --- lib/features/erd/erd_source.dart | 33 +++++++ lib/features/erd/erd_view.dart | 63 +++++++++--- .../extensions/extension_table_view.dart | 1 + lib/features/mysql/mysql_table_view.dart | 2 + .../postgresql/postgres_table_view.dart | 8 +- lib/features/sqlite/sqlite_table_view.dart | 2 + .../workspace/generic_table_view.dart | 99 ++++++++++++++++++- .../generic_table_view_relations_test.dart | 46 +++++++++ test/support/generic_table_view_harness.dart | 2 + 9 files changed, 239 insertions(+), 17 deletions(-) create mode 100644 test/features/workspace/generic_table_view_relations_test.dart diff --git a/lib/features/erd/erd_source.dart b/lib/features/erd/erd_source.dart index 10e2fc47..11d4c551 100644 --- a/lib/features/erd/erd_source.dart +++ b/lib/features/erd/erd_source.dart @@ -2,6 +2,7 @@ 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'; +import 'package:querya_desktop/features/workspace/table_data_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` @@ -32,3 +33,35 @@ class SqlErdSource implements ErdSource { depth: depth, ); } + +/// Runs catalog queries on a table browser's own read-only session, through +/// [TableDataMutationDelegate.loadCustomSql]. The table delegate keeps its own +/// connection, so the diagram never borrows the SQL editor's session. +class TableDataSqlAdapter implements SqlExecutionDelegate { + const TableDataSqlAdapter(this.table); + + final TableDataMutationDelegate table; + + @override + Future executeQuery( + String sql, { + int? limit, + Duration? timeout, + }) async { + final page = await table.loadCustomSql(sql); + return SqlExecutionResult(columns: page.columns, rows: page.rows); + } + + @override + Future explainQuery(String sql) => + Future.error(UnsupportedError('Explain is not used by the diagram')); + + @override + bool get supportsExplain => false; + + @override + Future cancelQuery() async => table.cancel(interruptIfBusy: true); + + @override + bool get supportsTransactions => false; +} diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 3c5b4f3f..476aa71d 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -51,17 +51,26 @@ enum _ExportAction { mermaid, svg, png, copyMermaid } class ErdView extends material.StatefulWidget { const ErdView({ super.key, - required this.delegate, - required this.dialect, + this.delegate, + this.dialect, this.databaseName = '', this.source, this.focusTable, + this.neighbourhoodDepth, this.onOpenTable, + this.onOpenFullDiagram, this.onSaveFile, - }); + }) : assert(source != null || (delegate != null && dialect != null)); + + /// Query runner and dialect, used when [source] is not given. + final SqlExecutionDelegate? delegate; + final SqlDialect? dialect; + + /// Draw only [focusTable] and the tables within this many foreign keys. + final int? neighbourhoodDepth; - final SqlExecutionDelegate delegate; - final SqlDialect dialect; + /// Opens the diagram's full schema, from a neighbourhood's empty state. + final material.VoidCallback? onOpenFullDiagram; /// Where the tables come from; the delegate and dialect when omitted. final ErdSource? source; @@ -140,9 +149,11 @@ class _ErdViewState extends material.State { _error = null; }); try { - final schema = await (widget.source ?? - SqlErdSource(delegate: widget.delegate, dialect: widget.dialect)) - .loadSchema(); + final depth = widget.neighbourhoodDepth; + final focus = widget.focusTable; + final schema = depth != null && focus != null + ? await _source.loadNeighbourhood(focus, depth: depth) + : await _source.loadSchema(); if (!mounted) return; setState(() { _schema = schema; @@ -154,13 +165,8 @@ class _ErdViewState extends material.State { 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); - } + final picked = _focusIn(schema); + if (picked != null) _selectTable(_visibleOf(schema), picked); }); } catch (e) { if (!mounted) return; @@ -179,6 +185,20 @@ class _ErdViewState extends material.State { : ErdRouter.route(_visibleOf(schema), layout); } + ErdSource get _source => + widget.source ?? + SqlErdSource(delegate: widget.delegate!, dialect: widget.dialect!); + + /// The focused table as named in [schema]: `public.orders` falls back to + /// `orders` when the current schema names it without a prefix. + String? _focusIn(ErdSchema schema) { + final f = widget.focusTable; + if (f == null) return null; + if (schema.tables.any((t) => t.name == f)) return f; + final bare = f.contains('.') ? f.substring(f.indexOf('.') + 1) : f; + return schema.tables.any((t) => t.name == bare) ? bare : null; + } + /// The schema as drawn: hidden tables gone, columns cut to keys in keys-only /// mode, and collapsed cards without columns. ErdSchema _visibleOf(ErdSchema schema) { @@ -542,6 +562,19 @@ class _ErdViewState extends material.State { 'This schema has no tables, or this role cannot see them.', ), ); + } else if (widget.neighbourhoodDepth != null && + widget.focusTable != null && + schema.relations.isEmpty) { + body = material.Center( + child: QueryaEmptyState( + icon: material.Icon(material.Icons.link_off_rounded, + color: wb.mutedForeground), + title: 'No foreign keys to or from ${widget.focusTable}', + description: 'This table has no relations within the schema.', + actionLabel: 'Open full diagram', + onAction: widget.onOpenFullDiagram, + ), + ); } else { body = material.CallbackShortcuts( bindings: _bindings(), diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index a82ed1bf..5b80dde8 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -457,6 +457,7 @@ class _ExtensionTableViewState extends material.State { return GenericTableView( key: _genericKey, delegate: _delegate, + showRelations: false, title: tableTitle, tableTitle: '${widget.database}.${widget.tableName}', dialect: SqlDialect.postgres, // Generic fallback diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index ebc06ad7..16893601 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -430,6 +430,8 @@ class _MysqlTableViewState extends material.State { ), ), const Gap(6), + state.buildViewSwitch(), + const Gap(4), if (!widget.isView) ...[ state.buildEditModeButton(), const Gap(4), diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index ab711e03..25b82a71 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -408,7 +408,13 @@ class _PostgresTableViewState extends material.State { onRefresh: () => unawaited(state.refresh()), editAction: widget.isView || widget.isMaterializedView ? null - : state.buildEditModeButton(), + : material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + state.buildViewSwitch(), + state.buildEditModeButton(), + ], + ), ); }, ); diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 9ec6104b..00f0b75f 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -467,6 +467,8 @@ class _SqliteTableViewState extends material.State { ), ), const Gap(6), + state.buildViewSwitch(), + const Gap(4), if (!widget.isView) ...[ state.buildEditModeButton(), const Gap(4), diff --git a/lib/features/workspace/generic_table_view.dart b/lib/features/workspace/generic_table_view.dart index 6c95042e..0f93550c 100644 --- a/lib/features/workspace/generic_table_view.dart +++ b/lib/features/workspace/generic_table_view.dart @@ -6,10 +6,14 @@ import 'package:querya_desktop/core/storage/local_db.dart' show MutationAuditSou import 'package:querya_desktop/core/storage/mutation_audit_recorder.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/features/erd/erd_source.dart'; +import 'package:querya_desktop/features/erd/erd_view.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/results_tab.dart'; import 'package:querya_desktop/features/workspace/table_data_delegate.dart'; import 'package:querya_desktop/features/workspace/table_view_staging.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Reusable database table browser supporting: @@ -36,6 +40,9 @@ class GenericTableView extends material.StatefulWidget { this.subToolbar, this.errorAction, this.showExportToolbar = true, + this.showRelations = true, + this.onOpenNeighbour, + this.onOpenFullDiagram, }); final TableDataMutationDelegate delegate; @@ -55,6 +62,15 @@ class GenericTableView extends material.StatefulWidget { final material.Widget? errorAction; final bool showExportToolbar; + /// Offers the Data | Relations switch. Extension tables have no diagram. + final bool showRelations; + + /// Double click on a neighbour in the Relations view. + final void Function(String table)? onOpenNeighbour; + + /// Opens the full diagram focused on this table. + final void Function(String table)? onOpenFullDiagram; + @override material.State createState() => GenericTableViewState(); } @@ -82,6 +98,77 @@ class GenericTableViewState extends material.State { bool _editMode = false; bool get isLoading => _loading; + + bool _relationsMode = false; + bool _relationsVisited = false; + int _relationsDepth = 1; + + bool get showsRelations => + widget.showRelations && !widget.isView && !widget.isMaterializedView; + + /// Neighbourhood source over this table's own read-only session. + ErdSource get _erdSource => SqlErdSource( + delegate: TableDataSqlAdapter(widget.delegate), + dialect: widget.dialect, + ); + + /// Data | Relations, for tables with a diagram source. + material.Widget buildViewSwitch() { + if (!showsRelations) return const material.SizedBox.shrink(); + return QueryaTabStrip( + labels: const ['Data', 'Relations'], + selectedIndex: _relationsMode ? 1 : 0, + onSelected: (i) => setState(() { + _relationsMode = i == 1; + if (_relationsMode) _relationsVisited = true; + }), + ); + } + + /// Neighbourhood of this table. Built on the first switch and kept, so + /// the grid's state (staged edits, page, filter) is never disturbed. + material.Widget _buildRelations() { + final schema = widget.schema; + final focus = schema == null || schema.isEmpty + ? widget.tableName + : '$schema.${widget.tableName}'; + void openFull() => widget.onOpenFullDiagram?.call(focus); + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.all(8), + child: material.Row( + children: [ + for (final d in const [1, 2]) + QueryaActionButton( + key: material.ValueKey('relations_depth_$d'), + label: 'Depth $d', + onPressed: () => setState(() => _relationsDepth = d), + ), + const material.Spacer(), + QueryaActionButton( + key: const material.ValueKey('relations_open_diagram'), + label: 'Open full diagram', + onPressed: widget.onOpenFullDiagram == null ? null : openFull, + ), + ], + ), + ), + material.Expanded( + child: ErdView( + key: material.ValueKey('relations_${focus}_$_relationsDepth'), + source: _erdSource, + focusTable: focus, + neighbourhoodDepth: _relationsDepth, + onOpenTable: widget.onOpenNeighbour, + onOpenFullDiagram: + widget.onOpenFullDiagram == null ? null : openFull, + ), + ), + ], + ); + } String? get error => _error; List get columnNames => _columnNames; List> get rows => _rows; @@ -620,6 +707,7 @@ class GenericTableViewState extends material.State { ), const Gap(6), if (!widget.isView && !widget.isMaterializedView) ...[ + buildViewSwitch(), buildEditModeButton(), const Gap(4), ], @@ -711,7 +799,10 @@ class GenericTableViewState extends material.State { ), if (widget.subToolbar != null) widget.subToolbar!, material.Expanded( - child: ResultsTab( + child: material.IndexedStack( + index: _relationsMode ? 1 : 0, + children: [ + ResultsTab( columns: _columnNames, rows: _rows, errorMessage: _error, @@ -726,6 +817,12 @@ class GenericTableViewState extends material.State { isSaving: _isSaving, errorAction: widget.errorAction, ), + if (_relationsVisited) + _buildRelations() + else + const material.SizedBox.shrink(), + ], + ), ), ], ), diff --git a/test/features/workspace/generic_table_view_relations_test.dart b/test/features/workspace/generic_table_view_relations_test.dart new file mode 100644 index 00000000..ab2c5221 --- /dev/null +++ b/test/features/workspace/generic_table_view_relations_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/erd/erd_view.dart'; + +import '../../support/fake_table_data_delegate.dart'; +import '../../support/generic_table_view_harness.dart'; + +void main() { + testWidgets('a table offers the Data | Relations switch', (t) async { + await pumpGenericTableView(t, FakeTableDataDelegate()); + expect(find.text('Relations'), findsOneWidget); + }); + + testWidgets('a view does not offer the switch', (t) async { + await pumpGenericTableView(t, FakeTableDataDelegate(), isView: true); + expect(find.text('Relations'), findsNothing); + }); + + testWidgets('extension tables do not offer the switch', (t) async { + await pumpGenericTableView(t, FakeTableDataDelegate(), + showRelations: false); + expect(find.text('Relations'), findsNothing); + }); + + testWidgets('Relations shows the neighbourhood and Data keeps its place', + (t) async { + await pumpGenericTableView(t, FakeTableDataDelegate()); + expect(find.byType(ErdView), findsNothing); + + await t.tap(find.text('Relations')); + await t.pump(); + await t.pump(); + expect(find.byType(ErdView), findsOneWidget); + expect(find.byKey(const material.ValueKey('relations_depth_1')), + findsOneWidget); + + await t.tap(find.byKey(const material.ValueKey('relations_depth_2'))); + await t.pump(); + expect(find.byKey(const material.ValueKey('relations_depth_2')), + findsOneWidget); + + await t.tap(find.text('Data')); + await t.pump(); + expect(find.byType(ErdView), findsOneWidget); + }); +} diff --git a/test/support/generic_table_view_harness.dart b/test/support/generic_table_view_harness.dart index 786bf9db..36a692b7 100644 --- a/test/support/generic_table_view_harness.dart +++ b/test/support/generic_table_view_harness.dart @@ -14,6 +14,7 @@ Future pumpGenericTableView( SqlDialect dialect = SqlDialect.postgres, bool isReadOnly = false, bool isView = false, + bool showRelations = true, int limit = 200, }) async { await tester.binding.setSurfaceSize(const material.Size(1300, 800)); @@ -30,6 +31,7 @@ Future pumpGenericTableView( schema: 'public', isReadOnly: isReadOnly, isView: isView, + showRelations: showRelations, limit: limit, ), ), From 7ecd3c8e179a71ed19f4e949f4d83b48c0d1ae88 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 05:14:02 +0300 Subject: [PATCH 2/5] fix(table): extend SqlExecutionDelegate in the table adapter, drop duplicate imports (#1149) --- lib/features/erd/erd_source.dart | 2 +- lib/features/workspace/generic_table_view.dart | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/features/erd/erd_source.dart b/lib/features/erd/erd_source.dart index 11d4c551..85cc727a 100644 --- a/lib/features/erd/erd_source.dart +++ b/lib/features/erd/erd_source.dart @@ -37,7 +37,7 @@ class SqlErdSource implements ErdSource { /// Runs catalog queries on a table browser's own read-only session, through /// [TableDataMutationDelegate.loadCustomSql]. The table delegate keeps its own /// connection, so the diagram never borrows the SQL editor's session. -class TableDataSqlAdapter implements SqlExecutionDelegate { +class TableDataSqlAdapter extends SqlExecutionDelegate { const TableDataSqlAdapter(this.table); final TableDataMutationDelegate table; diff --git a/lib/features/workspace/generic_table_view.dart b/lib/features/workspace/generic_table_view.dart index 0f93550c..46634324 100644 --- a/lib/features/workspace/generic_table_view.dart +++ b/lib/features/workspace/generic_table_view.dart @@ -12,8 +12,6 @@ import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart' import 'package:querya_desktop/features/workspace/results_tab.dart'; import 'package:querya_desktop/features/workspace/table_data_delegate.dart'; import 'package:querya_desktop/features/workspace/table_view_staging.dart'; -import 'package:querya_desktop/shared/widgets/querya_action_button.dart'; -import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Reusable database table browser supporting: From 2886aff6aaae13d6947532c528e086f64a4302c1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 05:18:09 +0300 Subject: [PATCH 3/5] fix(erd): the table adapter constructor is not const (#1149) --- lib/features/erd/erd_source.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/erd/erd_source.dart b/lib/features/erd/erd_source.dart index 85cc727a..cbf98c57 100644 --- a/lib/features/erd/erd_source.dart +++ b/lib/features/erd/erd_source.dart @@ -38,7 +38,7 @@ class SqlErdSource implements ErdSource { /// [TableDataMutationDelegate.loadCustomSql]. The table delegate keeps its own /// connection, so the diagram never borrows the SQL editor's session. class TableDataSqlAdapter extends SqlExecutionDelegate { - const TableDataSqlAdapter(this.table); + TableDataSqlAdapter(this.table); final TableDataMutationDelegate table; From cdab13b2c6f9e6358b1fd7ab943aa444c95d118e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 05:21:19 +0300 Subject: [PATCH 4/5] test(table): exercise the Relations selection through selectView (#1149) --- lib/features/workspace/generic_table_view.dart | 13 +++++++++---- .../generic_table_view_relations_test.dart | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/features/workspace/generic_table_view.dart b/lib/features/workspace/generic_table_view.dart index 46634324..2efaa746 100644 --- a/lib/features/workspace/generic_table_view.dart +++ b/lib/features/workspace/generic_table_view.dart @@ -116,13 +116,18 @@ class GenericTableViewState extends material.State { return QueryaTabStrip( labels: const ['Data', 'Relations'], selectedIndex: _relationsMode ? 1 : 0, - onSelected: (i) => setState(() { - _relationsMode = i == 1; - if (_relationsMode) _relationsVisited = true; - }), + onSelected: selectView, ); } + /// 0 shows the grid (Data), 1 the neighbourhood (Relations). + void selectView(int index) { + setState(() { + _relationsMode = index == 1; + if (_relationsMode) _relationsVisited = true; + }); + } + /// Neighbourhood of this table. Built on the first switch and kept, so /// the grid's state (staged edits, page, filter) is never disturbed. material.Widget _buildRelations() { diff --git a/test/features/workspace/generic_table_view_relations_test.dart b/test/features/workspace/generic_table_view_relations_test.dart index ab2c5221..85cbd125 100644 --- a/test/features/workspace/generic_table_view_relations_test.dart +++ b/test/features/workspace/generic_table_view_relations_test.dart @@ -24,10 +24,10 @@ void main() { testWidgets('Relations shows the neighbourhood and Data keeps its place', (t) async { - await pumpGenericTableView(t, FakeTableDataDelegate()); + final state = await pumpGenericTableView(t, FakeTableDataDelegate()); expect(find.byType(ErdView), findsNothing); - await t.tap(find.text('Relations')); + state.selectView(1); await t.pump(); await t.pump(); expect(find.byType(ErdView), findsOneWidget); From e4a28bb6d7995aa90f75aa4e23f2b17b70114015 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 05:24:24 +0300 Subject: [PATCH 5/5] test(table): the neighbourhood stays alive offstage after Data (#1149) --- .../workspace/generic_table_view_relations_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/features/workspace/generic_table_view_relations_test.dart b/test/features/workspace/generic_table_view_relations_test.dart index 85cbd125..8ca1de5f 100644 --- a/test/features/workspace/generic_table_view_relations_test.dart +++ b/test/features/workspace/generic_table_view_relations_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/erd/erd_view.dart'; +import 'package:querya_desktop/features/workspace/results_tab.dart'; import '../../support/fake_table_data_delegate.dart'; import '../../support/generic_table_view_harness.dart'; @@ -41,6 +42,8 @@ void main() { await t.tap(find.text('Data')); await t.pump(); - expect(find.byType(ErdView), findsOneWidget); + // Offstage, not gone: the neighbourhood and the grid both stay alive. + expect(find.byType(ErdView, skipOffstage: false), findsOneWidget); + expect(find.byType(ResultsTab, skipOffstage: false), findsOneWidget); }); }