From 0113175788f3389372b17dd8b92f37dc022f4f6f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:06:19 +0300 Subject: [PATCH 01/12] feat(postgres): add postgres package and PostgresConnection class Made-with: Cursor --- .flutter-plugins-dependencies | 2 +- lib/core/database/postgres_connection.dart | 283 +++++++++++++++++++++ pubspec.yaml | 1 + 3 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 lib/core/database/postgres_connection.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 8acd192c..70009b2e 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-10 05:45:19.165964","version":"3.41.4","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-14 09:50:21.245489","version":"3.41.4","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart new file mode 100644 index 00000000..eb208b19 --- /dev/null +++ b/lib/core/database/postgres_connection.dart @@ -0,0 +1,283 @@ +import 'package:postgres/postgres.dart'; + +/// PostgreSQL connection using the pure-Dart `postgres` package. +class PostgresConnection { + PostgresConnection({ + required this.id, + required this.name, + required this.host, + this.port = 5432, + this.username, + this.password, + this.database, + this.useSSL = false, + this.connectionString, + }); + + final int id; + final String name; + final String host; + final int port; + final String? username; + final String? password; + final String? database; + final bool useSSL; + final String? connectionString; + + Connection? _conn; + bool _isConnected = false; + + bool get isConnected => _isConnected && _conn != null; + + Endpoint _buildEndpoint() { + return Endpoint( + host: host, + port: port, + database: database ?? 'postgres', + username: username, + password: password, + ); + } + + ConnectionSettings _buildSettings() { + return ConnectionSettings( + sslMode: useSSL ? SslMode.require : SslMode.disable, + connectTimeout: const Duration(seconds: 10), + queryTimeout: const Duration(seconds: 30), + ); + } + + Future connect() async { + if (_isConnected && _conn != null) return; + try { + _conn = await Connection.open( + _buildEndpoint(), + settings: _buildSettings(), + ); + _isConnected = true; + } catch (e) { + _isConnected = false; + _conn = null; + rethrow; + } + } + + Future disconnect() async { + _isConnected = false; + final c = _conn; + _conn = null; + try { + await c?.close(); + } catch (_) {} + } + + Future testConnection() async { + try { + await connect(); + if (_conn != null) { + await _conn!.execute('SELECT 1'); + return true; + } + return false; + } catch (_) { + return false; + } finally { + await disconnect(); + } + } + + Future execute(String sql) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + return _conn!.execute(sql); + } + + Future> listDatabases() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname", + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listSchemas() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + "SELECT schema_name FROM information_schema.schemata " + "WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') " + "ORDER BY schema_name", + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listTables({String schema = 'public'}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = @schema AND table_type = 'BASE TABLE' " + "ORDER BY table_name", + ), + parameters: {'schema': schema}, + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listViews({String schema = 'public'}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + "SELECT table_name FROM information_schema.views " + "WHERE table_schema = @schema " + "ORDER BY table_name", + ), + parameters: {'schema': schema}, + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listFunctions({String schema = 'public'}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + "SELECT routine_name FROM information_schema.routines " + "WHERE routine_schema = @schema AND routine_type = 'FUNCTION' " + "ORDER BY routine_name", + ), + parameters: {'schema': schema}, + ); + return result.map((row) => row[0] as String).toList(); + } + + Future> listSequences({String schema = 'public'}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + "SELECT sequence_name FROM information_schema.sequences " + "WHERE sequence_schema = @schema " + "ORDER BY sequence_name", + ), + parameters: {'schema': schema}, + ); + return result.map((row) => row[0] as String).toList(); + } + + Future serverVersion() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute('SELECT version()'); + return result.first[0] as String; + } + + /// Fetches key server statistics for the stats dashboard. + Future> serverStats() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final stats = {}; + + final ver = await _conn!.execute('SELECT version()'); + stats['version'] = ver.first[0] as String; + + final settings = await _conn!.execute( + "SELECT name, setting FROM pg_settings " + "WHERE name IN ('max_connections','shared_buffers','work_mem'," + "'effective_cache_size','server_version','data_directory'," + "'listen_addresses','port','server_encoding','timezone')", + ); + final settingsMap = {}; + for (final row in settings) { + settingsMap[row[0] as String] = row[1] as String; + } + stats['settings'] = settingsMap; + + final activity = await _conn!.execute( + "SELECT count(*) AS total, " + "count(*) FILTER (WHERE state = 'active') AS active, " + "count(*) FILTER (WHERE state = 'idle') AS idle " + "FROM pg_stat_activity", + ); + if (activity.isNotEmpty) { + stats['connections_total'] = activity.first[0]; + stats['connections_active'] = activity.first[1]; + stats['connections_idle'] = activity.first[2]; + } + + final dbStats = await _conn!.execute( + "SELECT datname, pg_database_size(datname) AS size, " + "numbackends, xact_commit, xact_rollback, blks_read, blks_hit, " + "tup_returned, tup_fetched, tup_inserted, tup_updated, tup_deleted " + "FROM pg_stat_database WHERE datname NOT LIKE 'template%' " + "ORDER BY datname", + ); + final dbList = >[]; + for (final row in dbStats) { + dbList.add({ + 'datname': row[0], + 'size': row[1], + 'numbackends': row[2], + 'xact_commit': row[3], + 'xact_rollback': row[4], + 'blks_read': row[5], + 'blks_hit': row[6], + 'tup_returned': row[7], + 'tup_fetched': row[8], + 'tup_inserted': row[9], + 'tup_updated': row[10], + 'tup_deleted': row[11], + }); + } + stats['databases'] = dbList; + + try { + final uptime = await _conn!.execute( + "SELECT extract(epoch from (now() - pg_postmaster_start_time()))::bigint", + ); + stats['uptime_seconds'] = uptime.first[0]; + } catch (_) {} + + try { + final dbSize = await _conn!.execute( + "SELECT pg_database_size(current_database())", + ); + stats['current_db_size'] = dbSize.first[0]; + } catch (_) {} + + return stats; + } + + /// Connect to a specific database (creates a new connection). + Future connectToDatabase(String dbName) async { + return PostgresConnection( + id: id, + name: name, + host: host, + port: port, + username: username, + password: password, + database: dbName, + useSSL: useSSL, + ); + } +} + +class PostgresConnectionException implements Exception { + PostgresConnectionException(this.message); + final String message; + @override + String toString() => message; +} diff --git a/pubspec.yaml b/pubspec.yaml index 69136f29..7a4bd2d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: sqflite_common_ffi: ^2.3.2 mongo_dart: ^0.10.8 redis: ^4.0.0 + postgres: ^3.5.6 fl_chart: ^0.69.0 dev_dependencies: From a2afe97cbfb1fa4025b33e1f894c86cb693af8fe Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:06:24 +0300 Subject: [PATCH 02/12] feat(postgres): add PostgreSQL connection form dialog Made-with: Cursor --- .../postgresql_connection_form.dart | 452 ++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 lib/features/postgresql/postgresql_connection_form.dart diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart new file mode 100644 index 00000000..092be759 --- /dev/null +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -0,0 +1,452 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows PostgreSQL connection form dialog. +/// Returns ConnectionRow if saved, null if cancelled. +Future showPostgresConnectionForm( + BuildContext context, { + int? folderId, +}) async { + return showDialog( + context: context, + barrierColor: material.Colors.black54, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: + const material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), + child: _PostgresConnectionFormContent(folderId: folderId), + ), + ); +} + +class _PostgresConnectionFormContent extends material.StatefulWidget { + const _PostgresConnectionFormContent({this.folderId}); + + final int? folderId; + + @override + material.State<_PostgresConnectionFormContent> createState() => + _PostgresConnectionFormContentState(); +} + +class _PostgresConnectionFormContentState + extends material.State<_PostgresConnectionFormContent> { + final _nameController = material.TextEditingController(); + final _hostController = material.TextEditingController(text: 'localhost'); + final _portController = material.TextEditingController(text: '5432'); + final _databaseController = material.TextEditingController(text: 'postgres'); + final _usernameController = material.TextEditingController(text: 'postgres'); + final _passwordController = material.TextEditingController(); + + bool _useSSL = false; + bool _showPassword = false; + bool _isTesting = false; + String? _testResult; + Timer? _dismissTimer; + + @override + void initState() { + super.initState(); + _nameController.addListener(_onFieldChanged); + _hostController.addListener(_onFieldChanged); + _portController.addListener(_onFieldChanged); + _databaseController.addListener(_onFieldChanged); + _usernameController.addListener(_onFieldChanged); + } + + void _onFieldChanged() => setState(() {}); + + bool get _formValid { + final host = _hostController.text.trim(); + final db = _databaseController.text.trim(); + return host.isNotEmpty && db.isNotEmpty; + } + + void _showTestResult(String result) { + _dismissTimer?.cancel(); + setState(() { + _isTesting = false; + _testResult = result; + }); + _dismissTimer = Timer(const Duration(seconds: 5), () { + if (mounted) setState(() => _testResult = null); + }); + } + + void _dismissResult() { + _dismissTimer?.cancel(); + _dismissTimer = null; + setState(() => _testResult = null); + } + + Future _testConnection() async { + if (!_formValid) return; + _dismissTimer?.cancel(); + _dismissTimer = null; + setState(() { + _isTesting = true; + _testResult = null; + }); + try { + final conn = PostgresConnection( + id: 0, + name: _nameController.text.trim().isEmpty + ? 'test' + : _nameController.text.trim(), + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 5432, + database: _databaseController.text.trim().isEmpty + ? null + : _databaseController.text.trim(), + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: _passwordController.text.isEmpty + ? null + : _passwordController.text, + useSSL: _useSSL, + ); + final ok = await conn.testConnection(); + if (mounted) _showTestResult(ok ? 'success' : 'failed'); + } catch (e) { + if (mounted) _showTestResult('error: $e'); + } + } + + void _save() { + if (!_formValid) return; + final name = _nameController.text.trim(); + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()) ?? 5432; + final database = _databaseController.text.trim(); + final displayName = + name.isNotEmpty ? name : 'PostgreSQL $host:$port/$database'; + final row = ConnectionRow( + type: 'postgresql', + name: displayName, + host: host, + port: port, + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: + _passwordController.text.isEmpty ? null : _passwordController.text, + databaseName: database.isEmpty ? null : database, + useSSL: _useSSL, + folderId: widget.folderId, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); + material.Navigator.of(context).pop(row); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _nameController.removeListener(_onFieldChanged); + _hostController.removeListener(_onFieldChanged); + _portController.removeListener(_onFieldChanged); + _databaseController.removeListener(_onFieldChanged); + _usernameController.removeListener(_onFieldChanged); + _nameController.dispose(); + _hostController.dispose(); + _portController.dispose(); + _databaseController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + + return material.Container( + constraints: + const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + material.SizedBox( + width: 24, + height: 24, + child: material.Image.asset( + 'assets/images/postgresql_icon.png', + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + material.Icons.storage_rounded, + size: 24, + color: theme.primary, + ), + ), + ), + const Gap(12), + const Text('PostgreSQL Connection').large().semiBold(), + ], + ), + const Gap(8), + const Text('Configure your PostgreSQL connection settings') + .muted() + .small(), + ], + ), + ), + const material.Divider(height: 1), + // Form body + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Connection Name + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('My PostgreSQL Server'), + ), + const Gap(16), + // Host + Port + material.Row( + children: [ + material.Expanded( + flex: 3, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Host').small().semiBold(), + const Gap(8), + TextField( + controller: _hostController, + placeholder: const Text('localhost'), + ), + ], + ), + ), + const Gap(12), + material.Expanded( + flex: 1, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Port').small().semiBold(), + const Gap(8), + TextField( + controller: _portController, + placeholder: const Text('5432'), + ), + ], + ), + ), + ], + ), + const Gap(16), + // Database + const Text('Database').small().semiBold(), + const Gap(8), + TextField( + controller: _databaseController, + placeholder: const Text('postgres'), + ), + const Gap(16), + // Username + const Text('Username').small().semiBold(), + const Gap(8), + TextField( + controller: _usernameController, + placeholder: const Text('postgres'), + ), + const Gap(16), + // Password + const Text('Password').small().semiBold(), + const Gap(8), + material.Stack( + children: [ + TextField( + controller: _passwordController, + placeholder: const Text('Password'), + obscureText: !_showPassword, + ), + material.Positioned( + right: 8, + top: 0, + bottom: 0, + child: material.Center( + child: material.IconButton( + icon: material.Icon( + _showPassword + ? material.Icons.visibility_off + : material.Icons.visibility, + size: 20, + ), + onPressed: () => + setState(() => _showPassword = !_showPassword), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(), + ), + ), + ), + ], + ), + const Gap(16), + // SSL toggle + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? false), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + ], + ), + ), + ), + const material.Divider(height: 1), + // Test result banner + if (_testResult != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 8, 16, 8), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: _dismissResult, + borderRadius: material.BorderRadius.circular(8), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: material.BoxDecoration( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.12) + : theme.destructive.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.35) + : theme.destructive.withValues(alpha: 0.35), + width: 1, + ), + ), + child: material.Row( + children: [ + material.Icon( + _testResult == 'success' + ? material.Icons.check_circle_outline + : material.Icons.info_outline_rounded, + size: 18, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, + ), + const Gap(10), + material.Expanded( + child: Text( + _testResult == 'success' + ? 'Connection successful!' + : _testResult!.startsWith('error:') + ? _testResult!.substring(7) + : 'Connection failed', + style: material.TextStyle( + fontSize: 13, + color: theme.foreground, + ), + ).small(), + ), + material.IconButton( + icon: material.Icon( + material.Icons.close, + size: 18, + color: theme.mutedForeground, + ), + onPressed: _dismissResult, + style: material.IconButton.styleFrom( + minimumSize: const material.Size(28, 28), + padding: material.EdgeInsets.zero, + ), + ), + ], + ), + ), + ), + ), + ), + // Footer + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: material.Row( + children: [ + OutlineButton( + onPressed: + _formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: _formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: + _formValid ? theme.primary : theme.mutedForeground, + ), + ), + ), + const material.Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: _formValid ? _save : null, + child: const Text('Save'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} From 8d22b0b0f417ada9315087300b6fe3a10cf85dd7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:06:29 +0300 Subject: [PATCH 03/12] feat(postgres): wire connection form and add browser tree in connections panel Made-with: Cursor --- .../connections/connections_panel.dart | 845 +++++++++++++++++- 1 file changed, 796 insertions(+), 49 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index a25f025b..9b8549f9 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material show Padding, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, Column, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator; import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; @@ -8,6 +9,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart'; import 'package:querya_desktop/features/mongodb/mongodb_connection_form.dart'; +import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:querya_desktop/features/redis/redis_connection_form.dart'; import 'new_connection_dialog.dart'; import 'new_folder_dialog.dart'; @@ -76,8 +78,7 @@ class _ConnectionsPanelState extends State { } static bool _isStubConnection(ConnectionRow c) { - return (c.type == 'postgresql' && c.name == 'PostgreSQL connection') || - (c.type == 'mysql' && c.name == 'MySQL connection'); + return c.type == 'mysql' && c.name == 'MySQL connection'; } Future _createFolder(BuildContext menuContext) async { @@ -90,12 +91,13 @@ class _ConnectionsPanelState extends State { Future _createConnection(ConnectionType type, {int? folderId}) async { ConnectionRow? row; - if (type == ConnectionType.mongodb) { + if (type == ConnectionType.postgresql) { + row = await showPostgresConnectionForm(context, folderId: folderId); + } else if (type == ConnectionType.mongodb) { row = await showMongoConnectionForm(context, folderId: folderId); } else if (type == ConnectionType.redis) { row = await showRedisConnectionForm(context, folderId: folderId); } else { - // PostgreSQL / MySQL: no connection form yet — do not create a stub row = null; } @@ -132,6 +134,43 @@ class _ConnectionsPanelState extends State { }; } + Widget _buildConnectionTile(ConnectionRow conn) { + if (conn.type == 'postgresql') { + return _PostgresConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + ); + } else if (conn.type == 'redis') { + return _RedisConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), + ); + } else if (conn.type == 'mongodb') { + return _MongoConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), + ); + } + return _ConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -202,34 +241,11 @@ class _ConnectionsPanelState extends State { onConnectionTap: widget.onConnectionSelected, onRedisDatabaseTap: widget.onRedisDatabaseSelected, onMongoDBDatabaseTap: widget.onMongoDBDatabaseSelected, + buildConnectionTile: _buildConnectionTile, ), // Root connections (no folder) for (final conn in rootConnections) - conn.type == 'redis' - ? _RedisConnectionTile( - connection: conn, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), - onRemove: () => _removeConnection(conn.id!), - onTap: () => widget.onConnectionSelected?.call(conn), - onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), - ) - : conn.type == 'mongodb' - ? _MongoConnectionTile( - connection: conn, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), - onRemove: () => _removeConnection(conn.id!), - onTap: () => widget.onConnectionSelected?.call(conn), - onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), - ) - : _ConnectionTile( - connection: conn, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), - onRemove: () => _removeConnection(conn.id!), - onTap: () => widget.onConnectionSelected?.call(conn), - ), + _buildConnectionTile(conn), // Empty state if (_connections.isEmpty && _folders.isEmpty) const material.Padding( @@ -421,6 +437,7 @@ class _FolderTile extends StatelessWidget { this.onConnectionTap, this.onRedisDatabaseTap, this.onMongoDBDatabaseTap, + this.buildConnectionTile, }); final String name; @@ -434,6 +451,7 @@ class _FolderTile extends StatelessWidget { final void Function(ConnectionRow connection)? onConnectionTap; final void Function(ConnectionRow connection, int database)? onRedisDatabaseTap; final void Function(ConnectionRow connection, String database)? onMongoDBDatabaseTap; + final Widget Function(ConnectionRow conn)? buildConnectionTile; @override Widget build(BuildContext context) { @@ -499,31 +517,15 @@ class _FolderTile extends StatelessWidget { for (final conn in connections) material.Padding( padding: const material.EdgeInsets.only(left: 24), - child: conn.type == 'redis' - ? _RedisConnectionTile( + child: buildConnectionTile != null + ? buildConnectionTile!(conn) + : _ConnectionTile( connection: conn, icon: iconForType(conn.type), iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), onRemove: () => onRemoveConnection(conn.id!), onTap: () => onConnectionTap?.call(conn), - onDatabaseTap: (db) => onRedisDatabaseTap?.call(conn, db), - ) - : conn.type == 'mongodb' - ? _MongoConnectionTile( - connection: conn, - icon: iconForType(conn.type), - iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => onRemoveConnection(conn.id!), - onTap: () => onConnectionTap?.call(conn), - onDatabaseTap: (db) => onMongoDBDatabaseTap?.call(conn, db), - ) - : _ConnectionTile( - connection: conn, - icon: iconForType(conn.type), - iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => onRemoveConnection(conn.id!), - onTap: () => onConnectionTap?.call(conn), - ), + ), ), ], ), @@ -1180,3 +1182,748 @@ class _MongoDatabaseNode extends StatelessWidget { ); } } + +// ─── PostgreSQL connection tile with expandable database tree ──────────────── + +class _PostgresConnectionTile extends StatefulWidget { + const _PostgresConnectionTile({ + required this.connection, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + }); + + final ConnectionRow connection; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + + @override + State<_PostgresConnectionTile> createState() => + _PostgresConnectionTileState(); +} + +class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + List _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final c = widget.connection; + final conn = PostgresConnection( + id: -1, + name: 'sidebar_probe', + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: c.databaseName ?? 'postgres', + useSSL: c.useSSL, + ); + await conn.connect(); + final dbs = await conn.listDatabases(); + await conn.disconnect(); + + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + material.Expanded( + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: widget.onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Text( + 'Error', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), + ), + if (_databases.isNotEmpty) + _PgDatabasesNode( + connection: widget.connection, + databases: _databases, + ), + ], + ], + ), + ), + ); + } +} + +class _PgDatabasesNode extends StatefulWidget { + const _PgDatabasesNode({ + required this.connection, + required this.databases, + }); + + final ConnectionRow connection; + final List databases; + + @override + State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); +} + +class _PgDatabasesNodeState extends State<_PgDatabasesNode> { + bool _expanded = true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: + const material.EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + const Gap(4), + material.Icon(material.Icons.dns_rounded, + size: 14, + color: theme.colorScheme.primary.withValues(alpha: 0.7)), + const Gap(6), + Text('Databases (${widget.databases.length})').small(), + ], + ), + ), + ), + ), + if (_expanded) + for (final db in widget.databases) + _PgDatabaseNode( + connection: widget.connection, + databaseName: db, + ), + ], + ), + ); + } +} + +class _PgDatabaseNode extends StatefulWidget { + const _PgDatabaseNode({ + required this.connection, + required this.databaseName, + }); + + final ConnectionRow connection; + final String databaseName; + + @override + State<_PgDatabaseNode> createState() => _PgDatabaseNodeState(); +} + +class _PgDatabaseNodeState extends State<_PgDatabaseNode> { + bool _expanded = false; + bool _loading = false; + List _schemas = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _schemas.isEmpty && !_loading) { + _loadSchemas(); + } + } + + Future _loadSchemas() async { + if (!mounted) return; + setState(() => _loading = true); + try { + final c = widget.connection; + final conn = PostgresConnection( + id: -1, + name: 'probe', + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: widget.databaseName, + useSSL: c.useSSL, + ); + await conn.connect(); + final schemas = await conn.listSchemas(); + await conn.disconnect(); + if (!mounted) return; + setState(() { + _schemas = schemas; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: + const material.EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + const Gap(4), + material.Icon(material.Icons.storage_rounded, + size: 14, + color: theme.colorScheme.primary.withValues(alpha: 0.7)), + const Gap(6), + material.Expanded( + child: material.Text( + widget.databaseName, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + ), + ), + ], + ), + ), + ), + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 24, top: 2, bottom: 2), + child: material.Row( + children: [ + const material.SizedBox( + width: 10, + height: 10, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(6), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_schemas.isNotEmpty) + _PgSchemasNode( + connection: widget.connection, + databaseName: widget.databaseName, + schemas: _schemas, + ), + ], + ], + ), + ); + } +} + +class _PgSchemasNode extends StatefulWidget { + const _PgSchemasNode({ + required this.connection, + required this.databaseName, + required this.schemas, + }); + + final ConnectionRow connection; + final String databaseName; + final List schemas; + + @override + State<_PgSchemasNode> createState() => _PgSchemasNodeState(); +} + +class _PgSchemasNodeState extends State<_PgSchemasNode> { + bool _expanded = true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: + const material.EdgeInsets.symmetric(horizontal: 4, vertical: 3), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + const Gap(4), + material.Icon(material.Icons.account_tree_rounded, + size: 13, color: theme.colorScheme.mutedForeground), + const Gap(6), + Text('Schemas (${widget.schemas.length})').muted().xSmall(), + ], + ), + ), + ), + ), + if (_expanded) + for (final schema in widget.schemas) + _PgSchemaNode( + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: schema, + ), + ], + ), + ); + } +} + +class _PgSchemaNode extends StatefulWidget { + const _PgSchemaNode({ + required this.connection, + required this.databaseName, + required this.schemaName, + }); + + final ConnectionRow connection; + final String databaseName; + final String schemaName; + + @override + State<_PgSchemaNode> createState() => _PgSchemaNodeState(); +} + +class _PgSchemaNodeState extends State<_PgSchemaNode> { + bool _expanded = false; + bool _loading = false; + List _tables = []; + List _views = []; + List _functions = []; + List _sequences = []; + bool _loaded = false; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && !_loaded && !_loading) { + _loadObjects(); + } + } + + Future _loadObjects() async { + if (!mounted) return; + setState(() => _loading = true); + try { + final c = widget.connection; + final conn = PostgresConnection( + id: -1, + name: 'probe', + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: widget.databaseName, + useSSL: c.useSSL, + ); + await conn.connect(); + final tables = await conn.listTables(schema: widget.schemaName); + final views = await conn.listViews(schema: widget.schemaName); + final functions = await conn.listFunctions(schema: widget.schemaName); + final sequences = await conn.listSequences(schema: widget.schemaName); + await conn.disconnect(); + if (!mounted) return; + setState(() { + _tables = tables; + _views = views; + _functions = functions; + _sequences = sequences; + _loading = false; + _loaded = true; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: + const material.EdgeInsets.symmetric(horizontal: 4, vertical: 3), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + const Gap(4), + material.Icon(material.Icons.diamond_outlined, + size: 13, + color: theme.colorScheme.primary.withValues(alpha: 0.6)), + const Gap(6), + material.Expanded( + child: material.Text( + widget.schemaName, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, color: theme.colorScheme.foreground), + ), + ), + ], + ), + ), + ), + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 24, top: 2, bottom: 2), + child: material.Row( + children: [ + const material.SizedBox( + width: 10, + height: 10, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(6), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_loaded) ...[ + _PgObjectGroup( + label: 'Tables', + icon: material.Icons.table_chart_rounded, + items: _tables), + _PgObjectGroup( + label: 'Views', + icon: material.Icons.view_agenda_rounded, + items: _views), + _PgObjectGroup( + label: 'Functions', + icon: material.Icons.functions_rounded, + items: _functions), + _PgObjectGroup( + label: 'Sequences', + icon: material.Icons.format_list_numbered_rounded, + items: _sequences), + ], + ], + ], + ), + ); + } +} + +class _PgObjectGroup extends StatefulWidget { + const _PgObjectGroup({ + required this.label, + required this.icon, + required this.items, + }); + + final String label; + final material.IconData icon; + final List items; + + @override + State<_PgObjectGroup> createState() => _PgObjectGroupState(); +} + +class _PgObjectGroupState extends State<_PgObjectGroup> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: + const material.EdgeInsets.symmetric(horizontal: 4, vertical: 3), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 13, + color: theme.colorScheme.mutedForeground, + ), + ), + const Gap(4), + material.Icon(widget.icon, + size: 13, color: theme.colorScheme.mutedForeground), + const Gap(6), + Text('${widget.label} (${widget.items.length})') + .muted() + .xSmall(), + ], + ), + ), + ), + ), + if (_expanded) + for (final item in widget.items) + material.Padding( + padding: const material.EdgeInsets.only(left: 22), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 2), + child: material.Row( + children: [ + material.Icon(widget.icon, + size: 12, + color: theme.colorScheme.primary + .withValues(alpha: 0.5)), + const Gap(6), + material.Expanded( + child: material.Text( + item, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} From ebeb55b5adab09588b4714bf281517c216de4c11 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:06:36 +0300 Subject: [PATCH 04/12] feat(postgres): add PostgreSQL server statistics view Made-with: Cursor --- lib/features/main_screen/workspace_panel.dart | 15 + .../postgresql/postgres_stats_view.dart | 538 ++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 lib/features/postgresql/postgres_stats_view.dart diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index cfddbe6a..c45b28f1 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_stats_view.dart'; +import 'package:querya_desktop/features/postgresql/postgres_stats_view.dart'; import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'query_editor_tab.dart'; @@ -42,6 +43,20 @@ class _WorkspacePanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); + // If a PostgreSQL connection is selected → show stats + if (widget.activeConnection != null && + widget.activeConnection!.type == 'postgresql') { + return material.Container( + color: theme.colorScheme.background, + child: material.SizedBox.expand( + child: PostgresStatsView( + key: ValueKey('pg_stats_${widget.activeConnection!.id}'), + connectionRow: widget.activeConnection!, + ), + ), + ); + } + // If a MongoDB connection is selected if (widget.activeConnection != null && widget.activeConnection!.type == 'mongodb') { diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart new file mode 100644 index 00000000..c9bc798e --- /dev/null +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -0,0 +1,538 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +const _pollInterval = Duration(seconds: 5); +const _summaryChipHeight = 72.0; +const _gridCardHeight = 220.0; + +class PostgresStatsView extends material.StatefulWidget { + const PostgresStatsView({ + super.key, + required this.connectionRow, + }); + + final ConnectionRow connectionRow; + + @override + material.State createState() => _PostgresStatsViewState(); +} + +class _PostgresStatsViewState extends material.State { + PostgresConnection? _connection; + Map? _stats; + bool _loading = true; + String? _error; + Timer? _timer; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant PostgresStatsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _timer?.cancel(); + _disconnectCurrent(); + _load(); + } + } + + @override + void dispose() { + _timer?.cancel(); + _disconnectCurrent(); + super.dispose(); + } + + void _disconnectCurrent() { + final conn = _connection; + _connection = null; + conn?.disconnect(); + } + + Future _load() async { + _timer?.cancel(); + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _stats = null; + }); + try { + final c = widget.connectionRow; + final conn = PostgresConnection( + id: c.id ?? 0, + name: c.name, + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: c.databaseName ?? 'postgres', + useSSL: c.useSSL, + ); + await conn.connect(); + if (!mounted) { + conn.disconnect(); + return; + } + _connection = conn; + await _fetch(); + if (mounted) _startTimer(); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _fetch() async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final stats = await c.serverStats(); + if (!mounted) return; + setState(() { + _stats = stats; + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(_pollInterval, (_) async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final stats = await c.serverStats(); + if (!mounted) return; + setState(() => _stats = stats); + } catch (_) {} + }); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final width = MediaQuery.sizeOf(context).width; + + if (_loading) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Connecting...').muted().small(), + ], + ), + ); + } + + if (_error != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Connection Error').large().semiBold(), + const Gap(8), + material.SelectableText(_error!, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + final stats = _stats; + if (stats == null) return material.Container(color: cs.background); + + return material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: width, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), + const Gap(24), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _connectionsCard(context, stats)), + const Gap(16), + material.Expanded( + child: _serverSettingsCard(context, stats)), + ], + ), + const Gap(16), + _databasesCard(context, stats), + ], + ), + ), + ), + ), + ); + } + + material.Widget _header(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), + ), + child: material.SizedBox( + width: 28, + height: 28, + child: material.Image.asset( + 'assets/images/postgresql_icon.png', + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + material.Icons.storage_rounded, + size: 28, + color: cs.primary), + ), + ), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 5432}') + .muted() + .small(), + ], + ), + ), + OutlineButton( + onPressed: _load, + leading: + const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh'), + ), + ], + ); + } + + material.Widget _summaryChips( + material.BuildContext context, Map stats) { + final cs = shadcn.Theme.of(context).colorScheme; + final versionFull = stats['version'] as String? ?? '—'; + final versionShort = _extractPgVersion(versionFull); + final settings = stats['settings'] as Map? ?? {}; + final maxConn = settings['max_connections'] ?? '—'; + final totalConn = stats['connections_total'] ?? 0; + final uptimeSec = stats['uptime_seconds'] as int?; + final uptimeStr = uptimeSec != null + ? '${uptimeSec ~/ 86400}d ${(uptimeSec % 86400) ~/ 3600}h' + : '—'; + final dbSize = stats['current_db_size']; + final dbSizeStr = dbSize != null ? _formatBytes(dbSize as int) : '—'; + + material.Widget chip( + String label, String value, material.IconData icon) { + return material.Expanded( + child: material.SizedBox( + height: _summaryChipHeight, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.5)), + ), + child: material.Row( + children: [ + material.Icon(icon, size: 20, color: cs.primary), + const Gap(12), + material.Expanded( + child: material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).muted().xSmall(), + const Gap(2), + Text(value).semiBold().small(), + ], + ), + ), + ], + ), + ), + ), + ); + } + + return material.Row( + children: [ + chip('Version', versionShort, material.Icons.tag_rounded), + const Gap(12), + chip('Uptime', uptimeStr, material.Icons.schedule_rounded), + const Gap(12), + chip('Connections', '$totalConn / $maxConn', + material.Icons.people_outline_rounded), + const Gap(12), + chip('DB Size', dbSizeStr, material.Icons.storage_rounded), + ], + ); + } + + material.Widget _card(material.BuildContext context, String title, + material.Widget body, + {double? height}) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Container( + width: double.infinity, + height: height, + padding: const material.EdgeInsets.all(20), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(12), + border: + material.Border.all(color: cs.border.withValues(alpha: 0.4)), + ), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text(title).semiBold(), + const Gap(12), + body, + ], + ), + ); + } + + material.Widget _connectionsCard( + material.BuildContext context, Map stats) { + final total = stats['connections_total'] ?? 0; + final active = stats['connections_active'] ?? 0; + final idle = stats['connections_idle'] ?? 0; + final settings = stats['settings'] as Map? ?? {}; + final maxConn = settings['max_connections'] ?? '—'; + return _card( + context, + 'Connections', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Total', '$total'), + _row(context, 'Active', '$active'), + _row(context, 'Idle', '$idle'), + _row(context, 'Max connections', maxConn), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _serverSettingsCard( + material.BuildContext context, Map stats) { + final settings = stats['settings'] as Map? ?? {}; + return _card( + context, + 'Server Settings', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Shared buffers', settings['shared_buffers'] ?? '—'), + _row(context, 'Work mem', settings['work_mem'] ?? '—'), + _row(context, 'Effective cache', + settings['effective_cache_size'] ?? '—'), + _row(context, 'Encoding', settings['server_encoding'] ?? '—'), + _row(context, 'Timezone', settings['timezone'] ?? '—'), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _databasesCard( + material.BuildContext context, Map stats) { + final databases = + stats['databases'] as List>? ?? []; + if (databases.isEmpty) return const material.SizedBox.shrink(); + + final cs = shadcn.Theme.of(context).colorScheme; + return _card( + context, + 'Databases', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Row( + children: [ + material.SizedBox( + width: 140, child: const Text('Name').muted().xSmall()), + material.SizedBox( + width: 80, child: const Text('Size').muted().xSmall()), + material.SizedBox( + width: 70, + child: const Text('Backends').muted().xSmall()), + material.SizedBox( + width: 80, + child: const Text('Commits').muted().xSmall()), + material.SizedBox( + width: 80, + child: const Text('Rollbacks').muted().xSmall()), + material.Expanded( + child: const Text('Hit ratio').muted().xSmall()), + ], + ), + ), + material.Divider( + height: 1, color: cs.border.withValues(alpha: 0.3)), + for (final db in databases) + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 5), + child: material.Row( + children: [ + material.SizedBox( + width: 140, + child: material.Text( + '${db['datname']}', + style: material.TextStyle( + fontSize: 13, color: cs.foreground), + overflow: material.TextOverflow.ellipsis, + ), + ), + material.SizedBox( + width: 80, + child: Text(_formatBytes( + (db['size'] as int?) ?? 0)) + .muted() + .xSmall(), + ), + material.SizedBox( + width: 70, + child: Text('${db['numbackends'] ?? 0}') + .muted() + .xSmall(), + ), + material.SizedBox( + width: 80, + child: Text('${db['xact_commit'] ?? 0}') + .muted() + .xSmall(), + ), + material.SizedBox( + width: 80, + child: Text('${db['xact_rollback'] ?? 0}') + .muted() + .xSmall(), + ), + material.Expanded( + child: Text(_hitRatio(db)).muted().xSmall(), + ), + ], + ), + ), + ], + ), + ); + } + + material.Widget _row( + material.BuildContext context, String key, String value) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox(width: 160, child: Text(key).muted().small()), + material.Expanded( + child: material.SelectableText(value, + style: material.TextStyle( + fontSize: 13, color: cs.foreground))), + ], + ), + ); + } + + String _extractPgVersion(String full) { + final match = RegExp(r'PostgreSQL\s+([\d.]+)').firstMatch(full); + return match?.group(1) ?? full; + } + + String _hitRatio(Map db) { + final hits = (db['blks_hit'] as int?) ?? 0; + final reads = (db['blks_read'] as int?) ?? 0; + final total = hits + reads; + if (total == 0) return '—'; + return '${(hits / total * 100).toStringAsFixed(1)}%'; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} From e610989f262da48f5986ecbf488e5bd1993fda88 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:13:43 +0300 Subject: [PATCH 05/12] test(postgres): add PostgresConnection unit tests Made-with: Cursor --- .../database/postgres_connection_test.dart | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 test/core/database/postgres_connection_test.dart diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart new file mode 100644 index 00000000..20e312c1 --- /dev/null +++ b/test/core/database/postgres_connection_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; + +void main() { + group('PostgresConnection initial state', () { + test('isConnected is false before connect()', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.isConnected, false); + }); + + test('default port is 5432', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.port, 5432); + }); + + test('default useSSL is false', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.useSSL, false); + }); + + test('custom port and useSSL are stored', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + port: 5433, + useSSL: true, + ); + expect(conn.port, 5433); + expect(conn.useSSL, true); + }); + + test('stores all constructor parameters', () { + final conn = PostgresConnection( + id: 42, + name: 'My PostgreSQL', + host: 'pg.example.com', + port: 5433, + username: 'admin', + password: 's3cret', + database: 'appdb', + useSSL: true, + ); + expect(conn.id, 42); + expect(conn.name, 'My PostgreSQL'); + expect(conn.host, 'pg.example.com'); + expect(conn.port, 5433); + expect(conn.username, 'admin'); + expect(conn.password, 's3cret'); + expect(conn.database, 'appdb'); + expect(conn.useSSL, true); + }); + + test('username, password, database default to null', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.username, isNull); + expect(conn.password, isNull); + expect(conn.database, isNull); + }); + }); + + group('PostgresConnection.disconnect', () { + test('disconnect on a never-connected instance does not throw', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + await conn.disconnect(); + expect(conn.isConnected, false); + }); + + test('double disconnect does not throw', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + await conn.disconnect(); + await conn.disconnect(); + expect(conn.isConnected, false); + }); + }); + + group('PostgresConnection when not connected', () { + late PostgresConnection conn; + + setUp(() { + conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + }); + + test('execute throws StateError', () { + expect( + () => conn.execute('SELECT 1'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listDatabases throws StateError', () { + expect( + () => conn.listDatabases(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listSchemas throws StateError', () { + expect( + () => conn.listSchemas(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listTables throws StateError', () { + expect( + () => conn.listTables(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listViews throws StateError', () { + expect( + () => conn.listViews(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listFunctions throws StateError', () { + expect( + () => conn.listFunctions(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('listSequences throws StateError', () { + expect( + () => conn.listSequences(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('serverVersion throws StateError', () { + expect( + () => conn.serverVersion(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('serverStats throws StateError', () { + expect( + () => conn.serverStats(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + }); + + group('PostgresConnection.connectToDatabase', () { + test('returns new PostgresConnection with same params and given database', () async { + final conn = PostgresConnection( + id: 10, + name: 'Server', + host: 'pg.local', + port: 5433, + username: 'u', + password: 'p', + database: 'postgres', + useSSL: true, + ); + final newConn = await conn.connectToDatabase('otherdb'); + + expect(newConn.id, conn.id); + expect(newConn.name, conn.name); + expect(newConn.host, conn.host); + expect(newConn.port, conn.port); + expect(newConn.username, conn.username); + expect(newConn.password, conn.password); + expect(newConn.useSSL, conn.useSSL); + expect(newConn.database, 'otherdb'); + expect(newConn.isConnected, false); + }); + + test('original connection is unchanged', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + database: 'mydb', + ); + await conn.connectToDatabase('other'); + expect(conn.database, 'mydb'); + }); + }); + + group('PostgresConnectionException', () { + test('stores message and toString returns it', () { + final ex = PostgresConnectionException('connection refused'); + expect(ex.message, 'connection refused'); + expect(ex.toString(), 'connection refused'); + }); + }); +} From fd7d0d060fae07caa66916e7dfea8f3d345913e5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:13:44 +0300 Subject: [PATCH 06/12] test(postgres): add connection form widget tests; fix footer overflow with Wrap Made-with: Cursor --- .../postgresql_connection_form.dart | 29 +++--- .../postgresql_connection_form_test.dart | 91 +++++++++++++++++++ 2 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 test/features/postgresql/postgresql_connection_form_test.dart diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 092be759..1903754d 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -397,11 +397,14 @@ class _PostgresConnectionFormContentState ), ), ), - // Footer + // Footer — Wrap avoids overflow on narrow viewports (e.g. in tests) material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 24, vertical: 16), - child: material.Row( + child: material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, children: [ OutlineButton( onPressed: @@ -431,15 +434,19 @@ class _PostgresConnectionFormContentState ), ), ), - const material.Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Save'), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: _formValid ? _save : null, + child: const Text('Save'), + ), + ], ), ], ), diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart new file mode 100644 index 00000000..2c8819ae --- /dev/null +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('showPostgresConnectionForm', () { + testWidgets('dialog shows PostgreSQL Connection title and main actions', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showPostgresConnectionForm(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('PostgreSQL Connection'), findsOneWidget); + expect(find.text('Connection Name'), findsOneWidget); + expect(find.text('Host'), findsOneWidget); + expect(find.text('Port'), findsOneWidget); + expect(find.text('Database'), findsOneWidget); + expect(find.text('Username'), findsOneWidget); + expect(find.text('Password'), findsAtLeast(1)); + expect(find.text('Test Connection'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('dialog shows Use SSL/TLS option', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showPostgresConnectionForm(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Use SSL/TLS'), findsOneWidget); + }); + + testWidgets('Cancel closes dialog and returns null', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + dynamic result; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showPostgresConnectionForm(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isNull); + }); + }); +} From b9e17d1bfec2a30de25662684f55230975043b72 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:33:21 +0300 Subject: [PATCH 07/12] feat(postgres): add table/view data query and viewing - Add onPostgresTableSelected callback in connections panel; Tables and Views in PG tree are clickable and open data view in workspace - Add PostgresTableView: SELECT * with limit, virtualized ListView.builder, horizontal/vertical scroll with proper ScrollControllers, zebra rows, NULL styling - Wire selected table state in MainScreen and WorkspacePanel - Fix UI freeze by converting result to strings in compute() and using ListView.builder - Fix Scrollbar ScrollController error by attaching explicit controllers - Toolbar with row count chip and Refresh button (small size, no clipping) Made-with: Cursor --- .../connections/connections_panel.dart | 104 +++- lib/features/main_screen/main_screen.dart | 24 +- lib/features/main_screen/workspace_panel.dart | 28 +- .../postgresql/postgres_table_view.dart | 460 ++++++++++++++++++ 4 files changed, 587 insertions(+), 29 deletions(-) create mode 100644 lib/features/postgresql/postgres_table_view.dart diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 9b8549f9..bd629e92 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -21,6 +21,7 @@ class ConnectionsPanel extends StatefulWidget { this.onConnectionSelected, this.onRedisDatabaseSelected, this.onMongoDBDatabaseSelected, + this.onPostgresTableSelected, }); /// Called when the user taps a connection tile. @@ -34,6 +35,10 @@ class ConnectionsPanel extends StatefulWidget { final void Function(ConnectionRow connection, String database)? onMongoDBDatabaseSelected; + /// Called when the user taps a PostgreSQL table or view in the tree. + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; + @override State createState() => _ConnectionsPanelState(); } @@ -142,6 +147,7 @@ class _ConnectionsPanelState extends State { iconAsset: _iconAssetForType(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), + onPostgresTableSelected: widget.onPostgresTableSelected, ); } else if (conn.type == 'redis') { return _RedisConnectionTile( @@ -1192,6 +1198,7 @@ class _PostgresConnectionTile extends StatefulWidget { this.iconAsset, required this.onRemove, this.onTap, + this.onPostgresTableSelected, }); final ConnectionRow connection; @@ -1199,6 +1206,8 @@ class _PostgresConnectionTile extends StatefulWidget { final String? iconAsset; final VoidCallback onRemove; final VoidCallback? onTap; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; @override State<_PostgresConnectionTile> createState() => @@ -1399,6 +1408,7 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { _PgDatabasesNode( connection: widget.connection, databases: _databases, + onPostgresTableSelected: widget.onPostgresTableSelected, ), ], ], @@ -1412,10 +1422,13 @@ class _PgDatabasesNode extends StatefulWidget { const _PgDatabasesNode({ required this.connection, required this.databases, + this.onPostgresTableSelected, }); final ConnectionRow connection; final List databases; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; @override State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); @@ -1468,6 +1481,7 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { _PgDatabaseNode( connection: widget.connection, databaseName: db, + onPostgresTableSelected: widget.onPostgresTableSelected, ), ], ), @@ -1479,10 +1493,13 @@ class _PgDatabaseNode extends StatefulWidget { const _PgDatabaseNode({ required this.connection, required this.databaseName, + this.onPostgresTableSelected, }); final ConnectionRow connection; final String databaseName; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; @override State<_PgDatabaseNode> createState() => _PgDatabaseNodeState(); @@ -1601,6 +1618,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { connection: widget.connection, databaseName: widget.databaseName, schemas: _schemas, + onPostgresTableSelected: widget.onPostgresTableSelected, ), ], ], @@ -1614,11 +1632,14 @@ class _PgSchemasNode extends StatefulWidget { required this.connection, required this.databaseName, required this.schemas, + this.onPostgresTableSelected, }); final ConnectionRow connection; final String databaseName; final List schemas; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; @override State<_PgSchemasNode> createState() => _PgSchemasNodeState(); @@ -1671,6 +1692,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { connection: widget.connection, databaseName: widget.databaseName, schemaName: schema, + onPostgresTableSelected: widget.onPostgresTableSelected, ), ], ), @@ -1683,11 +1705,14 @@ class _PgSchemaNode extends StatefulWidget { required this.connection, required this.databaseName, required this.schemaName, + this.onPostgresTableSelected, }); final ConnectionRow connection; final String databaseName; final String schemaName; + final void Function(ConnectionRow connection, String database, String schema, + String tableOrViewName, bool isView)? onPostgresTableSelected; @override State<_PgSchemaNode> createState() => _PgSchemaNodeState(); @@ -1812,13 +1837,33 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { ), if (_loaded) ...[ _PgObjectGroup( - label: 'Tables', - icon: material.Icons.table_chart_rounded, - items: _tables), + label: 'Tables', + icon: material.Icons.table_chart_rounded, + items: _tables, + onItemTap: widget.onPostgresTableSelected != null + ? (name) => widget.onPostgresTableSelected!( + widget.connection, + widget.databaseName, + widget.schemaName, + name, + false, + ) + : null, + ), _PgObjectGroup( - label: 'Views', - icon: material.Icons.view_agenda_rounded, - items: _views), + label: 'Views', + icon: material.Icons.view_agenda_rounded, + items: _views, + onItemTap: widget.onPostgresTableSelected != null + ? (name) => widget.onPostgresTableSelected!( + widget.connection, + widget.databaseName, + widget.schemaName, + name, + true, + ) + : null, + ), _PgObjectGroup( label: 'Functions', icon: material.Icons.functions_rounded, @@ -1840,11 +1885,13 @@ class _PgObjectGroup extends StatefulWidget { required this.label, required this.icon, required this.items, + this.onItemTap, }); final String label; final material.IconData icon; final List items; + final void Function(String itemName)? onItemTap; @override State<_PgObjectGroup> createState() => _PgObjectGroupState(); @@ -1900,25 +1947,36 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 4, vertical: 2), - child: material.Row( - children: [ - material.Icon(widget.icon, - size: 12, - color: theme.colorScheme.primary - .withValues(alpha: 0.5)), - const Gap(6), - material.Expanded( - child: material.Text( - item, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.foreground, + child: material.MouseRegion( + cursor: widget.onItemTap != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.InkWell( + onTap: widget.onItemTap != null + ? () => widget.onItemTap!(item) + : null, + borderRadius: material.BorderRadius.circular(4), + child: material.Row( + children: [ + material.Icon(widget.icon, + size: 12, + color: theme.colorScheme.primary + .withValues(alpha: 0.5)), + const Gap(6), + material.Expanded( + child: material.Text( + item, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + ), ), - ), + ], ), - ], + ), ), ), ), diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 661de173..6dbd2e00 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -30,11 +30,31 @@ class _MainScreenState extends State { /// Currently selected MongoDB database (null = show stats). String? _activeMongoDB; + /// When set, user selected a PostgreSQL table/view in the tree. + ({String database, String schema, String tableName, bool isView})? + _activePostgresTable; + void _onConnectionSelected(ConnectionRow connection) { setState(() { _activeConnection = connection; - _activeRedisDb = null; // Reset: connection click → stats + _activeRedisDb = null; + _activeMongoDB = null; + _activePostgresTable = null; + }); + } + + void _onPostgresTableSelected(ConnectionRow connection, String database, + String schema, String tableOrViewName, bool isView) { + setState(() { + _activeConnection = connection; + _activeRedisDb = null; _activeMongoDB = null; + _activePostgresTable = ( + database: database, + schema: schema, + tableName: tableOrViewName, + isView: isView, + ); }); } @@ -77,6 +97,7 @@ class _MainScreenState extends State { onConnectionSelected: _onConnectionSelected, onRedisDatabaseSelected: _onRedisDatabaseSelected, onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, + onPostgresTableSelected: _onPostgresTableSelected, ), ), _VerticalResizeHandle( @@ -92,6 +113,7 @@ class _MainScreenState extends State { activeConnection: _activeConnection, selectedRedisDb: _activeRedisDb, selectedMongoDb: _activeMongoDB, + selectedPostgresTable: _activePostgresTable, ), ), ], diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index c45b28f1..fa822136 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -5,6 +5,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_stats_view.dart'; import 'package:querya_desktop/features/postgresql/postgres_stats_view.dart'; +import 'package:querya_desktop/features/postgresql/postgres_table_view.dart'; import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'query_editor_tab.dart'; @@ -17,6 +18,7 @@ class WorkspacePanel extends StatefulWidget { this.activeConnection, this.selectedRedisDb, this.selectedMongoDb, + this.selectedPostgresTable, }); /// Currently selected connection from the sidebar. @@ -30,6 +32,11 @@ class WorkspacePanel extends StatefulWidget { /// null = show stats, non-null = show data explorer for that db. final String? selectedMongoDb; + /// When set, the user selected a PostgreSQL table/view in the sidebar tree. + /// null = show stats, non-null = show table data view. + final ({String database, String schema, String tableName, bool isView})? + selectedPostgresTable; + @override State createState() => _WorkspacePanelState(); } @@ -43,16 +50,27 @@ class _WorkspacePanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); - // If a PostgreSQL connection is selected → show stats + // If a PostgreSQL connection is selected → stats or table data if (widget.activeConnection != null && widget.activeConnection!.type == 'postgresql') { + final pgTable = widget.selectedPostgresTable; return material.Container( color: theme.colorScheme.background, child: material.SizedBox.expand( - child: PostgresStatsView( - key: ValueKey('pg_stats_${widget.activeConnection!.id}'), - connectionRow: widget.activeConnection!, - ), + child: pgTable != null + ? PostgresTableView( + key: ValueKey( + 'pg_table_${widget.activeConnection!.id}_${pgTable.schema}_${pgTable.tableName}'), + connectionRow: widget.activeConnection!, + database: pgTable.database, + schema: pgTable.schema, + tableName: pgTable.tableName, + isView: pgTable.isView, + ) + : PostgresStatsView( + key: ValueKey('pg_stats_${widget.activeConnection!.id}'), + connectionRow: widget.activeConnection!, + ), ), ); } diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart new file mode 100644 index 00000000..2b8278b9 --- /dev/null +++ b/lib/features/postgresql/postgres_table_view.dart @@ -0,0 +1,460 @@ +import 'package:flutter/foundation.dart' show compute; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _defaultLimit = 200; + +class PostgresTableView extends material.StatefulWidget { + const PostgresTableView({ + super.key, + required this.connectionRow, + required this.database, + required this.schema, + required this.tableName, + this.isView = false, + this.limit = _defaultLimit, + }); + + final ConnectionRow connectionRow; + final String database; + final String schema; + final String tableName; + final bool isView; + final int limit; + + @override + material.State createState() => + _PostgresTableViewState(); +} + +class _PostgresTableViewState extends material.State { + PostgresConnection? _connection; + bool _loading = true; + String? _error; + + List _columnNames = []; + List> _rows = []; + int _totalRows = 0; + + final _verticalController = material.ScrollController(); + final _horizontalController = material.ScrollController(); + + @override + void initState() { + super.initState(); + _connectAndLoad(); + } + + @override + void didUpdateWidget(covariant PostgresTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database || + oldWidget.schema != widget.schema || + oldWidget.tableName != widget.tableName) { + _disconnectCurrent(); + _connectAndLoad(); + } + } + + @override + void dispose() { + _verticalController.dispose(); + _horizontalController.dispose(); + _disconnectCurrent(); + super.dispose(); + } + + void _disconnectCurrent() { + final conn = _connection; + _connection = null; + conn?.disconnect(); + } + + static String _quoteIdentifier(String name) { + return '"${name.replaceAll('"', '""')}"'; + } + + Future _connectAndLoad() async { + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _columnNames = []; + _rows = []; + _totalRows = 0; + }); + try { + final c = widget.connectionRow; + final conn = PostgresConnection( + id: c.id ?? 0, + name: c.name, + host: c.host ?? 'localhost', + port: c.port ?? 5432, + username: c.username, + password: c.password, + database: widget.database, + useSSL: c.useSSL, + ); + await conn.connect(); + if (!mounted) { + conn.disconnect(); + return; + } + _connection = conn; + await _fetch(); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _fetch() async { + final conn = _connection; + if (conn == null || !conn.isConnected) return; + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + final schemaQ = _quoteIdentifier(widget.schema); + final tableQ = _quoteIdentifier(widget.tableName); + final sql = 'SELECT * FROM $schemaQ.$tableQ LIMIT ${widget.limit}'; + try { + final result = await conn.execute(sql); + if (!mounted) return; + + final colNames = List.generate( + result.schema.columns.length, + (i) => result.schema.columns[i].columnName ?? 'col_$i', + ); + + // Convert to plain strings in isolate to avoid blocking UI + final rawRows = result.map((row) { + return List.generate(row.length, (i) => row[i]); + }).toList(); + + final stringRows = await compute(_convertRows, rawRows); + + if (!mounted) return; + setState(() { + _columnNames = colNames; + _rows = stringRows; + _totalRows = stringRows.length; + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + static List> _convertRows(List> rawRows) { + return rawRows.map((row) { + return row.map((value) { + if (value == null) return 'NULL'; + if (value is DateTime) return value.toIso8601String(); + return value.toString(); + }).toList(); + }).toList(); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_loading) { + return material.Container( + color: cs.background, + child: material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 28, + height: 28, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(12), + const Text('Loading data...').muted().small(), + ], + ), + ), + ); + } + + if (_error != null) { + return material.Container( + color: cs.background, + child: material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Query Error').large().semiBold(), + const Gap(8), + material.SelectableText(_error!, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _fetch, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + if (_columnNames.isEmpty) { + return material.Container(color: cs.background); + } + + const double rowHeight = 36; + const double headerHeight = 40; + final colCount = _columnNames.length; + + return material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Toolbar + material.Container( + padding: const material.EdgeInsets.fromLTRB(16, 10, 16, 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.5)), + ), + ), + child: material.Row( + children: [ + material.Icon( + widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: material.Text( + '${widget.schema}.${widget.tableName}' + '${widget.isView ? ' (view)' : ''}', + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + overflow: material.TextOverflow.ellipsis, + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + '$_totalRows rows', + style: material.TextStyle( + fontSize: 11, color: cs.mutedForeground), + ), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _fetch, + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14), + child: const Text('Refresh'), + ), + ], + ), + ), + // Table grid + material.Expanded( + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: _calcTableWidth(colCount, constraints.maxWidth), + child: material.Column( + children: [ + // Header + material.Container( + height: headerHeight, + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.5)), + ), + ), + child: material.Row( + children: [ + _rowNumberCell(cs, '#', isHeader: true), + for (var i = 0; i < colCount; i++) + _headerCell(cs, _columnNames[i]), + ], + ), + ), + // Data rows via ListView.builder for virtualization + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: _totalRows, + itemExtent: rowHeight, + itemBuilder: (context, rowIdx) { + final row = _rows[rowIdx]; + final isEven = rowIdx % 2 == 0; + return material.Container( + height: rowHeight, + decoration: material.BoxDecoration( + color: isEven + ? material.Colors.transparent + : cs.muted.withValues(alpha: 0.12), + border: material.Border( + bottom: material.BorderSide( + color: + cs.border.withValues(alpha: 0.15), + ), + ), + ), + child: material.Row( + children: [ + _rowNumberCell(cs, + '${rowIdx + 1}'), + for (var c = 0; + c < colCount; + c++) + _dataCell( + cs, + row.length > c + ? row[c] + : ''), + ], + ), + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + double _calcTableWidth(int colCount, double availableWidth) { + const double rowNumWidth = 52; + const double minColWidth = 150; + final calculated = rowNumWidth + colCount * minColWidth; + return calculated > availableWidth ? calculated : availableWidth; + } + + material.Widget _rowNumberCell(ColorScheme cs, String text, + {bool isHeader = false}) { + return material.Container( + width: 52, + padding: const material.EdgeInsets.symmetric(horizontal: 8), + alignment: material.Alignment.centerRight, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide(color: cs.border.withValues(alpha: 0.3)), + ), + ), + child: material.Text( + text, + style: material.TextStyle( + fontSize: 11, + fontWeight: + isHeader ? material.FontWeight.w600 : material.FontWeight.normal, + color: cs.mutedForeground.withValues(alpha: 0.7), + fontFamily: 'monospace', + ), + ), + ); + } + + material.Widget _headerCell(ColorScheme cs, String name) { + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + child: material.Text( + name, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ); + } + + material.Widget _dataCell(ColorScheme cs, String value) { + final isNull = value == 'NULL'; + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + child: material.Text( + value, + style: material.TextStyle( + fontSize: 12, + color: isNull + ? cs.mutedForeground.withValues(alpha: 0.5) + : cs.foreground, + fontStyle: + isNull ? material.FontStyle.italic : material.FontStyle.normal, + ), + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ); + } +} From b290d7431a2851e6ba4936adf9a7bc7cd7528eac Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:42:25 +0300 Subject: [PATCH 08/12] test: add postgres table utils tests and extend coverage - Add postgres_table_utils.dart: quotePostgresIdentifier, convertResultRowsToStrings - Use utils in PostgresTableView (for compute and SQL quoting) - Add postgres_table_utils_test: 10 unit tests for quoting and row conversion - PostgresConnection: test connectionString in constructor - ConnectionRow: test fromMap for postgresql with databaseName Made-with: Cursor --- .../postgresql/postgres_table_utils.dart | 18 ++++ .../postgresql/postgres_table_view.dart | 21 +---- .../database/postgres_connection_test.dart | 19 +++++ test/core/storage/connection_row_test.dart | 17 ++++ .../postgresql/postgres_table_utils_test.dart | 83 +++++++++++++++++++ 5 files changed, 141 insertions(+), 17 deletions(-) create mode 100644 lib/features/postgresql/postgres_table_utils.dart create mode 100644 test/features/postgresql/postgres_table_utils_test.dart diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart new file mode 100644 index 00000000..cb4e96bb --- /dev/null +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -0,0 +1,18 @@ +/// Utilities for PostgreSQL table data view (quoting, row conversion). + +/// Quotes a PostgreSQL identifier (e.g. schema or table name). +/// Doubles any internal double-quote. +String quotePostgresIdentifier(String name) { + return '"${name.replaceAll('"', '""')}"'; +} + +/// Converts raw result rows (list of dynamic values per row) to list of string rows. +List> convertResultRowsToStrings(List> rawRows) { + return rawRows.map((row) { + return row.map((value) { + if (value == null) return 'NULL'; + if (value is DateTime) return value.toIso8601String(); + return value.toString(); + }).toList(); + }).toList(); +} diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 2b8278b9..24211c1c 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart' show compute; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; const _defaultLimit = 200; @@ -73,10 +74,6 @@ class _PostgresTableViewState extends material.State { conn?.disconnect(); } - static String _quoteIdentifier(String name) { - return '"${name.replaceAll('"', '""')}"'; - } - Future _connectAndLoad() async { _disconnectCurrent(); if (!mounted) return; @@ -124,8 +121,8 @@ class _PostgresTableViewState extends material.State { _loading = true; _error = null; }); - final schemaQ = _quoteIdentifier(widget.schema); - final tableQ = _quoteIdentifier(widget.tableName); + final schemaQ = quotePostgresIdentifier(widget.schema); + final tableQ = quotePostgresIdentifier(widget.tableName); final sql = 'SELECT * FROM $schemaQ.$tableQ LIMIT ${widget.limit}'; try { final result = await conn.execute(sql); @@ -141,7 +138,7 @@ class _PostgresTableViewState extends material.State { return List.generate(row.length, (i) => row[i]); }).toList(); - final stringRows = await compute(_convertRows, rawRows); + final stringRows = await compute(convertResultRowsToStrings, rawRows); if (!mounted) return; setState(() { @@ -160,16 +157,6 @@ class _PostgresTableViewState extends material.State { } } - static List> _convertRows(List> rawRows) { - return rawRows.map((row) { - return row.map((value) { - if (value == null) return 'NULL'; - if (value is DateTime) return value.toIso8601String(); - return value.toString(); - }).toList(); - }).toList(); - } - @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index 20e312c1..2be20f1f 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -73,6 +73,25 @@ void main() { expect(conn.password, isNull); expect(conn.database, isNull); }); + + test('connectionString is stored when provided', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'postgresql://user:pass@host/db', + ); + expect(conn.connectionString, 'postgresql://user:pass@host/db'); + }); + + test('connectionString defaults to null', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + expect(conn.connectionString, isNull); + }); }); group('PostgresConnection.disconnect', () { diff --git a/test/core/storage/connection_row_test.dart b/test/core/storage/connection_row_test.dart index afe40230..c423a4d8 100644 --- a/test/core/storage/connection_row_test.dart +++ b/test/core/storage/connection_row_test.dart @@ -169,5 +169,22 @@ void main() { final map = row.toMap(); expect(map.containsKey('id'), false); }); + + test('fromMap restores postgresql connection with databaseName', () { + final map = { + 'id': 1, + 'type': 'postgresql', + 'name': 'PG', + 'host': 'localhost', + 'port': 5432, + 'database_name': 'mydb', + 'use_ssl': 0, + 'sort_order': 0, + 'created_at': '2026-01-01T00:00:00Z', + }; + final row = ConnectionRow.fromMap(map); + expect(row.type, 'postgresql'); + expect(row.databaseName, 'mydb'); + }); }); } diff --git a/test/features/postgresql/postgres_table_utils_test.dart b/test/features/postgresql/postgres_table_utils_test.dart new file mode 100644 index 00000000..9f237c04 --- /dev/null +++ b/test/features/postgresql/postgres_table_utils_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; + +void main() { + group('quotePostgresIdentifier', () { + test('quotes simple identifier', () { + expect(quotePostgresIdentifier('users'), '"users"'); + expect(quotePostgresIdentifier('public'), '"public"'); + }); + + test('doubles internal double-quotes', () { + expect(quotePostgresIdentifier('say "hello"'), '"say ""hello"""'); + expect(quotePostgresIdentifier('a""b'), '"a""""b"'); + }); + + test('handles empty string', () { + expect(quotePostgresIdentifier(''), '""'); + }); + + test('handles mixed case and special chars', () { + expect(quotePostgresIdentifier('MyTable'), '"MyTable"'); + expect(quotePostgresIdentifier('schema.name'), '"schema.name"'); + }); + }); + + group('convertResultRowsToStrings', () { + test('converts null to "NULL"', () { + final result = convertResultRowsToStrings([ + [null, 1], + [2, null], + ]); + expect(result, [ + ['NULL', '1'], + ['2', 'NULL'], + ]); + }); + + test('converts int and string', () { + final result = convertResultRowsToStrings([ + [42, 'hello'], + [0, ''], + ]); + expect(result, [ + ['42', 'hello'], + ['0', ''], + ]); + }); + + test('converts DateTime to ISO8601', () { + final dt = DateTime.utc(2026, 3, 15, 12, 30, 45); + final result = convertResultRowsToStrings([ + [dt], + ]); + expect(result, [ + [dt.toIso8601String()], + ]); + }); + + test('converts double and bool', () { + final result = convertResultRowsToStrings([ + [3.14, true], + [0.0, false], + ]); + expect(result, [ + ['3.14', 'true'], + ['0.0', 'false'], + ]); + }); + + test('empty list returns empty list', () { + expect(convertResultRowsToStrings([]), []); + }); + + test('empty row returns list of empty strings for nulls', () { + final result = convertResultRowsToStrings([ + [null, null], + ]); + expect(result, [ + ['NULL', 'NULL'], + ]); + }); + }); +} From 135cab953cba54caf1a4025e25dedc09e32bfeed Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:44:10 +0300 Subject: [PATCH 09/12] fix lib --- lib/features/postgresql/postgres_table_utils.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart index cb4e96bb..bf50e816 100644 --- a/lib/features/postgresql/postgres_table_utils.dart +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -1,4 +1,5 @@ /// Utilities for PostgreSQL table data view (quoting, row conversion). +library; /// Quotes a PostgreSQL identifier (e.g. schema or table name). /// Doubles any internal double-quote. From 25b0d234303e5421d140767c36f9a3681962cef7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:59:19 +0300 Subject: [PATCH 10/12] ci: add Release workflow for GitHub Releases - Trigger on push of tags v* (e.g. v0.1.0) - Build Windows and Linux release artifacts, zip and upload to Release - allow_updates so both jobs can attach assets to the same release Made-with: Cursor --- .github/workflows/release.yml | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..bd375a14 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +# Сборка релизов и публикация в GitHub Releases. +# Запуск: создайте и запушьте тег, например v0.1.0: +# git tag v0.1.0 +# git push origin v0.1.0 + +name: Release + +on: + push: + tags: + - 'v*' # v0.1.0, v1.0.0, etc. + +jobs: + build-windows: + name: Build Windows + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.5' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build Windows (release) + run: flutter build windows --release + + - name: Zip Windows artifact + run: | + $tag = "${{ github.ref_name }}" + $dir = "build\windows\x64\runner\Release" + Compress-Archive -Path "$dir\*" -DestinationPath "Querya-Desktop-$tag-windows.zip" + + - name: Create Release and upload Windows + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + generate_release_notes: true + files: Querya-Desktop-${{ github.ref_name }}-windows.zip + allow_updates: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-linux: + name: Build Linux + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.5' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build Linux (release) + run: flutter build linux --release + + - name: Zip Linux artifact + run: | + cd build/linux/x64/release/bundle + zip -r ../../../../../../Querya-Desktop-${{ github.ref_name }}-linux.zip . + + - name: Upload to Release (Linux) + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + generate_release_notes: true + files: Querya-Desktop-${{ github.ref_name }}-linux.zip + allow_updates: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From ac31629af3be3b826e5187e44550cead97eadb1b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 10:59:25 +0300 Subject: [PATCH 11/12] test: add unit tests for redis_info, ConnectionRow, Postgres, Mongo - redis_info: sectionDouble missing section; parseRedisInfo value with colon, same key in multiple sections - ConnectionRow: toMap includes database_name for postgresql - PostgresConnection: connectToDatabase result has connectionString null - MongoConnection: empty database omitted from URI path Made-with: Cursor --- .../database/mongodb_connection_test.dart | 10 ++++++++ .../database/postgres_connection_test.dart | 12 +++++++++ test/core/database/redis_info_test.dart | 25 +++++++++++++++++++ test/core/storage/connection_row_test.dart | 11 ++++++++ 4 files changed, 58 insertions(+) diff --git a/test/core/database/mongodb_connection_test.dart b/test/core/database/mongodb_connection_test.dart index 51a4254a..d3dfbec3 100644 --- a/test/core/database/mongodb_connection_test.dart +++ b/test/core/database/mongodb_connection_test.dart @@ -70,6 +70,16 @@ void main() { expect(conn.buildConnectionUri(), 'mongodb://localhost/mydb'); }); + test('empty database is omitted from path', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + database: '', + ); + expect(conn.buildConnectionUri(), 'mongodb://localhost'); + }); + test('authSource is added as query parameter', () { final conn = MongoConnection( id: 1, diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index 2be20f1f..1d53e00b 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -263,6 +263,18 @@ void main() { await conn.connectToDatabase('other'); expect(conn.database, 'mydb'); }); + + test('returned connection has connectionString null', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'postgresql://u:p@h/db', + ); + final newConn = await conn.connectToDatabase('targetdb'); + expect(newConn.connectionString, isNull); + expect(newConn.database, 'targetdb'); + }); }); group('PostgresConnectionException', () { diff --git a/test/core/database/redis_info_test.dart b/test/core/database/redis_info_test.dart index d7457c08..f559be34 100644 --- a/test/core/database/redis_info_test.dart +++ b/test/core/database/redis_info_test.dart @@ -187,5 +187,30 @@ void main() { final info = parseRedisInfo(_sampleInfo); expect(sectionDouble(info, 'Server', 'nope'), isNull); }); + + test('returns null for missing section', () { + final info = parseRedisInfo(_sampleInfo); + expect(sectionDouble(info, 'NoSuchSection', 'key'), isNull); + }); + }); + + group('parseRedisInfo edge cases', () { + test('handles value containing colon', () { + const raw = '# Server\nredis_version:7.0.0:patch1\n'; + final info = parseRedisInfo(raw); + expect(sectionValue(info, 'Server', 'redis_version'), '7.0.0:patch1'); + }); + + test('handles multiple sections with same-name keys', () { + const raw = ''' +# A +x:1 +# B +x:2 +'''; + final info = parseRedisInfo(raw); + expect(sectionValue(info, 'A', 'x'), '1'); + expect(sectionValue(info, 'B', 'x'), '2'); + }); }); } diff --git a/test/core/storage/connection_row_test.dart b/test/core/storage/connection_row_test.dart index c423a4d8..200aa166 100644 --- a/test/core/storage/connection_row_test.dart +++ b/test/core/storage/connection_row_test.dart @@ -186,5 +186,16 @@ void main() { expect(row.type, 'postgresql'); expect(row.databaseName, 'mydb'); }); + + test('toMap includes database_name for postgresql', () { + const row = ConnectionRow( + type: 'postgresql', + name: 'PG', + host: 'localhost', + databaseName: 'appdb', + createdAt: '2026-01-01T00:00:00Z', + ); + expect(row.toMap()['database_name'], 'appdb'); + }); }); } From a83127f12486c8c6e196572925bd6b8bb6157169 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 14 Mar 2026 11:03:29 +0300 Subject: [PATCH 12/12] test: add driver_urls tests and extend postgres/mongo utils - Add test/core/drivers/driver_urls_test: constants, DownloadableDriver extension, DriverDownloadException - postgres_table_utils: test single row with mixed types - mongodb_uri_replacement: test buildUriForDatabase with empty database name Made-with: Cursor --- .../mongodb_uri_replacement_test.dart | 11 +++++ test/core/drivers/driver_urls_test.dart | 43 +++++++++++++++++++ .../postgresql/postgres_table_utils_test.dart | 8 ++++ 3 files changed, 62 insertions(+) create mode 100644 test/core/drivers/driver_urls_test.dart diff --git a/test/core/database/mongodb_uri_replacement_test.dart b/test/core/database/mongodb_uri_replacement_test.dart index f98fc459..2ac7531c 100644 --- a/test/core/database/mongodb_uri_replacement_test.dart +++ b/test/core/database/mongodb_uri_replacement_test.dart @@ -188,5 +188,16 @@ void main() { expect(uri, contains('/testdb')); expect(uri, isNot(contains('authSource'))); }); + + test('buildUriForDatabase with empty database name uses root path', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + final uri = conn.buildUriForDatabase(''); + expect(uri, contains('mongodb://')); + expect(Uri.parse(uri).path, '/'); + }); }); } diff --git a/test/core/drivers/driver_urls_test.dart b/test/core/drivers/driver_urls_test.dart new file mode 100644 index 00000000..80d801d9 --- /dev/null +++ b/test/core/drivers/driver_urls_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/drivers/driver_urls.dart'; +import 'package:querya_desktop/core/drivers/driver_storage.dart'; + +void main() { + group('driver_urls constants', () { + test('postgresqlDriverUrl is valid HTTPS URL', () { + expect(postgresqlDriverUrl, startsWith('https://')); + expect(Uri.tryParse(postgresqlDriverUrl), isNotNull); + }); + + test('postgresqlJarFileName ends with .jar', () { + expect(postgresqlJarFileName, endsWith('.jar')); + expect(postgresqlJarFileName, contains('postgresql')); + }); + }); + + group('DownloadableDriver extension', () { + test('postgresql url matches constant', () { + expect(DownloadableDriver.postgresql.url, postgresqlDriverUrl); + }); + + test('postgresql jarFileName matches constant', () { + expect(DownloadableDriver.postgresql.jarFileName, postgresqlJarFileName); + }); + + test('all enum values have url and jarFileName', () { + for (final d in DownloadableDriver.values) { + expect(d.url, isNotEmpty); + expect(d.jarFileName, isNotEmpty); + expect(d.jarFileName, endsWith('.jar')); + } + }); + }); + + group('DriverDownloadException', () { + test('stores message and toString returns it', () { + final ex = DriverDownloadException('HTTP 404'); + expect(ex.message, 'HTTP 404'); + expect(ex.toString(), 'HTTP 404'); + }); + }); +} diff --git a/test/features/postgresql/postgres_table_utils_test.dart b/test/features/postgresql/postgres_table_utils_test.dart index 9f237c04..372b26b4 100644 --- a/test/features/postgresql/postgres_table_utils_test.dart +++ b/test/features/postgresql/postgres_table_utils_test.dart @@ -79,5 +79,13 @@ void main() { ['NULL', 'NULL'], ]); }); + + test('single row with mixed types', () { + final result = convertResultRowsToStrings([ + [1, null, 'x', 2.5, true, DateTime.utc(2026, 1, 1)], + ]); + expect(result.length, 1); + expect(result.first, ['1', 'NULL', 'x', '2.5', 'true', '2026-01-01T00:00:00.000Z']); + }); }); }