From 693265a3595793b47bb93536a38b931b781165fb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 13:59:40 +0300 Subject: [PATCH] feat(erd): table and column comments from the database as notes (#1279) The catalog reads COMMENT ON TABLE / COLUMN (PostgreSQL col_description and obj_description, MySQL column_comment and table_comment; SQLite has none). A table with a comment gets a note icon in its header whose tooltip is the comment; a column with one gets a small note icon and a 'Note:' line in its row tooltip. Cards are measured with the icons. MCP describe_table reports the comments, which tell a model what a table is for. --- lib/core/erd/erd_catalog.dart | 12 ++++-- lib/core/erd/erd_model.dart | 28 +++++++++++- lib/core/mcp/mcp_query_service.dart | 10 +++++ lib/features/erd/erd_card_measure.dart | 6 ++- lib/features/erd/erd_view.dart | 22 ++++++++++ test/core/erd/erd_comments_test.dart | 42 ++++++++++++++++++ .../erd/erd_card_width_colour_test.dart | 43 +++++++++++++++++++ 7 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 test/core/erd/erd_comments_test.dart diff --git a/lib/core/erd/erd_catalog.dart b/lib/core/erd/erd_catalog.dart index fb585e02..0b56a488 100644 --- a/lib/core/erd/erd_catalog.dart +++ b/lib/core/erd/erd_catalog.dart @@ -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 @@ -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 @@ -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''', diff --git a/lib/core/erd/erd_model.dart b/lib/core/erd/erd_model.dart index ca8b24ec..4a686ef6 100644 --- a/lib/core/erd/erd_model.dart +++ b/lib/core/erd/erd_model.dart @@ -11,6 +11,7 @@ class ErdColumn { this.isUnique = false, this.defaultValue, this.isIdentity = false, + this.comment, }); final String name; @@ -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 get badges => [ if (isUnique && !isPrimaryKey) 'UQ', @@ -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 columns; + + /// The table's comment in the database (`COMMENT ON TABLE`) (#1279). + final String? comment; } /// A foreign key: [fromTable].[fromColumn] references [toTable].[toColumn]. @@ -95,7 +104,8 @@ 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> columnRows, @@ -103,6 +113,7 @@ class ErdSchema { bool truncated = false, }) { final order = []; + final tableComments = {}; final cols = >{}; for (final r in columnRows) { if (r.length < 4) continue; @@ -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) @@ -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}') @@ -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; +} diff --git a/lib/core/mcp/mcp_query_service.dart b/lib/core/mcp/mcp_query_service.dart index b54423d0..f28e2361 100644 --- a/lib/core/mcp/mcp_query_service.dart +++ b/lib/core/mcp/mcp_query_service.dart @@ -133,6 +133,7 @@ class McpQueryService { ) => McpTableDescription( name: t.name, + comment: t.comment, columns: [ for (final c in t.columns) McpColumnInfo( @@ -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) @@ -430,6 +432,7 @@ class McpColumnInfo { this.unique = false, this.defaultValue, this.identity = false, + this.comment, }); final String name; @@ -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; @@ -450,6 +454,7 @@ class McpColumnInfo { if (unique) 'unique': true, if (identity) 'identity': true, if (defaultValue != null) 'default': defaultValue, + if (comment != null) 'comment': comment, }; } @@ -467,14 +472,19 @@ class McpTableDescription { required this.name, required this.columns, required this.indexes, + this.comment, }); final String name; final List columns; final List indexes; + /// The table's comment in the database: often what the table is for. + final String? comment; + Map toJson() => { 'name': name, + if (comment != null) 'comment': comment, 'columns': [for (final c in columns) c.toJson()], 'indexes': [for (final i in indexes) i.toJson()], }; diff --git a/lib/features/erd/erd_card_measure.dart b/lib/features/erd/erd_card_measure.dart index 60232fd7..ce7d66ba 100644 --- a/lib/features/erd/erd_card_measure.dart +++ b/lib/features/erd/erd_card_measure.dart @@ -48,7 +48,10 @@ 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. @@ -56,6 +59,7 @@ class ErdCardMeasure { 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); } diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 41c086c9..b9076002 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -397,6 +397,7 @@ class _ErdViewState extends material.State { if (!_hidden.contains(t.name)) ErdTable( name: t.name, + comment: t.comment, columns: _collapsed.contains(t.name) || _detail == ErdDetail.names ? const [] : [ @@ -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. @@ -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)), @@ -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), diff --git a/test/core/erd/erd_comments_test.dart b/test/core/erd/erd_comments_test.dart new file mode 100644 index 00000000..d6ebefd9 --- /dev/null +++ b/test/core/erd/erd_comments_test.dart @@ -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')); + }); +} diff --git a/test/features/erd/erd_card_width_colour_test.dart b/test/features/erd/erd_card_width_colour_test.dart index 74692bab..27576739 100644 --- a/test/features/erd/erd_card_width_colour_test.dart +++ b/test/features/erd/erd_card_width_colour_test.dart @@ -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(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(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')); + }); }