diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index ae868e8c..8af12316 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -6,7 +6,10 @@ import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_object_metadata.dart'; +import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart'; import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; @@ -271,6 +274,68 @@ class ExtensionDriverSession { return _nodesFromResult(result); } + Future getCapabilities(ConnectionRow row) async { + final bridge = await ensureConnected(row); + try { + final result = await bridge.sendRequest('db.getCapabilities', { + 'connectionId': row.id, + }); + return ExtensionDriverCapabilities.fromRpc(result); + } catch (e) { + debugPrint('ExtensionDriverSession getCapabilities fallback ($e)'); + return const ExtensionDriverCapabilities(); + } + } + + Future getServerStats(ConnectionRow row) async { + final bridge = await ensureConnected(row); + try { + final result = await bridge.sendRequest('db.getServerStats', { + 'connectionId': row.id, + }); + return ExtensionServerStats.fromRpc(result); + } catch (e) { + debugPrint('ExtensionDriverSession getServerStats fallback ($e)'); + return const ExtensionServerStats(); + } + } + + Future getObjectMetadata( + ConnectionRow row, { + required String nodeId, + required String nodeType, + }) async { + final bridge = await ensureConnected(row); + try { + final result = await bridge.sendRequest('db.getObjectMetadata', { + 'connectionId': row.id, + 'nodeId': nodeId, + 'nodeType': nodeType, + }); + return ExtensionObjectMetadata.fromRpc(result); + } catch (e) { + debugPrint('ExtensionDriverSession getObjectMetadata fallback ($e)'); + return ExtensionObjectMetadata(nodeId: nodeId, nodeType: nodeType); + } + } + + Future cancelQuery(ConnectionRow row, {required String queryId}) async { + final bridge = await ensureConnected(row); + try { + final result = await bridge.sendRequest('db.cancelQuery', { + 'connectionId': row.id, + 'queryId': queryId, + }); + if (result is Map) { + return result['success'] == true || result['cancelled'] == true; + } + return true; + } catch (e) { + debugPrint('ExtensionDriverSession cancelQuery failed ($e)'); + return false; + } + } + Future disconnect(int connectionId) async { final bridge = _bridges.remove(connectionId); _manifests.remove(connectionId); diff --git a/lib/core/extensions/models/extension_driver_capabilities.dart b/lib/core/extensions/models/extension_driver_capabilities.dart new file mode 100644 index 00000000..088215fc --- /dev/null +++ b/lib/core/extensions/models/extension_driver_capabilities.dart @@ -0,0 +1,76 @@ +/// Feature flags reported by an extension database driver via `db.getCapabilities`. +class ExtensionDriverCapabilities { + const ExtensionDriverCapabilities({ + this.supportsTransactions = false, + this.supportsCancel = false, + this.supportsDDLInspection = false, + this.supportsPrivileges = false, + this.hasServerStats = false, + }); + + /// True if `db.query` supports transaction control queries (BEGIN, COMMIT, ROLLBACK). + final bool supportsTransactions; + + /// True if the driver supports `db.cancelQuery`. + final bool supportsCancel; + + /// True if the driver supports `db.getObjectDDL` / `db.getObjectMetadata`. + final bool supportsDDLInspection; + + /// True if the driver supports privilege inspection/grant management. + final bool supportsPrivileges; + + /// True if the driver supports `db.getServerStats`. + final bool hasServerStats; + + factory ExtensionDriverCapabilities.fromRpc(Object? raw) { + if (raw is! Map) return const ExtensionDriverCapabilities(); + final map = raw is Map + ? raw + : Map.from(raw); + return ExtensionDriverCapabilities( + supportsTransactions: + map['supportsTransactions'] == true || + map['supports_transactions'] == true, + supportsCancel: + map['supportsCancel'] == true || map['supports_cancel'] == true, + supportsDDLInspection: + map['supportsDDLInspection'] == true || + map['supports_ddl_inspection'] == true, + supportsPrivileges: + map['supportsPrivileges'] == true || + map['supports_privileges'] == true, + hasServerStats: + map['hasServerStats'] == true || map['has_server_stats'] == true, + ); + } + + Map toJson() => { + 'supportsTransactions': supportsTransactions, + 'supportsCancel': supportsCancel, + 'supportsDDLInspection': supportsDDLInspection, + 'supportsPrivileges': supportsPrivileges, + 'hasServerStats': hasServerStats, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ExtensionDriverCapabilities && + runtimeType == other.runtimeType && + supportsTransactions == other.supportsTransactions && + supportsCancel == other.supportsCancel && + supportsDDLInspection == other.supportsDDLInspection && + supportsPrivileges == other.supportsPrivileges && + hasServerStats == other.hasServerStats; + + @override + int get hashCode => + Object.hash( + supportsTransactions, + supportsCancel, + supportsDDLInspection, + supportsPrivileges, + hasServerStats, + ); +} diff --git a/lib/core/extensions/models/extension_object_metadata.dart b/lib/core/extensions/models/extension_object_metadata.dart new file mode 100644 index 00000000..65a633c2 --- /dev/null +++ b/lib/core/extensions/models/extension_object_metadata.dart @@ -0,0 +1,108 @@ +/// Represents a column in a database object inspected by an extension driver. +class ExtensionObjectColumn { + const ExtensionObjectColumn({ + required this.name, + required this.dataType, + this.isNullable = true, + this.defaultValue, + this.comment, + }); + + final String name; + final String dataType; + final bool isNullable; + final String? defaultValue; + final String? comment; + + factory ExtensionObjectColumn.fromRpc(Object? raw) { + if (raw is! Map) { + return const ExtensionObjectColumn(name: '', dataType: ''); + } + final map = raw is Map + ? raw + : Map.from(raw); + return ExtensionObjectColumn( + name: '${map['name'] ?? ''}'.trim(), + dataType: + '${map['dataType'] ?? map['data_type'] ?? map['type'] ?? ''}'.trim(), + isNullable: map['isNullable'] == false || + map['is_nullable'] == false || + map['nullable'] == false + ? false + : true, + defaultValue: map['defaultValue']?.toString() ?? + map['default_value']?.toString(), + comment: map['comment']?.toString(), + ); + } + + Map toJson() => { + 'name': name, + 'dataType': dataType, + 'isNullable': isNullable, + if (defaultValue != null) 'defaultValue': defaultValue, + if (comment != null) 'comment': comment, + }; +} + +/// DDL and structural metadata returned by `db.getObjectDDL` / `db.getObjectMetadata`. +class ExtensionObjectMetadata { + const ExtensionObjectMetadata({ + this.nodeId = '', + this.nodeType = '', + this.ddl, + this.columns = const [], + this.properties = const {}, + }); + + final String nodeId; + final String nodeType; + + /// The SQL creation statement (`CREATE TABLE ...`, `CREATE FUNCTION ...`). + final String? ddl; + + /// Structural columns if `nodeType` is a table or view. + final List columns; + + /// Additional key-value properties (e.g., engine, row count, comment). + final Map properties; + + factory ExtensionObjectMetadata.fromRpc(Object? raw) { + if (raw is! Map) return const ExtensionObjectMetadata(); + final map = raw is Map + ? raw + : Map.from(raw); + + final cols = []; + final rawCols = map['columns']; + if (rawCols is List) { + for (final col in rawCols) { + cols.add(ExtensionObjectColumn.fromRpc(col)); + } + } + + final props = {}; + final rawProps = map['properties']; + if (rawProps is Map) { + rawProps.forEach((k, v) { + if (v != null) props['$k'] = v; + }); + } + + return ExtensionObjectMetadata( + nodeId: '${map['nodeId'] ?? map['node_id'] ?? ''}'.trim(), + nodeType: '${map['nodeType'] ?? map['node_type'] ?? ''}'.trim(), + ddl: map['ddl']?.toString() ?? map['sql']?.toString(), + columns: cols, + properties: props, + ); + } + + Map toJson() => { + 'nodeId': nodeId, + 'nodeType': nodeType, + if (ddl != null) 'ddl': ddl, + 'columns': columns.map((c) => c.toJson()).toList(), + 'properties': properties, + }; +} diff --git a/lib/core/extensions/models/extension_server_stats.dart b/lib/core/extensions/models/extension_server_stats.dart new file mode 100644 index 00000000..7193267a --- /dev/null +++ b/lib/core/extensions/models/extension_server_stats.dart @@ -0,0 +1,87 @@ +/// Normalized server statistics reported by an extension driver via `db.getServerStats`. +class ExtensionServerStats { + const ExtensionServerStats({ + this.serverVersion, + this.uptimeSeconds, + this.activeConnections, + this.activeQueries, + this.memoryUsageBytes, + this.databaseSizes = const {}, + this.extraMetrics = const {}, + }); + + /// Server engine version string (e.g. "ClickHouse 24.3.1.1"). + final String? serverVersion; + + /// Total uptime in seconds. + final int? uptimeSeconds; + + /// Number of active client connections. + final int? activeConnections; + + /// Number of currently running queries on the server. + final int? activeQueries; + + /// Total memory consumption by the database process in bytes. + final int? memoryUsageBytes; + + /// Size in bytes for each database on the server (`{"default": 1048576, ...}`). + final Map databaseSizes; + + /// Driver-specific extra metrics (key -> string/num value). + final Map extraMetrics; + + factory ExtensionServerStats.fromRpc(Object? raw) { + if (raw is! Map) return const ExtensionServerStats(); + final map = raw is Map + ? raw + : Map.from(raw); + + final dbSizes = {}; + final rawSizes = map['databaseSizes'] ?? map['database_sizes']; + if (rawSizes is Map) { + rawSizes.forEach((k, v) { + if (v is num) dbSizes['$k'] = v.toInt(); + }); + } + + final extra = {}; + final rawExtra = map['extraMetrics'] ?? map['extra_metrics']; + if (rawExtra is Map) { + rawExtra.forEach((k, v) { + if (v != null) extra['$k'] = v; + }); + } + + int? toIntOrNull(Object? val) { + if (val is num) return val.toInt(); + if (val is String) return int.tryParse(val.trim()); + return null; + } + + return ExtensionServerStats( + serverVersion: map['serverVersion']?.toString() ?? + map['server_version']?.toString() ?? + map['version']?.toString(), + uptimeSeconds: toIntOrNull(map['uptimeSeconds'] ?? map['uptime_seconds']), + activeConnections: toIntOrNull( + map['activeConnections'] ?? map['active_connections']), + activeQueries: + toIntOrNull(map['activeQueries'] ?? map['active_queries']), + memoryUsageBytes: toIntOrNull( + map['memoryUsageBytes'] ?? map['memory_usage_bytes']), + databaseSizes: dbSizes, + extraMetrics: extra, + ); + } + + Map toJson() => { + if (serverVersion != null) 'serverVersion': serverVersion, + if (uptimeSeconds != null) 'uptimeSeconds': uptimeSeconds, + if (activeConnections != null) 'activeConnections': activeConnections, + if (activeQueries != null) 'activeQueries': activeQueries, + if (memoryUsageBytes != null) 'memoryUsageBytes': memoryUsageBytes, + 'databaseSizes': databaseSizes, + 'extraMetrics': extraMetrics, + }; +} diff --git a/test/core/extensions/extension_driver_session_test.dart b/test/core/extensions/extension_driver_session_test.dart index 6db7b7d7..de74d719 100644 --- a/test/core/extensions/extension_driver_session_test.dart +++ b/test/core/extensions/extension_driver_session_test.dart @@ -1,5 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; +import 'package:querya_desktop/core/extensions/models/extension_object_metadata.dart'; +import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; void main() { @@ -50,5 +53,80 @@ void main() { expect(params['user'], 'default'); expect(params['safeMode'], isTrue); }); + + test('ExtensionDriverCapabilities.fromRpc correctly parses capabilities', () { + final caps = ExtensionDriverCapabilities.fromRpc({ + 'supportsTransactions': true, + 'supportsCancel': true, + 'supportsDDLInspection': true, + 'supportsPrivileges': false, + 'hasServerStats': true, + }); + + expect(caps.supportsTransactions, isTrue); + expect(caps.supportsCancel, isTrue); + expect(caps.supportsDDLInspection, isTrue); + expect(caps.supportsPrivileges, isFalse); + expect(caps.hasServerStats, isTrue); + }); + + test('ExtensionServerStats.fromRpc normalizes metrics map', () { + final stats = ExtensionServerStats.fromRpc({ + 'serverVersion': 'ClickHouse 24.3.1.1', + 'uptimeSeconds': 3600, + 'activeConnections': '12', + 'activeQueries': 3, + 'memoryUsageBytes': 104857600, + 'databaseSizes': { + 'analytics': 50000000, + 'default': 1024, + }, + 'extraMetrics': { + 'read_rows': 100000, + }, + }); + + expect(stats.serverVersion, 'ClickHouse 24.3.1.1'); + expect(stats.uptimeSeconds, 3600); + expect(stats.activeConnections, 12); + expect(stats.activeQueries, 3); + expect(stats.memoryUsageBytes, 104857600); + expect(stats.databaseSizes['analytics'], 50000000); + expect(stats.extraMetrics['read_rows'], 100000); + }); + + test('ExtensionObjectMetadata.fromRpc parses DDL and column list', () { + final metadata = ExtensionObjectMetadata.fromRpc({ + 'nodeId': 'events_table', + 'nodeType': 'table', + 'ddl': 'CREATE TABLE events (id UInt64, event_time DateTime) ENGINE = MergeTree() ORDER BY id;', + 'columns': [ + { + 'name': 'id', + 'dataType': 'UInt64', + 'isNullable': false, + 'comment': 'Primary ID', + }, + { + 'name': 'event_time', + 'dataType': 'DateTime', + 'isNullable': true, + }, + ], + 'properties': { + 'engine': 'MergeTree', + }, + }); + + expect(metadata.nodeId, 'events_table'); + expect(metadata.nodeType, 'table'); + expect(metadata.ddl, contains('MergeTree()')); + expect(metadata.columns.length, 2); + expect(metadata.columns[0].name, 'id'); + expect(metadata.columns[0].dataType, 'UInt64'); + expect(metadata.columns[0].isNullable, isFalse); + expect(metadata.columns[0].comment, 'Primary ID'); + expect(metadata.properties['engine'], 'MergeTree'); + }); }); }