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
33 changes: 33 additions & 0 deletions lib/features/erd/erd_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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<SqlExecutionResult> executeQuery(
String sql, {
int? limit,
Duration? timeout,
}) async {
final page = await table.loadCustomSql(sql);
return SqlExecutionResult(columns: page.columns, rows: page.rows);
}

@override
Future<String> explainQuery(String sql) =>
Future.error(UnsupportedError('Explain is not used by the diagram'));

@override
bool get supportsExplain => false;

@override
Future<void> cancelQuery() async => table.cancel(interruptIfBusy: true);

@override
bool get supportsTransactions => false;
}
63 changes: 48 additions & 15 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -140,9 +149,11 @@ class _ErdViewState extends material.State<ErdView> {
_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;
Expand All @@ -154,13 +165,8 @@ class _ErdViewState extends material.State<ErdView> {
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;
Expand All @@ -179,6 +185,20 @@ class _ErdViewState extends material.State<ErdView> {
: 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) {
Expand Down Expand Up @@ -542,6 +562,19 @@ class _ErdViewState extends material.State<ErdView> {
'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(),
Expand Down
1 change: 1 addition & 0 deletions lib/features/extensions/extension_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ class _ExtensionTableViewState extends material.State<ExtensionTableView> {
return GenericTableView(
key: _genericKey,
delegate: _delegate,
showRelations: false,
title: tableTitle,
tableTitle: '${widget.database}.${widget.tableName}',
dialect: SqlDialect.postgres, // Generic fallback
Expand Down
2 changes: 2 additions & 0 deletions lib/features/mysql/mysql_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ class _MysqlTableViewState extends material.State<MysqlTableView> {
),
),
const Gap(6),
state.buildViewSwitch(),
const Gap(4),
if (!widget.isView) ...[
state.buildEditModeButton(),
const Gap(4),
Expand Down
8 changes: 7 additions & 1 deletion lib/features/postgresql/postgres_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,13 @@ class _PostgresTableViewState extends material.State<PostgresTableView> {
onRefresh: () => unawaited(state.refresh()),
editAction: widget.isView || widget.isMaterializedView
? null
: state.buildEditModeButton(),
: material.Row(
mainAxisSize: material.MainAxisSize.min,
children: [
state.buildViewSwitch(),
state.buildEditModeButton(),
],
),
);
},
);
Expand Down
2 changes: 2 additions & 0 deletions lib/features/sqlite/sqlite_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@ class _SqliteTableViewState extends material.State<SqliteTableView> {
),
),
const Gap(6),
state.buildViewSwitch(),
const Gap(4),
if (!widget.isView) ...[
state.buildEditModeButton(),
const Gap(4),
Expand Down
102 changes: 101 additions & 1 deletion lib/features/workspace/generic_table_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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<GenericTableView> createState() => GenericTableViewState();
}
Expand Down Expand Up @@ -82,6 +96,82 @@ class GenericTableViewState extends material.State<GenericTableView> {
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<String> get columnNames => _columnNames;
List<List<String>> get rows => _rows;
Expand Down Expand Up @@ -620,6 +710,7 @@ class GenericTableViewState extends material.State<GenericTableView> {
),
const Gap(6),
if (!widget.isView && !widget.isMaterializedView) ...[
buildViewSwitch(),
buildEditModeButton(),
const Gap(4),
],
Expand Down Expand Up @@ -711,7 +802,10 @@ class GenericTableViewState extends material.State<GenericTableView> {
),
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,
Expand All @@ -726,6 +820,12 @@ class GenericTableViewState extends material.State<GenericTableView> {
isSaving: _isSaving,
errorAction: widget.errorAction,
),
if (_relationsVisited)
_buildRelations()
else
const material.SizedBox.shrink(),
],
),
),
],
),
Expand Down
49 changes: 49 additions & 0 deletions test/features/workspace/generic_table_view_relations_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading
Loading