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
97 changes: 86 additions & 11 deletions lib/features/erd/erd_catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,39 @@ import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart';
/// Catalog queries per dialect. Column query yields `table, column, type,
/// isPk, isNullable`; FK query yields `table, column, refTable, refColumn`.
///
/// Tables outside the current schema (PostgreSQL) are named `schema.table`,
/// so relations between schemas keep the same keys as the rest of the diagram.
///
/// Every output column has a distinct alias: the SQLite driver returns rows as
/// maps keyed by column name, so two `name` columns would collapse into one.
class ErdCatalog {
ErdCatalog._();

static String columnsSql(SqlDialect d) => switch (d) {
/// PostgreSQL name of relation alias [rel] in namespace alias [ns]:
/// `table` for the current schema, `schema.table` otherwise.
static String _pgQualified(String rel, String ns) =>
"CASE WHEN $ns.nspname = current_schema() THEN $rel.relname "
"ELSE $ns.nspname || '.' || $rel.relname END";

/// Columns of [schema] (PostgreSQL) or of the connected database. [schema]
/// `null` means the current schema.
static String columnsSql(SqlDialect d, {String? schema}) => switch (d) {
// pg_catalog is readable by every role, unlike information_schema,
// which hides keys of tables the current role has only SELECT on.
SqlDialect.postgres => '''
SELECT c.relname AS table_name, a.attname AS column_name,
SELECT ${_pgQualified('c', 'ns')} AS table_name, a.attname AS column_name,
format_type(a.atttypid, a.atttypmod) AS data_type,
CASE WHEN pk.oid IS NULL THEN 0 ELSE 1 END AS is_pk,
CASE WHEN a.attnotnull THEN 0 ELSE 1 END AS is_nullable
FROM pg_catalog.pg_attribute a
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace
LEFT JOIN pg_catalog.pg_constraint pk
ON pk.conrelid = c.oid AND pk.contype = 'p' AND a.attnum = ANY (pk.conkey)
WHERE n.nspname = current_schema()
WHERE ns.nspname = ${schema == null ? 'current_schema()' : "'${_lit(schema)}'"}
AND c.relkind IN ('r', 'p')
AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY c.relname, a.attnum''',
ORDER BY table_name, a.attnum''',
SqlDialect.mysql => '''
SELECT c.table_name, c.column_name, c.column_type,
IF(c.column_key = 'PRI', 1, 0),
Expand All @@ -46,16 +57,17 @@ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
ORDER BY m.name, p.cid''',
};

/// Foreign keys of every user schema. A reference across schemas is named
/// `schema.table` on the side that lives outside the current schema.
static String foreignKeysSql(SqlDialect d) => switch (d) {
// conkey and confkey are paired by position, so a composite key yields
// one row per column pair. References outside the current schema are
// skipped: their table names would otherwise match same-named tables.
// one row per column pair.
SqlDialect.postgres => '''
SELECT src.relname AS table_name, sa.attname AS column_name,
ref.relname AS ref_table, ra.attname AS ref_column
SELECT ${_pgQualified('src', 'ns')} AS table_name, sa.attname AS column_name,
${_pgQualified('ref', 'rn')} AS ref_table, ra.attname AS ref_column
FROM pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
JOIN pg_catalog.pg_namespace n ON n.oid = src.relnamespace
JOIN pg_catalog.pg_namespace ns ON ns.oid = src.relnamespace
JOIN pg_catalog.pg_class ref ON ref.oid = con.confrelid
JOIN pg_catalog.pg_namespace rn ON rn.oid = ref.relnamespace
CROSS JOIN LATERAL unnest(con.conkey, con.confkey) AS k(src_attnum, ref_attnum)
Expand All @@ -64,7 +76,9 @@ JOIN pg_catalog.pg_attribute sa
JOIN pg_catalog.pg_attribute ra
ON ra.attrelid = con.confrelid AND ra.attnum = k.ref_attnum
WHERE con.contype = 'f'
AND n.nspname = current_schema() AND rn.nspname = current_schema()''',
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
AND ns.nspname NOT LIKE 'pg_toast%'
AND rn.nspname NOT IN ('pg_catalog', 'information_schema')''',
SqlDialect.mysql => '''
SELECT table_name, column_name, referenced_table_name, referenced_column_name
FROM information_schema.key_column_usage
Expand All @@ -77,6 +91,15 @@ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
''',
};

static String _lit(String s) => s.replaceAll("'", "''");

/// Schema part of a diagram name: `sales` for `sales.orders`, `null` for a
/// table of the current schema.
static String? schemaOf(String name) {
final dot = name.indexOf('.');
return dot < 0 ? null : name.substring(0, dot);
}

/// Loads the schema of the current database through [delegate].
static Future<ErdSchema> load(
SqlExecutionDelegate delegate,
Expand All @@ -86,4 +109,56 @@ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
final fks = await delegate.executeQuery(foreignKeysSql(dialect));
return ErdSchema.fromCatalog(columnRows: cols.rows, fkRows: fks.rows);
}

/// Tables within [depth] foreign keys of [table] in either direction, with
/// their columns. Cycles and self references end the walk.
static Future<ErdSchema> loadNeighbourhood(
SqlExecutionDelegate delegate,
SqlDialect dialect, {
required String table,
int depth = 1,
}) async {
final fks = (await delegate.executeQuery(foreignKeysSql(dialect))).rows;
final names = neighbourhood(fks, table, depth);

final cols = <List<String>>[];
final schemas = {for (final n in names) schemaOf(n)};
for (final s in schemas) {
final rows = await delegate.executeQuery(
columnsSql(dialect, schema: dialect == SqlDialect.postgres ? s : null),
);
cols.addAll(rows.rows.where((r) => r.isNotEmpty && names.contains(r[0])));
}
return ErdSchema.fromCatalog(columnRows: cols, fkRows: [
for (final r in fks)
if (r.length >= 4 && names.contains(r[0]) && names.contains(r[2])) r,
]);
}

/// Names of the tables within [depth] foreign keys of [table], including
/// [table] itself. Pure, for tests.
static Set<String> neighbourhood(
List<List<String>> fkRows,
String table,
int depth,
) {
final adjacent = <String, Set<String>>{};
for (final r in fkRows) {
if (r.length < 4) continue;
adjacent.putIfAbsent(r[0], () => {}).add(r[2]);
adjacent.putIfAbsent(r[2], () => {}).add(r[0]);
}
final seen = {table};
var frontier = {table};
for (var i = 0; i < depth && frontier.isNotEmpty; i++) {
final next = <String>{};
for (final t in frontier) {
for (final n in adjacent[t] ?? const <String>{}) {
if (seen.add(n)) next.add(n);
}
}
frontier = next;
}
return seen;
}
}
34 changes: 34 additions & 0 deletions lib/features/erd/erd_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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';

/// 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`
/// outside the current schema).
abstract interface class ErdSource {
Future<ErdSchema> loadSchema();

/// [table] and the tables within [depth] foreign keys of it, either way.
Future<ErdSchema> loadNeighbourhood(String table, {int depth = 1});
}

/// Reads the catalog through a query runner for one dialect.
class SqlErdSource implements ErdSource {
const SqlErdSource({required this.delegate, required this.dialect});

final SqlExecutionDelegate delegate;
final SqlDialect dialect;

@override
Future<ErdSchema> loadSchema() => ErdCatalog.load(delegate, dialect);

@override
Future<ErdSchema> loadNeighbourhood(String table, {int depth = 1}) =>
ErdCatalog.loadNeighbourhood(
delegate,
dialect,
table: table,
depth: depth,
);
}
29 changes: 25 additions & 4 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart';
import 'package:querya_desktop/core/theme/querya_theme_scope.dart';
import 'package:querya_desktop/core/theme/querya_typography.dart';
import 'package:querya_desktop/features/erd/erd_canvas_controls.dart';
import 'package:querya_desktop/features/erd/erd_catalog.dart';
import 'package:querya_desktop/features/erd/erd_source.dart';
import 'package:querya_desktop/features/erd/erd_export.dart';
import 'package:querya_desktop/features/erd/erd_geometry.dart';
import 'package:querya_desktop/features/erd/erd_layout.dart';
Expand Down Expand Up @@ -54,13 +54,21 @@ class ErdView extends material.StatefulWidget {
required this.delegate,
required this.dialect,
this.databaseName = '',
this.source,
this.focusTable,
this.onOpenTable,
this.onSaveFile,
});

final SqlExecutionDelegate delegate;
final SqlDialect dialect;

/// Where the tables come from; the delegate and dialect when omitted.
final ErdSource? source;

/// Table to pick and centre once the schema is drawn.
final String? focusTable;

/// Names the exported files: `<databaseName>-erd.svg`.
final String databaseName;

Expand Down Expand Up @@ -132,15 +140,28 @@ class _ErdViewState extends material.State<ErdView> {
_error = null;
});
try {
final schema = await ErdCatalog.load(widget.delegate, widget.dialect);
final schema = await (widget.source ??
SqlErdSource(delegate: widget.delegate, dialect: widget.dialect))
.loadSchema();
if (!mounted) return;
setState(() {
_schema = schema;
_setLayout(ErdLayout.compute(_visibleOf(schema)));
_loading = false;
});
// The viewport is measured after this frame; fit the diagram then.
material.WidgetsBinding.instance.addPostFrameCallback((_) => _fit());
// The viewport is measured after this frame: fit the diagram, then pick
// and centre the focused table.
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);
}
});
} catch (e) {
if (!mounted) return;
setState(() {
Expand Down
1 change: 1 addition & 0 deletions lib/features/extensions/extension_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ class _ExtensionSqlWorkspaceState extends material.State<ExtensionSqlWorkspace>
initialTabTitle: widget.selectedObject?.name,
supportsAutocommit: false,
supportsStmtTimeout: false,
showDiagram: false,
effectiveDatabaseName: () => widget.connectionRow.databaseName ?? '',
headerBadge: material.Row(
mainAxisSize: material.MainAxisSize.min,
Expand Down
8 changes: 7 additions & 1 deletion lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class GenericSqlWorkspace extends material.StatefulWidget {
this.initialTabTitle,
this.transactionOpenNotifier,
this.isReadOnly = false,
this.showDiagram = true,
this.supportsAutocommit = false,
this.initialAutocommit = true,
this.onAutocommitChanged,
Expand All @@ -65,6 +66,10 @@ class GenericSqlWorkspace extends material.StatefulWidget {
final material.ValueNotifier<bool?>? transactionOpenNotifier;
final bool isReadOnly;

/// Whether the Diagram tab is offered. Extension drivers have no diagram
/// source yet, so their workspace turns it off.
final bool showDiagram;

final bool supportsAutocommit;
final bool initialAutocommit;
final void Function(bool value)? onAutocommitChanged;
Expand Down Expand Up @@ -990,7 +995,8 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
tooltip: 'Interrupt the running query',
onPressed: () => cancelRunning(session),
),
QueryaActionButton(
if (widget.showDiagram)
QueryaActionButton(
key: const material.ValueKey('open_diagram_tab'),
label: 'Diagram',
onPressed: openDiagramTab,
Expand Down
61 changes: 60 additions & 1 deletion test/features/erd/erd_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ void main() {
ErdCatalog.foreignKeysSql(SqlDialect.postgres),
]) {
expect(sql, contains('pg_catalog.'));
expect(sql, isNot(contains('information_schema')));
// Schema names may still mention information_schema to exclude it.
expect(sql, isNot(contains('information_schema.')));
}
expect(ErdCatalog.foreignKeysSql(SqlDialect.postgres),
contains('unnest(con.conkey, con.confkey)'));
Expand Down Expand Up @@ -110,6 +111,64 @@ void main() {
expect(s.tables[1].columns[1].isNullable, isTrue);
});

test('postgres names tables of other schemas schema.table', () {
expect(ErdCatalog.foreignKeysSql(SqlDialect.postgres),
contains("|| '.' ||"));
expect(ErdCatalog.columnsSql(SqlDialect.postgres, schema: 'sales'),
contains("nspname = 'sales'"));
});

test('neighbourhood follows foreign keys up to the requested depth', () {
final fks = [
['b', 'a_id', 'a', 'id'],
['c', 'b_id', 'b', 'id'],
['d', 'x', 'z', 'id'],
];
expect(ErdCatalog.neighbourhood(fks, 'a', 1), {'a', 'b'});
expect(ErdCatalog.neighbourhood(fks, 'a', 2), {'a', 'b', 'c'});
expect(ErdCatalog.neighbourhood(fks, 'z', 5), {'z', 'd'});
});

test('cycles and self references end the walk', () {
final fks = [
['a', 'b_id', 'b', 'id'],
['b', 'a_id', 'a', 'id'],
['a', 'parent', 'a', 'id'],
];
expect(ErdCatalog.neighbourhood(fks, 'a', 9), {'a', 'b'});
});

test('a table without relations is its own neighbourhood', () {
expect(ErdCatalog.neighbourhood(const [], 'lonely', 2), {'lonely'});
});

test('loadNeighbourhood loads only the tables around the one asked for',
() async {
final delegate = FakeSqlExecutionDelegate(onExecute: (sql) {
if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) {
return const SqlExecutionResult(rows: [
['a', 'id', 'INTEGER', '1'],
['b', 'id', 'INTEGER', '1'],
['b', 'a_id', 'INTEGER', '0'],
['c', 'id', 'INTEGER', '1'],
]);
}
if (sql == ErdCatalog.foreignKeysSql(SqlDialect.sqlite)) {
return const SqlExecutionResult(rows: [
['b', 'a_id', 'a', 'id'],
]);
}
return const SqlExecutionResult();
});
final s = await ErdCatalog.loadNeighbourhood(
delegate,
SqlDialect.sqlite,
table: 'a',
);
expect(s.tables.map((t) => t.name), ['a', 'b']);
expect(s.relations.length, 1);
});

test('load runs both queries through the delegate', () async {
final delegate = FakeSqlExecutionDelegate(onExecute: (sql) {
if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) {
Expand Down
Loading