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
31 changes: 28 additions & 3 deletions lib/core/erd/erd_catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,35 @@ 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
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 <> ''
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
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
Expand All @@ -56,7 +71,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''',
Expand Down
38 changes: 36 additions & 2 deletions lib/core/erd/erd_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String> 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,
);
}

Expand Down Expand Up @@ -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<List<String>> columnRows,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
}
12 changes: 12 additions & 0 deletions lib/core/mcp/mcp_query_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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,
};
}

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 @@ -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,
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion lib/features/erd/erd_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion lib/features/erd/erd_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 27 additions & 1 deletion lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
})),
],
],
),
),
Expand Down
53 changes: 53 additions & 0 deletions test/core/erd/erd_column_markers_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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'));
// 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'));
expect(ErdCatalog.columnsSql(SqlDialect.sqlite), contains('dflt_value'));
});
}
13 changes: 13 additions & 0 deletions test/core/mcp/mcp_sqlite_readonly_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions test/features/erd/erd_card_width_colour_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<material.Tooltip>(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'));
});
}
Loading