diff --git a/lib/core/actions/table_view_command_bridge.dart b/lib/core/actions/table_view_command_bridge.dart index ddf71232..2bb002bc 100644 --- a/lib/core/actions/table_view_command_bridge.dart +++ b/lib/core/actions/table_view_command_bridge.dart @@ -9,6 +9,8 @@ class TableViewCommandBridge { Object? _owner; void Function(int index)? _onSelectView; + int? _pendingView; + DateTime? _pendingAt; bool get isActive => _onSelectView != null; @@ -31,9 +33,31 @@ class TableViewCommandBridge { /// 0 shows the grid (Data), 1 the neighbourhood (Relations). void invokeSelectView(int index) => _onSelectView?.call(index); + /// Asks the next table browser that opens to start in view [index] + /// ("Show relations", or walking the graph from a Relations view). A request + /// older than a few seconds is dropped, so a table that did not open does + /// not change a later one. + void requestViewForNextTable(int index) { + _pendingView = index; + _pendingAt = DateTime.now(); + } + + /// The view asked for by [requestViewForNextTable], once. + int? takePendingView() { + final view = _pendingView, at = _pendingAt; + _pendingView = null; + _pendingAt = null; + if (view == null || at == null) return null; + return DateTime.now().difference(at) < const Duration(seconds: 3) + ? view + : null; + } + @visibleForTesting void resetForTest() { _owner = null; _onSelectView = null; + _pendingView = null; + _pendingAt = null; } } diff --git a/lib/features/erd/erd_table_names.dart b/lib/features/erd/erd_table_names.dart new file mode 100644 index 00000000..c4b2cd31 --- /dev/null +++ b/lib/features/erd/erd_table_names.dart @@ -0,0 +1,62 @@ +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + +/// Turns a diagram table name into what the rest of the app needs to open it. +/// +/// The diagram names a PostgreSQL table of the current schema `orders` and one +/// of another schema `sales.orders` (see `ErdCatalog`). MySQL and SQLite names +/// are never qualified, so a dot there is part of the name. +abstract final class ErdTableNames { + /// Schema used for an unqualified PostgreSQL name, as the Quick Switcher + /// does when an object has no schema. + static const String defaultPostgresSchema = 'public'; + + /// Schema (null for the current one) and table of a diagram name. + static (String? schema, String table) split(String name, SqlDialect dialect) { + if (dialect != SqlDialect.postgres) return (null, name); + final dot = name.indexOf('.'); + if (dot <= 0 || dot == name.length - 1) return (null, name); + return (name.substring(0, dot), name.substring(dot + 1)); + } + + static String _quote(String id, SqlDialect dialect) => dialect == SqlDialect.mysql + ? '`${id.replaceAll('`', '``')}`' + : '"${id.replaceAll('"', '""')}"'; + + /// The name as SQL: `"sales"."orders"`, `"orders"`, `` `orders` ``. + static String qualifiedSql(String name, SqlDialect dialect) { + final (schema, table) = split(name, dialect); + final t = _quote(table, dialect); + return schema == null ? t : '${_quote(schema, dialect)}.$t'; + } + + /// `SELECT * FROM LIMIT ;` for the SQL editor. + static String selectSql(String name, SqlDialect dialect, {int limit = 100}) => + 'SELECT * FROM ${qualifiedSql(name, dialect)} LIMIT $limit;'; + + /// The table as the Quick Switcher opens it in the table browser. + static QueryaSchemaObject schemaObject( + String name, + SqlDialect dialect, { + required String database, + }) { + final (schema, table) = split(name, dialect); + return switch (dialect) { + SqlDialect.postgres => QueryaSchemaObject.postgres( + database: database, + schema: schema ?? defaultPostgresSchema, + name: table, + kind: QueryaSchemaObjectKind.table, + ), + SqlDialect.mysql => QueryaSchemaObject.mysql( + database: database, + name: table, + kind: QueryaSchemaObjectKind.table, + ), + SqlDialect.sqlite => QueryaSchemaObject.sqlite( + name: table, + kind: QueryaSchemaObjectKind.table, + ), + }; + } +} diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index d2fd8edc..7e69183b 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -55,6 +55,8 @@ class ErdView extends material.StatefulWidget { this.focusTable, this.neighbourhoodDepth, this.onOpenTable, + this.onOpenInSql, + this.onShowRelations, this.onOpenFullDiagram, this.onSaveFile, }); @@ -76,6 +78,12 @@ class ErdView extends material.StatefulWidget { /// Called on double tap of a table card. final void Function(String table)? onOpenTable; + + /// "Open in SQL" in a card's menu: the table's rows in the SQL editor. + final void Function(String table)? onOpenInSql; + + /// "Show relations" in a card's menu: the table browser's Relations view. + final void Function(String table)? onShowRelations; final ErdFileSaver? onSaveFile; @override @@ -757,11 +765,28 @@ class _ErdViewState extends material.State { : 1, child: ContextMenu( items: [ - MenuButton( - onPressed: (_) => - widget.onOpenTable?.call(t.name), - child: const Text('Open data'), - ), + if (widget.onOpenTable case final open?) + MenuButton( + key: material.ValueKey( + 'erd_menu_open_${t.name}'), + onPressed: (_) => open(t.name), + child: const Text('Open data'), + ), + if (widget.onOpenInSql case final inSql?) + MenuButton( + key: material.ValueKey( + 'erd_menu_sql_${t.name}'), + onPressed: (_) => inSql(t.name), + child: const Text('Open in SQL'), + ), + if (widget.onShowRelations + case final relations?) + MenuButton( + key: material.ValueKey( + 'erd_menu_relations_${t.name}'), + onPressed: (_) => relations(t.name), + child: const Text('Show relations'), + ), MenuButton( onPressed: (_) { Clipboard.setData( diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 16893601..16a7b203 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -341,6 +341,7 @@ class _MysqlTableViewState extends material.State { dialect: SqlDialect.mysql, tableName: widget.tableName, schema: widget.database, + database: widget.database, isView: widget.isView, isReadOnly: widget.isReadOnly, limit: widget.limit, diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 25b82a71..5158aa13 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -376,6 +376,7 @@ class _PostgresTableViewState extends material.State { dialect: SqlDialect.postgres, tableName: widget.tableName, schema: widget.schema, + database: widget.database, isView: widget.isView, isMaterializedView: widget.isMaterializedView, limit: widget.limit, diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index c7bc7649..e7c6fc8b 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -9,7 +9,10 @@ import 'package:querya_desktop/core/storage/mutation_audit_recorder.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/core/actions/table_view_command_bridge.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; +import 'package:querya_desktop/features/erd/erd_table_names.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; @@ -276,12 +279,29 @@ class GenericSqlWorkspaceState extends material.State { }); } - void _openTableFromDiagram(String table) { - final q = switch (widget.dialect) { - SqlDialect.mysql => '`$table`', - _ => '"$table"', - }; - addNewTab(initialSql: 'SELECT * FROM $q LIMIT 100;', title: table); + /// Double click or "Open data" on a diagram card: the table browser, as the + /// Quick Switcher opens it. With [relations] it starts in the Relations + /// view. Without the app shell (tests, embedded use) the rows open in a + /// new SQL tab instead. + void _openTableFromDiagram(String table, {bool relations = false}) { + final open = QueryaCommandHost.maybeOf(context)?.onOpenSchemaObject; + if (open == null) { + _openTableInSql(table); + return; + } + if (relations) TableViewCommandBridge.instance.requestViewForNextTable(1); + open(ErdTableNames.schemaObject(table, widget.dialect, + database: effectiveDatabase)); + } + + /// "Open in SQL" on a diagram card: `SELECT * … LIMIT 100` in a new tab, + /// with the name qualified and quoted for the dialect. + void _openTableInSql(String table) { + final (_, bare) = ErdTableNames.split(table, widget.dialect); + addNewTab( + initialSql: ErdTableNames.selectSql(table, widget.dialect), + title: bare, + ); final session = _activeSession; material.WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(execute(session)); @@ -1192,6 +1212,11 @@ class GenericSqlWorkspaceState extends material.State { source: SqlErdSource(delegate: widget.delegate, dialect: widget.dialect), databaseName: effectiveDatabase, onOpenTable: _openTableFromDiagram, + onOpenInSql: _openTableInSql, + onShowRelations: + QueryaCommandHost.maybeOf(context)?.onOpenSchemaObject == null + ? null + : (table) => _openTableFromDiagram(table, relations: true), ); } diff --git a/lib/features/workspace/generic_table_view.dart b/lib/features/workspace/generic_table_view.dart index 774c006b..dedd67a3 100644 --- a/lib/features/workspace/generic_table_view.dart +++ b/lib/features/workspace/generic_table_view.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/storage/local_db.dart' show MutationAuditSource; import 'package:querya_desktop/core/storage/mutation_audit_recorder.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; import 'package:querya_desktop/core/actions/table_view_command_bridge.dart'; +import 'package:querya_desktop/features/erd/erd_table_names.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'; @@ -30,6 +32,7 @@ class GenericTableView extends material.StatefulWidget { required this.dialect, this.tableName = '', this.schema, + this.database, this.isView = false, this.isMaterializedView = false, this.isReadOnly = false, @@ -50,6 +53,9 @@ class GenericTableView extends material.StatefulWidget { final SqlDialect dialect; final String tableName; final String? schema; + + /// Database of the table, for opening a neighbour from the Relations view. + final String? database; final bool isView; final bool isMaterializedView; final bool isReadOnly; @@ -64,7 +70,9 @@ class GenericTableView extends material.StatefulWidget { /// Offers the Data | Relations switch. Extension tables have no diagram. final bool showRelations; - /// Double click on a neighbour in the Relations view. + /// Double click on a neighbour in the Relations view. When null, the + /// neighbour opens in the table browser like a Quick Switcher pick, in the + /// Relations view, so the graph can be walked. final void Function(String table)? onOpenNeighbour; /// Opens the full diagram focused on this table. @@ -165,7 +173,7 @@ class GenericTableViewState extends material.State { source: _erdSource, focusTable: focus, neighbourhoodDepth: _relationsDepth, - onOpenTable: widget.onOpenNeighbour, + onOpenTable: _openNeighbour, onOpenFullDiagram: widget.onOpenFullDiagram == null ? null : openFull, ), @@ -217,9 +225,31 @@ class GenericTableViewState extends material.State { void initState() { super.initState(); _loadPage(refreshCount: true); + _takePendingView(); _syncRelationsCommand(); } + /// Starts in the view a "Show relations" or a neighbour open asked for. + void _takePendingView() { + final view = TableViewCommandBridge.instance.takePendingView(); + if (view == null || !showsRelations) return; + _relationsMode = view == 1; + if (_relationsMode) _relationsVisited = true; + } + + void _openNeighbour(String name) { + final custom = widget.onOpenNeighbour; + if (custom != null) { + custom(name); + return; + } + final open = QueryaCommandHost.maybeOf(context)?.onOpenSchemaObject; + if (open == null) return; + TableViewCommandBridge.instance.requestViewForNextTable(1); + open(ErdTableNames.schemaObject(name, widget.dialect, + database: widget.database ?? '')); + } + /// The Command Palette can switch Data / Relations only while this table /// offers the switch. void _syncRelationsCommand() { @@ -244,6 +274,8 @@ class GenericTableViewState extends material.State { _resetStaging(); _offset = 0; _loadPage(refreshCount: true); + // The browser is reused for the next table: it may ask for a view too. + _takePendingView(); } else if (oldWidget.isReadOnly != widget.isReadOnly) { _syncStagingToReadOnly(); } diff --git a/test/features/erd/erd_table_names_test.dart b/test/features/erd/erd_table_names_test.dart new file mode 100644 index 00000000..cc681379 --- /dev/null +++ b/test/features/erd/erd_table_names_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/erd/erd_table_names.dart'; + +void main() { + group('ErdTableNames', () { + test('postgres splits schema.table and quotes each part', () { + expect(ErdTableNames.split('sales.orders', SqlDialect.postgres), + ('sales', 'orders')); + expect(ErdTableNames.split('orders', SqlDialect.postgres), + (null, 'orders')); + expect(ErdTableNames.qualifiedSql('sales.orders', SqlDialect.postgres), + '"sales"."orders"'); + expect(ErdTableNames.qualifiedSql('a"b', SqlDialect.postgres), '"a""b"'); + }); + + test('mysql and sqlite keep a dot as part of the name', () { + expect(ErdTableNames.split('a.b', SqlDialect.mysql), (null, 'a.b')); + expect(ErdTableNames.qualifiedSql('a.b', SqlDialect.mysql), '`a.b`'); + expect(ErdTableNames.selectSql('users', SqlDialect.sqlite), + 'SELECT * FROM "users" LIMIT 100;'); + }); + + test('schema objects match the Quick Switcher ones', () { + final other = ErdTableNames.schemaObject('sales.orders', + SqlDialect.postgres, database: 'shop'); + expect(other.schema, 'sales'); + expect(other.name, 'orders'); + expect(other.database, 'shop'); + expect(other.kind, QueryaSchemaObjectKind.table); + + final current = ErdTableNames.schemaObject('orders', SqlDialect.postgres, + database: 'shop'); + expect(current.schema, ErdTableNames.defaultPostgresSchema); + + final mysql = ErdTableNames.schemaObject('order.items', SqlDialect.mysql, + database: 'shop'); + expect(mysql.name, 'order.items'); + expect(mysql.database, 'shop'); + + final sqlite = ErdTableNames.schemaObject('users', SqlDialect.sqlite, + database: ''); + expect(sqlite.name, 'users'); + expect(sqlite.schema, isNull); + }); + }); +} diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 74065028..ac268328 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/gestures.dart' show kSecondaryButton; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -618,6 +619,61 @@ void main() { await t.pump(const Duration(seconds: 1)); }); + // The card menu is a desktop popover; on a phone platform shadcn opens it + // as a sheet, which needs the app's drawer overlay. + final desktop = TargetPlatformVariant.only(material.TargetPlatform.linux); + + testWidgets('a card menu opens the table in SQL and shows its relations', + variant: desktop, (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + String? inSql, relations; + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: delegate(), dialect: SqlDialect.sqlite), + onOpenTable: (_) {}, + onOpenInSql: (n) => inSql = n, + onShowRelations: (n) => relations = n, + ), + )); + await t.pump(); + await t.pump(); + final card = find.byKey(const material.ValueKey('erd_table_users')); + + await t.tap(card, buttons: kSecondaryButton); + await t.pump(); + await t.pump(const Duration(milliseconds: 300)); + expect(find.text('Open data'), findsOneWidget); + await t.tap(find.byKey(const material.ValueKey('erd_menu_sql_users'))); + await t.pump(const Duration(milliseconds: 300)); + expect(inSql, 'users'); + + await t.tap(card, buttons: kSecondaryButton); + await t.pump(); + await t.pump(const Duration(milliseconds: 300)); + await t.tap( + find.byKey(const material.ValueKey('erd_menu_relations_users'))); + await t.pump(const Duration(milliseconds: 300)); + expect(relations, 'users'); + }); + + testWidgets('a card menu leaves out actions nobody handles', + variant: desktop, (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: delegate(), dialect: SqlDialect.sqlite)), + )); + await t.pump(); + await t.pump(); + await t.tap(find.byKey(const material.ValueKey('erd_table_users')), + buttons: kSecondaryButton); + await t.pump(); + await t.pump(const Duration(milliseconds: 300)); + expect(find.text('Copy name'), findsOneWidget); + expect(find.text('Open in SQL'), findsNothing); + expect(find.text('Show relations'), findsNothing); + }); + testWidgets('a card can be dragged and Auto layout puts it back', (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index 2491d3f2..fd17517f 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -4,7 +4,10 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/querya_command_host.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/features/erd/erd_catalog.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/workspace/generic_sql_workspace.dart'; @@ -361,4 +364,51 @@ void main() { expect(find.text('Data Output'), findsNothing); expect(find.byKey(const material.ValueKey('open_diagram_tab')), findsOneWidget); }); + + testWidgets('a double click on a diagram card opens the table browser', + timeout: _timeout, (tester) async { + final opened = []; + final delegate = FakeSqlExecutionDelegate(onExecute: (sql) { + if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + return const SqlExecutionResult(rows: [ + ['users', 'id', 'INTEGER', '1'], + ]); + } + return const SqlExecutionResult(); + }); + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaCommandHost( + onOpenSchemaObject: opened.add, + child: material.SizedBox.expand( + child: GenericSqlWorkspace( + connectionRow: connection, + delegate: delegate, + dialect: SqlDialect.sqlite, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const material.ValueKey('open_diagram_tab'))); + await settle(tester); + final card = find.byKey(const material.ValueKey('erd_table_users')); + expect(card, findsOneWidget); + + await tester.tap(card); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(card); + await settle(tester); + + expect(opened, hasLength(1)); + expect(opened.single.name, 'users'); + expect(opened.single.kind, QueryaSchemaObjectKind.table); + // No SQL tab: the browser opens instead. + expect(delegate.executed.where((q) => q.startsWith('SELECT * FROM')), + isEmpty); + }); } diff --git a/test/features/workspace/generic_table_view_relations_test.dart b/test/features/workspace/generic_table_view_relations_test.dart index 25a342bf..2fca6023 100644 --- a/test/features/workspace/generic_table_view_relations_test.dart +++ b/test/features/workspace/generic_table_view_relations_test.dart @@ -70,4 +70,20 @@ void main() { await pumpGenericTableView(t, FakeTableDataDelegate(), isView: true); expect(TableViewCommandBridge.instance.isActive, isFalse); }); + + testWidgets('a table asked to open in Relations starts there', (t) async { + TableViewCommandBridge.instance.requestViewForNextTable(1); + await pumpGenericTableView(t, FakeTableDataDelegate()); + await t.pump(); + expect(find.byType(ErdView), findsOneWidget); + // The request is used once. + expect(TableViewCommandBridge.instance.takePendingView(), isNull); + }); + + testWidgets('a view ignores a Relations request', (t) async { + TableViewCommandBridge.instance.requestViewForNextTable(1); + await pumpGenericTableView(t, FakeTableDataDelegate(), isView: true); + await t.pump(); + expect(find.byType(ErdView), findsNothing); + }); }