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
12 changes: 9 additions & 3 deletions lib/core/erd/erd_catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ SELECT ${_pgQualified('c', 'ns')} AS table_name, a.attname AS column_name,
pg_get_expr(df.adbin, df.adrelid) AS column_default,
CASE WHEN a.attidentity <> ''
OR pg_get_expr(df.adbin, df.adrelid) LIKE 'nextval(%'
THEN 1 ELSE 0 END AS is_identity
THEN 1 ELSE 0 END AS is_identity,
col_description(c.oid, a.attnum) AS column_comment,
obj_description(c.oid, 'pg_class') AS table_comment
FROM pg_catalog.pg_attribute a
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace
Expand All @@ -61,7 +63,9 @@ SELECT c.table_name, c.column_name, c.column_type,
IF(c.is_nullable = 'YES', 1, 0),
IF(c.column_key = 'UNI', 1, 0),
c.column_default,
IF(c.extra LIKE '%auto_increment%', 1, 0)
IF(c.extra LIKE '%auto_increment%', 1, 0),
c.column_comment,
t.table_comment
FROM information_schema.columns c
JOIN information_schema.tables t
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
Expand All @@ -81,7 +85,9 @@ SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type,
p.dflt_value AS column_default,
CASE WHEN p.pk = 1 AND upper(p.type) = 'INTEGER'
AND (SELECT COUNT(*) FROM pragma_table_info(m.name) WHERE pk > 0) = 1
THEN 1 ELSE 0 END AS is_identity
THEN 1 ELSE 0 END AS is_identity,
NULL AS column_comment,
NULL AS table_comment
FROM sqlite_master m JOIN pragma_table_info(m.name) p
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
ORDER BY m.name, p.cid''',
Expand Down
28 changes: 26 additions & 2 deletions lib/core/erd/erd_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class ErdColumn {
this.isUnique = false,
this.defaultValue,
this.isIdentity = false,
this.comment,
});

final String name;
Expand All @@ -30,6 +31,10 @@ class ErdColumn {
/// auto_increment, or SQLite's INTEGER PRIMARY KEY.
final bool isIdentity;

/// The column's comment in the database (`COMMENT ON COLUMN`); null when
/// none (#1279).
final String? comment;

/// Short markers after the type on a card and in exports (#1277).
List<String> get badges => [
if (isUnique && !isPrimaryKey) 'UQ',
Expand All @@ -46,15 +51,19 @@ class ErdColumn {
isUnique: isUnique,
defaultValue: defaultValue,
isIdentity: isIdentity,
comment: comment,
);
}

@immutable
class ErdTable {
const ErdTable({required this.name, required this.columns});
const ErdTable({required this.name, required this.columns, this.comment});

final String name;
final List<ErdColumn> columns;

/// The table's comment in the database (`COMMENT ON TABLE`) (#1279).
final String? comment;
}

/// A foreign key: [fromTable].[fromColumn] references [toTable].[toColumn].
Expand Down Expand Up @@ -95,14 +104,16 @@ class ErdSchema {
/// Builds a schema from flat catalog rows.
///
/// [columnRows]: `table, column, type, isPk`, then optionally `isNullable`,
/// `isUnique`, `default` and `isIdentity` (#1277). [fkRows]: `table, column, refTable, refColumn`. Relations to
/// `isUnique`, `default` and `isIdentity` (#1277), then the column comment
/// and the table comment (#1279). [fkRows]: `table, column, refTable, refColumn`. Relations to
/// unknown tables are dropped.
factory ErdSchema.fromCatalog({
required List<List<String>> columnRows,
required List<List<String>> fkRows,
bool truncated = false,
}) {
final order = <String>[];
final tableComments = <String, String>{};
final cols = <String, List<ErdColumn>>{};
for (final r in columnRows) {
if (r.length < 4) continue;
Expand All @@ -118,7 +129,12 @@ class ErdSchema {
isUnique: r.length > 5 && _truthy(r[5]),
defaultValue: r.length > 6 ? _default(r[6]) : null,
isIdentity: r.length > 7 && _truthy(r[7]),
comment: r.length > 8 ? _text(r[8]) : null,
));
if (r.length > 9) {
final tc = _text(r[9]);
if (tc != null) tableComments.putIfAbsent(r[0], () => tc);
}
}
final nullable = {
for (final t in cols.entries)
Expand All @@ -144,6 +160,7 @@ class ErdSchema {
for (final t in order)
ErdTable(
name: t,
comment: tableComments[t],
columns: [
for (final c in cols[t]!)
fkCols.contains('$t\u0000${c.name}')
Expand All @@ -169,3 +186,10 @@ String? _default(String raw) {
if (v.isEmpty || v == 'NULL' || v.toLowerCase() == 'null') return null;
return v;
}

/// A catalog text cell: empty and SQL NULL mean none.
String? _text(String raw) {
final v = raw.trim();
if (v.isEmpty || v == 'NULL') return null;
return v;
}
10 changes: 10 additions & 0 deletions lib/core/mcp/mcp_query_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ class McpQueryService {
) =>
McpTableDescription(
name: t.name,
comment: t.comment,
columns: [
for (final c in t.columns)
McpColumnInfo(
Expand All @@ -142,6 +143,7 @@ class McpQueryService {
unique: c.isUnique && !c.isPrimaryKey,
identity: c.isIdentity,
defaultValue: c.defaultValue,
comment: c.comment,
references: [
for (final r in schema.relations)
if (r.fromTable == t.name && r.fromColumn == c.name)
Expand Down Expand Up @@ -430,6 +432,7 @@ class McpColumnInfo {
this.unique = false,
this.defaultValue,
this.identity = false,
this.comment,
});

final String name;
Expand All @@ -438,6 +441,7 @@ class McpColumnInfo {
final bool unique;
final String? defaultValue;
final bool identity;
final String? comment;

/// `table.column` this column points to, when it is a foreign key.
final String? references;
Expand All @@ -450,6 +454,7 @@ class McpColumnInfo {
if (unique) 'unique': true,
if (identity) 'identity': true,
if (defaultValue != null) 'default': defaultValue,
if (comment != null) 'comment': comment,
};
}

Expand All @@ -467,14 +472,19 @@ class McpTableDescription {
required this.name,
required this.columns,
required this.indexes,
this.comment,
});

final String name;
final List<McpColumnInfo> columns;
final List<McpIndexInfo> indexes;

/// The table's comment in the database: often what the table is for.
final String? comment;

Map<String, Object?> toJson() => {
'name': name,
if (comment != null) 'comment': comment,
'columns': [for (final c in columns) c.toJson()],
'indexes': [for (final i in indexes) i.toJson()],
};
Expand Down
6 changes: 5 additions & 1 deletion lib/features/erd/erd_card_measure.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,18 @@ class ErdCardMeasure {
/// [ErdLayout.maxCardWidth].
double width(ErdTable t) {
// Padding 10, icon 14, gap 6, name, gap 8, count, padding 10, border 2.
var w = 50 + _text(t.name, _header) + _text('${t.columns.length}', _count);
var w = 50 +
_text(t.name, _header) +
_text('${t.columns.length}', _count) +
(t.comment != null ? 19 : 0);
for (final c in t.columns) {
final type = c.isNullable ? '${c.type}?' : c.type;
// Padding 10, marker slot 26, name, gap 8, type, padding 10, border 2.
// Plus 4 px so sub-pixel rounding never ellipsizes.
var row = 60 +
_text(c.name, c.isPrimaryKey ? _pkName : _name) +
_text(type, _type);
if (c.comment != null) row += 13;
for (final b in c.badges) {
row += 3 + _text(b, _badge);
}
Expand Down
22 changes: 22 additions & 0 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ class _ErdViewState extends material.State<ErdView> {
if (!_hidden.contains(t.name))
ErdTable(
name: t.name,
comment: t.comment,
columns: _collapsed.contains(t.name) || _detail == ErdDetail.names
? const []
: [
Expand Down Expand Up @@ -1389,6 +1390,7 @@ class _TableCard extends material.StatelessWidget {
if (c.isUnique && !c.isPrimaryKey) 'Unique',
if (c.isIdentity) 'Generated (identity / auto increment)',
if (c.defaultValue case final d?) 'Default: $d',
if (c.comment case final n?) 'Note: $n',
].join('\n');

/// Shadows read only on light surfaces, so a dark canvas gets twice the alpha.
Expand Down Expand Up @@ -1462,6 +1464,18 @@ class _TableCard extends material.StatelessWidget {
fontWeight: material.FontWeight.w600,
fontSize: 13)),
),
// The table's comment in the database (#1279).
if (table.comment case final note?) ...[
material.Tooltip(
message: note,
child: material.Icon(material.Icons.notes_rounded,
key: material.ValueKey(
'erd_note_${table.name}'),
size: 13,
color: wb.mutedForeground),
),
const material.SizedBox(width: 6),
],
Text('${table.columns.length}',
style: material.TextStyle(
fontSize: 10, color: wb.mutedForeground)),
Expand Down Expand Up @@ -1527,6 +1541,14 @@ class _TableCard extends material.StatelessWidget {
QueryaTypography.monoFontFamilyFallback,
color: wb.mutedForeground)),
),
if (c.comment != null) ...[
const material.SizedBox(width: 3),
material.Icon(material.Icons.notes_rounded,
key: material.ValueKey(
'erd_note_${table.name}_${c.name}'),
size: 10,
color: wb.mutedForeground),
],
// UQ / AI / DF after the type (#1277).
for (final b in c.badges) ...[
const material.SizedBox(width: 3),
Expand Down
42 changes: 42 additions & 0 deletions test/core/erd/erd_comments_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/database/table_mutation_engine.dart';
import 'package:querya_desktop/core/erd/erd_catalog.dart';
import 'package:querya_desktop/core/erd/erd_model.dart';

/// #1279: table and column comments from the database.
void main() {
test('catalog rows carry the column and table comments', () {
final s = ErdSchema.fromCatalog(columnRows: const [
['users', 'id', 'int', '1', '0', '0', '', '1', 'NULL', 'People who sign in'],
['users', 'email', 'text', '0', '0', '1', '', '0', 'Lower-cased', 'People who sign in'],
['tags', 'id', 'int', '1', '0', '0', '', '1', '', ''],
['old', 'id', 'int', '1'],
], fkRows: const []);
final t = {for (final t in s.tables) t.name: t};
expect(t['users']!.comment, 'People who sign in');
expect(t['users']!.columns.first.comment, isNull);
expect(t['users']!.columns.last.comment, 'Lower-cased');
expect(t['tags']!.comment, isNull, reason: 'empty is no comment');
expect(t['old']!.comment, isNull, reason: 'short rows still read');
});

test('a foreign key keeps its comment', () {
final s = ErdSchema.fromCatalog(columnRows: const [
['orders', 'user_id', 'int', '0', '0', '0', '', '0', 'Who ordered', ''],
['users', 'id', 'int', '1'],
], fkRows: const [
['orders', 'user_id', 'users', 'id'],
]);
final fk = s.tables.firstWhere((t) => t.name == 'orders').columns.single;
expect(fk.isForeignKey, isTrue);
expect(fk.comment, 'Who ordered');
});

test('PostgreSQL and MySQL ask for comments, SQLite has none', () {
expect(ErdCatalog.columnsSql(SqlDialect.postgres), contains('col_description'));
expect(ErdCatalog.columnsSql(SqlDialect.postgres), contains('obj_description'));
expect(ErdCatalog.columnsSql(SqlDialect.mysql), contains('column_comment'));
expect(ErdCatalog.columnsSql(SqlDialect.mysql), contains('table_comment'));
expect(ErdCatalog.columnsSql(SqlDialect.sqlite), contains('NULL AS table_comment'));
});
}
43 changes: 43 additions & 0 deletions test/features/erd/erd_card_width_colour_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,47 @@ void main() {
expect(tip.message, contains("Default: 'new'"));
expect(tip.message, contains('Nullable'));
});

testWidgets('comments show as notes on the header and the row (#1279)',
(t) async {
await t.binding.setSurfaceSize(const material.Size(1200, 800));
addTearDown(() => t.binding.setSurfaceSize(null));
await t.pumpWidget(queryaThemeTestShell(
child: ErdView(
source: SqlErdSource(
delegate: FakeSqlExecutionDelegate(onExecute: (sql) {
if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) {
return const SqlExecutionResult(rows: [
['users', 'id', 'INTEGER', '1', '0', '0', 'NULL', '1', 'NULL', 'People'],
['users', 'email', 'TEXT', '0', '0', '0', 'NULL', '0', 'Lower-cased', 'People'],
['tags', 'id', 'INTEGER', '1', '0', '0', 'NULL', '1', 'NULL', 'NULL'],
]);
}
return const SqlExecutionResult();
}),
dialect: SqlDialect.sqlite,
),
),
));
await t.pump();
await t.pump();
final tableNote = find.byKey(const material.ValueKey('erd_note_users'));
expect(tableNote, findsOneWidget);
expect(
t.widget<material.Tooltip>(find.ancestor(
of: tableNote, matching: find.byType(material.Tooltip)))
.message,
'People');
expect(find.byKey(const material.ValueKey('erd_note_users_email')),
findsOneWidget);
expect(find.byKey(const material.ValueKey('erd_note_users_id')),
findsNothing);
expect(find.byKey(const material.ValueKey('erd_note_tags')), findsNothing);
final rowTip = t.widget<material.Tooltip>(find
.ancestor(
of: find.byKey(const material.ValueKey('erd_note_users_email')),
matching: find.byType(material.Tooltip))
.first);
expect(rowTip.message, contains('Note: Lower-cased'));
});
}
Loading