Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/core/actions/table_view_command_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class TableViewCommandBridge {

Object? _owner;
void Function(int index)? _onSelectView;
int? _pendingView;
DateTime? _pendingAt;

bool get isActive => _onSelectView != null;

Expand All @@ -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;
}
}
62 changes: 62 additions & 0 deletions lib/features/erd/erd_table_names.dart
Original file line number Diff line number Diff line change
@@ -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 <name> LIMIT <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,
),
};
}
}
35 changes: 30 additions & 5 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class ErdView extends material.StatefulWidget {
this.focusTable,
this.neighbourhoodDepth,
this.onOpenTable,
this.onOpenInSql,
this.onShowRelations,
this.onOpenFullDiagram,
this.onSaveFile,
});
Expand All @@ -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
Expand Down Expand Up @@ -757,11 +765,28 @@ class _ErdViewState extends material.State<ErdView> {
: 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(
Expand Down
1 change: 1 addition & 0 deletions lib/features/mysql/mysql_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ class _MysqlTableViewState extends material.State<MysqlTableView> {
dialect: SqlDialect.mysql,
tableName: widget.tableName,
schema: widget.database,
database: widget.database,
isView: widget.isView,
isReadOnly: widget.isReadOnly,
limit: widget.limit,
Expand Down
1 change: 1 addition & 0 deletions lib/features/postgresql/postgres_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
dialect: SqlDialect.postgres,
tableName: widget.tableName,
schema: widget.schema,
database: widget.database,
isView: widget.isView,
isMaterializedView: widget.isMaterializedView,
limit: widget.limit,
Expand Down
37 changes: 31 additions & 6 deletions lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -276,12 +279,29 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
});
}

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));
Expand Down Expand Up @@ -1192,6 +1212,11 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
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),
);
}

Expand Down
36 changes: 34 additions & 2 deletions lib/features/workspace/generic_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -165,7 +173,7 @@ class GenericTableViewState extends material.State<GenericTableView> {
source: _erdSource,
focusTable: focus,
neighbourhoodDepth: _relationsDepth,
onOpenTable: widget.onOpenNeighbour,
onOpenTable: _openNeighbour,
onOpenFullDiagram:
widget.onOpenFullDiagram == null ? null : openFull,
),
Expand Down Expand Up @@ -217,9 +225,31 @@ class GenericTableViewState extends material.State<GenericTableView> {
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() {
Expand All @@ -244,6 +274,8 @@ class GenericTableViewState extends material.State<GenericTableView> {
_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();
}
Expand Down
48 changes: 48 additions & 0 deletions test/features/erd/erd_table_names_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading
Loading