diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 13edb906..bb315c00 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -77,7 +77,7 @@ and most stdio clients: | Tool | Arguments | Returns | |---|---|---| | `list_connections` | — | shared connections: `id`, `name`, `type`, `environment`, `database` | -| `list_tables` | `connection_id` | tables with their column count | +| `list_tables` | `connection_id` | tables and views with their column count | | `describe_table` | `connection_id`, `table` | columns, types, primary key, foreign key targets, indexes | | `sample_rows` | `connection_id`, `table`, `rows` (1-100, default 20) | first rows of the table | | `run_query` | `connection_id`, `sql` | `columns`, `rows`, `row_count`, `truncated` | @@ -97,8 +97,9 @@ can fix its query and retry. - **Rows:** at most 1000 per `run_query` (`truncated: true` when cut), 100 per `sample_rows`. **Cells** longer than 4 KB are cut with a marker. - **Time:** 15 s per statement. -- **Tables:** base tables of the current schema / database; views are not - listed yet. +- **Tables:** base tables and views of the current schema / database + (`kind: "view"`; PostgreSQL materialized views included). A view has no + primary key or index information. ## Activity log diff --git a/lib/core/erd/erd_catalog.dart b/lib/core/erd/erd_catalog.dart index 7221e911..33e4c8bf 100644 --- a/lib/core/erd/erd_catalog.dart +++ b/lib/core/erd/erd_catalog.dart @@ -59,6 +59,37 @@ SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type, CASE WHEN p."notnull" = 0 THEN 1 ELSE 0 END AS is_nullable 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''', + }; + + /// Columns of the views (and PostgreSQL materialized views) of the current + /// schema or database, in the shape of [columnsSql]: no primary key, every + /// column nullable. MCP lists them next to the tables. + static String viewColumnsSql(SqlDialect d) => switch (d) { + SqlDialect.postgres => ''' +SELECT ${_pgQualified('c', 'ns')} AS table_name, a.attname AS column_name, + format_type(a.atttypid, a.atttypmod) AS data_type, + 0 AS is_pk, 1 AS is_nullable +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 +WHERE ns.nspname = current_schema() + AND c.relkind IN ('v', 'm') + 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, 0, 1 +FROM information_schema.columns c +JOIN information_schema.tables t + ON t.table_schema = c.table_schema AND t.table_name = c.table_name + AND t.table_type = 'VIEW' +WHERE c.table_schema = DATABASE() +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, + 0 AS is_pk, 1 AS is_nullable +FROM sqlite_master m JOIN pragma_table_info(m.name) p +WHERE m.type = 'view' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid''', }; diff --git a/lib/core/mcp/mcp_query_service.dart b/lib/core/mcp/mcp_query_service.dart index 6cd14836..3180b991 100644 --- a/lib/core/mcp/mcp_query_service.dart +++ b/lib/core/mcp/mcp_query_service.dart @@ -82,10 +82,14 @@ class McpQueryService { } Future> listTables(int connectionId) async { - final schema = await _withDelegate(connectionId, _loadSchema); + final cat = await _withDelegate(connectionId, _loadCatalog); return [ - for (final t in schema.tables) - McpTableSummary(name: t.name, columnCount: t.columns.length), + for (final t in cat.schema.tables) + McpTableSummary( + name: t.name, + columnCount: t.columns.length, + isView: cat.views.contains(t.name), + ), ]; } @@ -93,7 +97,7 @@ class McpQueryService { /// the `schema://` resource. Future> schemaOverview(int connectionId) { return _withDelegate(connectionId, (delegate, dialect) async { - final schema = await _loadSchema(delegate, dialect); + final schema = (await _loadCatalog(delegate, dialect)).schema; return [for (final t in schema.tables) _describe(schema, t, const [])]; }); } @@ -103,8 +107,10 @@ class McpQueryService { String table, ) { return _withDelegate(connectionId, (delegate, dialect) async { - final schema = await _loadSchema(delegate, dialect); + final cat = await _loadCatalog(delegate, dialect); + final schema = cat.schema; final t = _findTable(schema, table); + if (cat.views.contains(t.name)) return _describe(schema, t, const []); final indexes = await _run( () => delegate.executeQuery( _indexesSql(dialect, t.name), @@ -150,7 +156,8 @@ class McpQueryService { }) { final n = rows.clamp(1, maxSampleRows); return _withDelegate(connectionId, (delegate, dialect) async { - final t = _findTable(await _loadSchema(delegate, dialect), table); + final t = + _findTable((await _loadCatalog(delegate, dialect)).schema, table); final result = await _run( () => delegate.executeQuery( 'SELECT * FROM ${quoteIdentifier(t.name, dialect)}', @@ -227,6 +234,35 @@ class McpQueryService { } } + /// Tables and keys, then the views: a name that is already a table stays a + /// table. Views have no keys and no indexes. + Future<({ErdSchema schema, Set views})> _loadCatalog( + SqlQueryRunner delegate, + SqlDialect dialect, + ) async { + final base = await _loadSchema(delegate, dialect); + final viewRows = await _run( + () => delegate.executeQuery(ErdCatalog.viewColumnsSql(dialect), + limit: _catalogRows, timeout: timeout), + dialect, + ); + final known = {for (final t in base.tables) t.name}; + final views = [ + for (final t in ErdSchema.fromCatalog( + columnRows: viewRows.rows, fkRows: const []) + .tables) + if (!known.contains(t.name)) t, + ]; + return ( + schema: ErdSchema( + tables: [...base.tables, ...views], + relations: base.relations, + truncated: base.truncated, + ), + views: {for (final v in views) v.name}, + ); + } + Future _loadSchema( SqlQueryRunner delegate, SqlDialect dialect, @@ -365,12 +401,21 @@ class McpConnectionInfo { } class McpTableSummary { - const McpTableSummary({required this.name, required this.columnCount}); + const McpTableSummary({ + required this.name, + required this.columnCount, + this.isView = false, + }); final String name; final int columnCount; + final bool isView; - Map toJson() => {'name': name, 'columns': columnCount}; + Map toJson() => { + 'name': name, + 'columns': columnCount, + if (isView) 'kind': 'view', + }; } class McpColumnInfo { diff --git a/lib/core/mcp/querya_mcp_server.dart b/lib/core/mcp/querya_mcp_server.dart index 1f23bed0..3ad9e51a 100644 --- a/lib/core/mcp/querya_mcp_server.dart +++ b/lib/core/mcp/querya_mcp_server.dart @@ -107,7 +107,8 @@ base class QueryaMcpServer extends MCPServer with ToolsSupport, ResourcesSupport static final _listTables = Tool( name: 'list_tables', - description: 'Lists the tables of a connection with their column count.', + description: 'Lists the tables and views of a connection with their column ' + 'count; views carry "kind": "view".', inputSchema: Schema.object( properties: {'connection_id': _connectionId}, required: ['connection_id'], diff --git a/test/core/mcp/mcp_query_service_test.dart b/test/core/mcp/mcp_query_service_test.dart index d8ad530c..d51023e4 100644 --- a/test/core/mcp/mcp_query_service_test.dart +++ b/test/core/mcp/mcp_query_service_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; 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/mcp/mcp_access_store.dart'; import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; import 'package:querya_desktop/core/security/connection_environment.dart'; @@ -222,6 +223,45 @@ void main() { ]); }); + group('views', () { + setUp(() { + db.onExecute = (sql) { + if (sql == ErdCatalog.viewColumnsSql(SqlDialect.postgres)) { + return const SqlExecutionResult(columns: ['t', 'c', 'ty', 'pk'], rows: [ + ['active_users', 'id', 'integer', '0'], + ['active_users', 'name', 'text', '0'], + // Already a table: stays a table. + ['users', 'id', 'integer', '0'], + ]); + } + return _catalog(sql); + }; + }); + + test('listTables names the views next to the tables', () async { + final tables = await service().listTables(1); + expect([for (final t in tables) t.toJson()], [ + {'name': 'users', 'columns': 2}, + {'name': 'orders', 'columns': 2}, + {'name': 'active_users', 'columns': 2, 'kind': 'view'}, + ]); + }); + + test('describeTable of a view has columns and no index query', () async { + final view = await service().describeTable(1, 'active_users'); + expect(view.columns.map((c) => c.name), ['id', 'name']); + expect(view.columns.any((c) => c.primaryKey), isFalse); + expect(view.indexes, isEmpty); + expect(db.executed.where((s) => s.contains('pg_indexes')), isEmpty); + }); + + test('sampleRows reads a view through its quoted name', () async { + await service().sampleRows(1, 'active_users', rows: 5); + expect( + db.executed.last, contains('"active_users"')); + }); + }); + test('describeTable has keys, references and indexes', () async { final users = await service().describeTable(1, 'users'); expect(users.columns.first.primaryKey, isTrue);