From bc549cbdf2791ebc638d3c3fd38b22afe82d74d7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 14:04:55 +0300 Subject: [PATCH] feat(erd): enum values on enum columns, a domain's base type (#1280) PostgreSQL: the catalog query lists an enum type's labels from pg_enum in their order (also when a domain wraps the enum) and a domain's base type. MySQL: the labels are read from the column type enum('a','b'), quotes unescaped. An enum column gets an EN marker; its row tooltip lists the values (at most 12, then '+N more') and a domain says 'Domain over '. MCP describe_table reports enum_values. Checked on the live types_showcase database through MCP: user_role_enum and order_status_enum list their labels, positive_int and valid_email their base types. --- lib/core/erd/erd_catalog.dart | 9 ++++- lib/core/erd/erd_model.dart | 47 ++++++++++++++++++++++++- lib/core/mcp/mcp_query_service.dart | 4 +++ lib/features/erd/erd_view.dart | 9 +++++ test/core/erd/erd_enum_values_test.dart | 45 +++++++++++++++++++++++ 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 test/core/erd/erd_enum_values_test.dart diff --git a/lib/core/erd/erd_catalog.dart b/lib/core/erd/erd_catalog.dart index 0b56a488..6cdd320b 100644 --- a/lib/core/erd/erd_catalog.dart +++ b/lib/core/erd/erd_catalog.dart @@ -45,9 +45,16 @@ SELECT ${_pgQualified('c', 'ns')} AS table_name, a.attname AS column_name, OR pg_get_expr(df.adbin, df.adrelid) LIKE 'nextval(%' 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 + obj_description(c.oid, 'pg_class') AS table_comment, + (SELECT string_agg(e.enumlabel, chr(31) ORDER BY e.enumsortorder) + FROM pg_catalog.pg_enum e + WHERE e.enumtypid = CASE WHEN ty.typtype = 'd' + THEN ty.typbasetype ELSE ty.oid END) AS enum_values, + CASE WHEN ty.typtype = 'd' + THEN format_type(ty.typbasetype, ty.typtypmod) END AS domain_base FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid +JOIN pg_catalog.pg_type ty ON ty.oid = a.atttypid 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) diff --git a/lib/core/erd/erd_model.dart b/lib/core/erd/erd_model.dart index 4a686ef6..539be6a9 100644 --- a/lib/core/erd/erd_model.dart +++ b/lib/core/erd/erd_model.dart @@ -12,6 +12,8 @@ class ErdColumn { this.defaultValue, this.isIdentity = false, this.comment, + this.enumValues = const [], + this.domainBase, }); final String name; @@ -35,8 +37,16 @@ class ErdColumn { /// none (#1279). final String? comment; + /// The labels of an enum type, in their order (#1280): PostgreSQL enums + /// (also behind a domain) and MySQL `enum(...)` columns. + final List enumValues; + + /// The base type when the column's type is a PostgreSQL domain. + final String? domainBase; + /// Short markers after the type on a card and in exports (#1277). List get badges => [ + if (enumValues.isNotEmpty) 'EN', if (isUnique && !isPrimaryKey) 'UQ', if (isIdentity) 'AI', if (defaultValue != null && !isIdentity) 'DF', @@ -52,6 +62,8 @@ class ErdColumn { defaultValue: defaultValue, isIdentity: isIdentity, comment: comment, + enumValues: enumValues, + domainBase: domainBase, ); } @@ -105,7 +117,8 @@ class ErdSchema { /// /// [columnRows]: `table, column, type, isPk`, then optionally `isNullable`, /// `isUnique`, `default` and `isIdentity` (#1277), then the column comment - /// and the table comment (#1279). [fkRows]: `table, column, refTable, refColumn`. Relations to + /// and the table comment (#1279), then the enum labels (separated by + /// U+001F) and a domain's base type (#1280). [fkRows]: `table, column, refTable, refColumn`. Relations to /// unknown tables are dropped. factory ErdSchema.fromCatalog({ required List> columnRows, @@ -130,6 +143,8 @@ class ErdSchema { defaultValue: r.length > 6 ? _default(r[6]) : null, isIdentity: r.length > 7 && _truthy(r[7]), comment: r.length > 8 ? _text(r[8]) : null, + enumValues: _enumValues(r[2], r.length > 10 ? r[10] : ''), + domainBase: r.length > 11 ? _text(r[11]) : null, )); if (r.length > 9) { final tc = _text(r[9]); @@ -193,3 +208,33 @@ String? _text(String raw) { if (v.isEmpty || v == 'NULL') return null; return v; } + +/// Enum labels from the catalog cell, or from a MySQL `enum('a','b')` type. +List _enumValues(String type, String cell) { + final listed = _text(cell); + if (listed != null) return listed.split('\u001f'); + final t = type.trim(); + if (!t.toLowerCase().startsWith('enum(') || !t.endsWith(')')) return const []; + final body = t.substring(5, t.length - 1); + final out = []; + final cur = StringBuffer(); + var inQuote = false; + for (var i = 0; i < body.length; i++) { + final ch = body[i]; + if (inQuote) { + if (ch == "'" && i + 1 < body.length && body[i + 1] == "'") { + cur.write("'"); + i++; + } else if (ch == "'") { + inQuote = false; + out.add(cur.toString()); + cur.clear(); + } else { + cur.write(ch); + } + } else if (ch == "'") { + inQuote = true; + } + } + return out; +} diff --git a/lib/core/mcp/mcp_query_service.dart b/lib/core/mcp/mcp_query_service.dart index f28e2361..a4d964fd 100644 --- a/lib/core/mcp/mcp_query_service.dart +++ b/lib/core/mcp/mcp_query_service.dart @@ -144,6 +144,7 @@ class McpQueryService { identity: c.isIdentity, defaultValue: c.defaultValue, comment: c.comment, + enumValues: c.enumValues, references: [ for (final r in schema.relations) if (r.fromTable == t.name && r.fromColumn == c.name) @@ -433,6 +434,7 @@ class McpColumnInfo { this.defaultValue, this.identity = false, this.comment, + this.enumValues = const [], }); final String name; @@ -442,6 +444,7 @@ class McpColumnInfo { final String? defaultValue; final bool identity; final String? comment; + final List enumValues; /// `table.column` this column points to, when it is a foreign key. final String? references; @@ -455,6 +458,7 @@ class McpColumnInfo { if (identity) 'identity': true, if (defaultValue != null) 'default': defaultValue, if (comment != null) 'comment': comment, + if (enumValues.isNotEmpty) 'enum_values': enumValues, }; } diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index b9076002..7c4ff225 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -1390,9 +1390,17 @@ 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.domainBase case final b?) 'Domain over $b', + if (c.enumValues.isNotEmpty) 'Values: ${_enumList(c.enumValues)}', if (c.comment case final n?) 'Note: $n', ].join('\n'); + /// At most [cap] labels, then how many more. + static String _enumList(List values, {int cap = 12}) => + values.length <= cap + ? values.join(', ') + : '${values.take(cap).join(', ')} (+${values.length - cap} more)'; + /// Shadows read only on light surfaces, so a dark canvas gets twice the alpha. double _shadowAlpha(material.Color canvas) { final base = dragging ? 0.28 : 0.12; @@ -1559,6 +1567,7 @@ class _TableCard extends material.StatelessWidget { fontSize: 9, fontWeight: material.FontWeight.w700, color: switch (b) { + 'EN' => palette.type5, 'UQ' => palette.type3, 'AI' => palette.type4, _ => wb.mutedForeground, diff --git a/test/core/erd/erd_enum_values_test.dart b/test/core/erd/erd_enum_values_test.dart new file mode 100644 index 00000000..fa024c3e --- /dev/null +++ b/test/core/erd/erd_enum_values_test.dart @@ -0,0 +1,45 @@ +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'; + +/// #1280: enum labels and domain base types. +List _row(String type, {String values = '', String base = ''}) => + ['t', 'c', type, '0', '1', '0', '', '0', '', '', values, base]; + +ErdColumn _col(List row) => + ErdSchema.fromCatalog(columnRows: [row], fkRows: const []) + .tables + .single + .columns + .single; + +void main() { + test('PostgreSQL labels come in order, separated by U+001F', () { + final c = _col(_row('user_role_enum', + values: 'admin\u001feditor\u001fviewer')); + expect(c.enumValues, ['admin', 'editor', 'viewer']); + expect(c.badges, contains('EN')); + }); + + test("MySQL enum('a','b') is read from the type, quotes unescaped", () { + final c = _col(_row("enum('new','it''s paid','a,b')")); + expect(c.enumValues, ['new', "it's paid", 'a,b']); + }); + + test('a domain reports its base type; plain columns have neither', () { + final d = _col(_row('positive_int', base: 'integer')); + expect(d.domainBase, 'integer'); + expect(d.enumValues, isEmpty); + final plain = _col(['t', 'c', 'text', '0']); + expect(plain.enumValues, isEmpty); + expect(plain.domainBase, isNull); + expect(plain.badges, isEmpty); + }); + + test('the PostgreSQL catalog asks pg_enum and the domain base', () { + final sql = ErdCatalog.columnsSql(SqlDialect.postgres); + expect(sql, contains('pg_enum')); + expect(sql, contains('typbasetype')); + }); +}