From 98e53777a8f108b1dc12c8da522eddb4b472878e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 13:46:29 +0300 Subject: [PATCH 1/3] feat(erd): column markers for unique, not null, default and identity (#1277) ErdColumn gets isUnique, defaultValue and isIdentity, read by the catalog query of each dialect: - PostgreSQL: single-column unique indexes (not the PK), pg_attrdef defaults, attidentity or a nextval() default; - MySQL: column_key UNI, column_default, extra auto_increment; - SQLite: single-column unique indexes from pragma_index_list, dflt_value, INTEGER PRIMARY KEY (the rowid alias). A card row shows UQ / AI / DF after the type (nullable keeps its ?), its tooltip lists every fact one a line, the card is measured with them, and the SVG export prints them too. MCP describe_table reports unique, identity and default. --- lib/core/erd/erd_catalog.dart | 30 ++++++++++-- lib/core/erd/erd_model.dart | 38 ++++++++++++++- lib/core/mcp/mcp_query_service.dart | 12 +++++ lib/features/erd/erd_card_measure.dart | 6 ++- lib/features/erd/erd_export.dart | 6 ++- lib/features/erd/erd_layout.dart | 5 +- lib/features/erd/erd_view.dart | 28 ++++++++++- test/core/erd/erd_column_markers_test.dart | 51 +++++++++++++++++++++ test/core/mcp/mcp_sqlite_readonly_test.dart | 13 ++++++ 9 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 test/core/erd/erd_column_markers_test.dart diff --git a/lib/core/erd/erd_catalog.dart b/lib/core/erd/erd_catalog.dart index 33e4c8bf..6af6cbd4 100644 --- a/lib/core/erd/erd_catalog.dart +++ b/lib/core/erd/erd_catalog.dart @@ -33,12 +33,23 @@ class ErdCatalog { 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 + CASE WHEN a.attnotnull THEN 0 ELSE 1 END AS is_nullable, + CASE WHEN EXISTS ( + SELECT 1 FROM pg_catalog.pg_index ui + WHERE ui.indrelid = c.oid AND ui.indisunique AND NOT ui.indisprimary + AND ui.indnatts = 1 AND ui.indkey[0] = a.attnum + ) THEN 1 ELSE 0 END AS is_unique, + 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 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 LEFT JOIN pg_catalog.pg_constraint pk ON pk.conrelid = c.oid AND pk.contype = 'p' AND a.attnum = ANY (pk.conkey) +LEFT JOIN pg_catalog.pg_attrdef df + ON df.adrelid = c.oid AND df.adnum = a.attnum WHERE ns.nspname = ${schema == null ? 'current_schema()' : "'${_lit(schema)}'"} AND c.relkind IN ('r', 'p') AND a.attnum > 0 AND NOT a.attisdropped @@ -46,7 +57,10 @@ 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), - IF(c.is_nullable = 'YES', 1, 0) + 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) FROM information_schema.columns c JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name @@ -56,7 +70,17 @@ ORDER BY c.table_name, c.ordinal_position''', SqlDialect.sqlite => ''' SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type, CASE WHEN p.pk > 0 THEN 1 ELSE 0 END AS is_pk, - CASE WHEN p."notnull" = 0 THEN 1 ELSE 0 END AS is_nullable + CASE WHEN p."notnull" = 0 THEN 1 ELSE 0 END AS is_nullable, + CASE WHEN EXISTS ( + SELECT 1 FROM pragma_index_list(m.name) il + WHERE il."unique" = 1 AND il.origin <> 'pk' + AND (SELECT COUNT(*) FROM pragma_index_info(il.name)) = 1 + AND (SELECT ii.name FROM pragma_index_info(il.name) ii) = p.name + ) THEN 1 ELSE 0 END AS is_unique, + 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 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 65d03015..ca8b24ec 100644 --- a/lib/core/erd/erd_model.dart +++ b/lib/core/erd/erd_model.dart @@ -8,6 +8,9 @@ class ErdColumn { this.isPrimaryKey = false, this.isForeignKey = false, this.isNullable = false, + this.isUnique = false, + this.defaultValue, + this.isIdentity = false, }); final String name; @@ -16,12 +19,33 @@ class ErdColumn { final bool isForeignKey; final bool isNullable; + /// A single-column unique constraint or index on it (a primary key is not + /// counted: it is unique anyway). + final bool isUnique; + + /// The default expression as the database prints it; null when none. + final String? defaultValue; + + /// Filled by the database: identity, serial / sequence default, + /// auto_increment, or SQLite's INTEGER PRIMARY KEY. + final bool isIdentity; + + /// Short markers after the type on a card and in exports (#1277). + List get badges => [ + if (isUnique && !isPrimaryKey) 'UQ', + if (isIdentity) 'AI', + if (defaultValue != null && !isIdentity) 'DF', + ]; + ErdColumn copyWith({bool? isForeignKey}) => ErdColumn( name: name, type: type, isPrimaryKey: isPrimaryKey, isForeignKey: isForeignKey ?? this.isForeignKey, isNullable: isNullable, + isUnique: isUnique, + defaultValue: defaultValue, + isIdentity: isIdentity, ); } @@ -70,8 +94,8 @@ class ErdSchema { /// Builds a schema from flat catalog rows. /// - /// [columnRows]: `table, column, type, isPk` and an optional fifth - /// `isNullable`. [fkRows]: `table, column, refTable, refColumn`. Relations to + /// [columnRows]: `table, column, type, isPk`, then optionally `isNullable`, + /// `isUnique`, `default` and `isIdentity` (#1277). [fkRows]: `table, column, refTable, refColumn`. Relations to /// unknown tables are dropped. factory ErdSchema.fromCatalog({ required List> columnRows, @@ -91,6 +115,9 @@ class ErdSchema { type: r[2], isPrimaryKey: _truthy(r[3]), isNullable: r.length > 4 && _truthy(r[4]), + isUnique: r.length > 5 && _truthy(r[5]), + defaultValue: r.length > 6 ? _default(r[6]) : null, + isIdentity: r.length > 7 && _truthy(r[7]), )); } final nullable = { @@ -135,3 +162,10 @@ class ErdSchema { return s == '1' || s == 't' || s == 'true' || s == 'yes'; } } + +/// A catalog default cell: empty and SQL NULL mean "no default". +String? _default(String raw) { + final v = raw.trim(); + if (v.isEmpty || v == 'NULL' || v.toLowerCase() == 'null') return null; + return v; +} diff --git a/lib/core/mcp/mcp_query_service.dart b/lib/core/mcp/mcp_query_service.dart index 3180b991..b54423d0 100644 --- a/lib/core/mcp/mcp_query_service.dart +++ b/lib/core/mcp/mcp_query_service.dart @@ -139,6 +139,9 @@ class McpQueryService { name: c.name, type: c.type, primaryKey: c.isPrimaryKey, + unique: c.isUnique && !c.isPrimaryKey, + identity: c.isIdentity, + defaultValue: c.defaultValue, references: [ for (final r in schema.relations) if (r.fromTable == t.name && r.fromColumn == c.name) @@ -424,11 +427,17 @@ class McpColumnInfo { required this.type, this.primaryKey = false, this.references, + this.unique = false, + this.defaultValue, + this.identity = false, }); final String name; final String type; final bool primaryKey; + final bool unique; + final String? defaultValue; + final bool identity; /// `table.column` this column points to, when it is a foreign key. final String? references; @@ -438,6 +447,9 @@ class McpColumnInfo { 'type': type, if (primaryKey) 'primary_key': true, if (references != null) 'references': references, + if (unique) 'unique': true, + if (identity) 'identity': true, + if (defaultValue != null) 'default': defaultValue, }; } diff --git a/lib/features/erd/erd_card_measure.dart b/lib/features/erd/erd_card_measure.dart index 90737b47..60232fd7 100644 --- a/lib/features/erd/erd_card_measure.dart +++ b/lib/features/erd/erd_card_measure.dart @@ -36,6 +36,7 @@ class ErdCardMeasure { static const _count = TextStyle(fontSize: 10); static const _name = TextStyle(fontSize: 12); static const _pkName = TextStyle(fontSize: 12, fontWeight: FontWeight.w600); + static const _badge = TextStyle(fontSize: 9, fontWeight: FontWeight.w700); static const _type = TextStyle( fontSize: 10, fontFamily: QueryaTypography.mono, @@ -52,9 +53,12 @@ class ErdCardMeasure { 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. - final row = 60 + + var row = 60 + _text(c.name, c.isPrimaryKey ? _pkName : _name) + _text(type, _type); + for (final b in c.badges) { + row += 3 + _text(b, _badge); + } if (row > w) w = row; } return w diff --git a/lib/features/erd/erd_export.dart b/lib/features/erd/erd_export.dart index 1e466262..e02493f0 100644 --- a/lib/features/erd/erd_export.dart +++ b/lib/features/erd/erd_export.dart @@ -234,7 +234,11 @@ class ErdExport { b.writeln(_pill(x, rowTop, 'FK', colors.foreignKey)); } final nameX = left + 10 + slot; - final type = c.isNullable ? '${c.type}?' : c.type; + // Type, then the UQ / AI / DF markers as on screen (#1277). + final type = [ + c.isNullable ? '${c.type}?' : c.type, + ...c.badges, + ].join(' '); final typeWidth = min(type.length * _typeCharPx, typeAvail); final nameAvail = inner - slot - typeWidth - 6; b diff --git a/lib/features/erd/erd_layout.dart b/lib/features/erd/erd_layout.dart index bb1274dd..5dd5fa37 100644 --- a/lib/features/erd/erd_layout.dart +++ b/lib/features/erd/erd_layout.dart @@ -46,7 +46,10 @@ class ErdLayout { 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. - final row = 54 + c.name.length * _nameCharPx + type.length * _typeCharPx; + final row = 54 + + c.name.length * _nameCharPx + + type.length * _typeCharPx + + c.badges.length * 18; if (row > w) w = row; } return w.clamp(minCardWidth, maxCardWidth).ceilToDouble(); diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index db71fa22..a413b8a4 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -1366,6 +1366,17 @@ class _TableCard extends material.StatelessWidget { final void Function(material.Offset delta) onDragMove; final material.VoidCallback onDragEnd; + /// Everything a row knows, one fact a line. + static String _columnTip(ErdColumn c) => [ + '${c.name} ${c.type}', + if (c.isPrimaryKey) 'Primary key', + if (c.isForeignKey) 'Foreign key', + c.isNullable ? 'Nullable' : 'Not null', + if (c.isUnique && !c.isPrimaryKey) 'Unique', + if (c.isIdentity) 'Generated (identity / auto increment)', + if (c.defaultValue case final d?) 'Default: $d', + ].join('\n'); + /// 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; @@ -1447,7 +1458,7 @@ class _TableCard extends material.StatelessWidget { material.SizedBox( height: ErdLayout.rowHeight, child: material.Tooltip( - message: '${c.name} ${c.type}', + message: _columnTip(c), child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 10), @@ -1502,6 +1513,21 @@ class _TableCard extends material.StatelessWidget { QueryaTypography.monoFontFamilyFallback, color: wb.mutedForeground)), ), + // UQ / AI / DF after the type (#1277). + for (final b in c.badges) ...[ + const material.SizedBox(width: 3), + Text(b, + key: material.ValueKey( + 'erd_badge_${table.name}_${c.name}_$b'), + style: material.TextStyle( + fontSize: 9, + fontWeight: material.FontWeight.w700, + color: switch (b) { + 'UQ' => palette.type3, + 'AI' => palette.type4, + _ => wb.mutedForeground, + })), + ], ], ), ), diff --git a/test/core/erd/erd_column_markers_test.dart b/test/core/erd/erd_column_markers_test.dart new file mode 100644 index 00000000..a832c61f --- /dev/null +++ b/test/core/erd/erd_column_markers_test.dart @@ -0,0 +1,51 @@ +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'; + +/// #1277: unique, default and identity come from the catalog. +void main() { + test('catalog rows carry unique, default and identity', () { + final s = ErdSchema.fromCatalog(columnRows: const [ + ['users', 'id', 'integer', '1', '0', '0', "nextval('users_id_seq'::regclass)", '1'], + ['users', 'email', 'text', '0', '0', '1', 'NULL', '0'], + ['users', 'status', 'text', '0', '1', '0', "'active'::text", '0'], + ['users', 'note', 'text', '0', '1'], + ], fkRows: const []); + final c = {for (final c in s.tables.single.columns) c.name: c}; + + expect(c['id']!.isIdentity, isTrue); + expect(c['id']!.badges, ['AI'], reason: 'a PK is not also "unique"'); + expect(c['email']!.isUnique, isTrue); + expect(c['email']!.defaultValue, isNull, reason: 'NULL is no default'); + expect(c['email']!.badges, ['UQ']); + expect(c['status']!.defaultValue, "'active'::text"); + expect(c['status']!.badges, ['DF']); + expect(c['note']!.badges, isEmpty, reason: 'old five-column rows still read'); + }); + + test('a foreign key keeps the markers', () { + final s = ErdSchema.fromCatalog(columnRows: const [ + ['orders', 'user_id', 'int', '0', '0', '1', '', '0'], + ['users', 'id', 'int', '1', '0', '0', '', '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.isUnique, isTrue); + }); + + test('every dialect asks the catalog for the markers', () { + for (final d in SqlDialect.values) { + final sql = ErdCatalog.columnsSql(d); + expect(sql.toLowerCase(), contains('default'), reason: d.name); + } + expect(ErdCatalog.columnsSql(SqlDialect.postgres), contains('attidentity')); + expect(ErdCatalog.columnsSql(SqlDialect.postgres), contains('indisunique')); + expect(ErdCatalog.columnsSql(SqlDialect.mysql), contains("'UNI'")); + expect(ErdCatalog.columnsSql(SqlDialect.mysql), contains('auto_increment')); + expect(ErdCatalog.columnsSql(SqlDialect.sqlite), contains('pragma_index_list')); + expect(ErdCatalog.columnsSql(SqlDialect.sqlite), contains('dflt_value')); + }); +} diff --git a/test/core/mcp/mcp_sqlite_readonly_test.dart b/test/core/mcp/mcp_sqlite_readonly_test.dart index 0f481ee5..40c9df18 100644 --- a/test/core/mcp/mcp_sqlite_readonly_test.dart +++ b/test/core/mcp/mcp_sqlite_readonly_test.dart @@ -34,6 +34,8 @@ void main() { await seed.execute('CREATE TABLE orders (id INTEGER PRIMARY KEY, ' 'user_id INTEGER REFERENCES users(id), total REAL)'); await seed.execute('CREATE INDEX orders_user ON orders (user_id)'); + await seed.execute('CREATE TABLE accounts (id INTEGER PRIMARY KEY, ' + "email TEXT UNIQUE, status TEXT DEFAULT 'new')"); await seed.execute("INSERT INTO users VALUES (1, 'ann'), (2, 'bob')"); await seed.execute('INSERT INTO orders VALUES (1, 1, 9.5)'); await seed.disconnect(); @@ -77,6 +79,17 @@ void main() { expect(plan, isNotEmpty); }); + test('the real SQLite catalog reports unique, default and identity (#1277)', + () async { + final accounts = await service.describeTable(42, 'accounts'); + final c = {for (final c in accounts.columns) c.name: c}; + expect(c['id']!.identity, isTrue, reason: 'INTEGER PRIMARY KEY'); + expect(c['email']!.unique, isTrue); + expect(c['email']!.defaultValue, isNull); + expect(c['status']!.defaultValue, "'new'"); + expect(c['status']!.unique, isFalse); + }); + test('the database itself refuses a write on the MCP session', () async { final delegate = createReadOnlyMcpDelegate(row, SqlDialect.sqlite); addTearDown(delegate.dispose); From 3061275d0e4bb14c65fad57b4314586c0ee64284 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 13:46:51 +0300 Subject: [PATCH 2/3] test(erd): a card row shows its markers and lists them in its tooltip --- .../erd/erd_card_width_colour_test.dart | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/features/erd/erd_card_width_colour_test.dart b/test/features/erd/erd_card_width_colour_test.dart index 4b3e35f0..74692bab 100644 --- a/test/features/erd/erd_card_width_colour_test.dart +++ b/test/features/erd/erd_card_width_colour_test.dart @@ -124,4 +124,43 @@ void main() { expect(svg, contains('fill="${const ErdSvgColors().header}"')); }); }); + + testWidgets('a row shows UQ, AI and DF and lists them in its tooltip (#1277)', + (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'], + ['users', 'email', 'TEXT', '0', '0', '1', 'NULL', '0'], + ['users', 'status', 'TEXT', '0', '1', '0', "'new'", '0'], + ]); + } + return const SqlExecutionResult(); + }), + dialect: SqlDialect.sqlite, + ), + ), + )); + await t.pump(); + await t.pump(); + expect(find.byKey(const material.ValueKey('erd_badge_users_id_AI')), + findsOneWidget); + expect(find.byKey(const material.ValueKey('erd_badge_users_email_UQ')), + findsOneWidget); + expect(find.byKey(const material.ValueKey('erd_badge_users_status_DF')), + findsOneWidget); + expect(find.byKey(const material.ValueKey('erd_badge_users_id_UQ')), + findsNothing); + final tip = t.widget(find.ancestor( + of: find.byKey(const material.ValueKey('erd_badge_users_status_DF')), + matching: find.byType(material.Tooltip), + )); + expect(tip.message, contains("Default: 'new'")); + expect(tip.message, contains('Nullable')); + }); } From 5693e76aeca5a426c7f64768718435185cd574df Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 13:50:04 +0300 Subject: [PATCH 3/3] fix(erd): a partial unique index does not mark a PostgreSQL column unique Checked on a live database through MCP: user_specialties.user_id has 'UNIQUE (user_id) WHERE is_primary', one primary specialty per user, and was shown as unique. Only indexes without a predicate count now. --- lib/core/erd/erd_catalog.dart | 1 + test/core/erd/erd_column_markers_test.dart | 2 ++ 2 files changed, 3 insertions(+) diff --git a/lib/core/erd/erd_catalog.dart b/lib/core/erd/erd_catalog.dart index 6af6cbd4..fb585e02 100644 --- a/lib/core/erd/erd_catalog.dart +++ b/lib/core/erd/erd_catalog.dart @@ -38,6 +38,7 @@ SELECT ${_pgQualified('c', 'ns')} AS table_name, a.attname AS column_name, SELECT 1 FROM pg_catalog.pg_index ui WHERE ui.indrelid = c.oid AND ui.indisunique AND NOT ui.indisprimary AND ui.indnatts = 1 AND ui.indkey[0] = a.attnum + AND ui.indpred IS NULL ) THEN 1 ELSE 0 END AS is_unique, pg_get_expr(df.adbin, df.adrelid) AS column_default, CASE WHEN a.attidentity <> '' diff --git a/test/core/erd/erd_column_markers_test.dart b/test/core/erd/erd_column_markers_test.dart index a832c61f..1c617b94 100644 --- a/test/core/erd/erd_column_markers_test.dart +++ b/test/core/erd/erd_column_markers_test.dart @@ -43,6 +43,8 @@ void main() { } expect(ErdCatalog.columnsSql(SqlDialect.postgres), contains('attidentity')); expect(ErdCatalog.columnsSql(SqlDialect.postgres), contains('indisunique')); + // A partial unique index (`WHERE is_primary`) does not make a column unique. + expect(ErdCatalog.columnsSql(SqlDialect.postgres), contains('indpred IS NULL')); expect(ErdCatalog.columnsSql(SqlDialect.mysql), contains("'UNI'")); expect(ErdCatalog.columnsSql(SqlDialect.mysql), contains('auto_increment')); expect(ErdCatalog.columnsSql(SqlDialect.sqlite), contains('pragma_index_list'));