diff --git a/lib/features/erd/erd_source.dart b/lib/features/erd/erd_source.dart index 10e2fc47..cbf98c57 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 extends SqlExecutionDelegate { + 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..2efaa746 100644 --- a/lib/features/workspace/generic_table_view.dart +++ b/lib/features/workspace/generic_table_view.dart @@ -6,6 +6,8 @@ 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'; @@ -36,6 +38,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 +60,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 +96,82 @@ 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: 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() { + 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 +710,7 @@ class GenericTableViewState extends material.State { ), const Gap(6), if (!widget.isView && !widget.isMaterializedView) ...[ + buildViewSwitch(), buildEditModeButton(), const Gap(4), ], @@ -711,7 +802,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 +820,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..8ca1de5f --- /dev/null +++ b/test/features/workspace/generic_table_view_relations_test.dart @@ -0,0 +1,49 @@ +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'; + +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 { + final state = await pumpGenericTableView(t, FakeTableDataDelegate()); + expect(find.byType(ErdView), findsNothing); + + state.selectView(1); + 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(); + // 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); + }); +} 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, ), ),