From 73c1a9de65c60ca3d4774d0ea6ae2fe673a698c9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 12:51:18 +0300 Subject: [PATCH 1/5] feat(postgresql): PostgresService pool, PgLease, interrupt, session read-only Made-with: Cursor --- lib/core/database/postgres_connection.dart | 87 +++++++++++- lib/core/database/postgres_service.dart | 126 ++++++++++++++++++ .../database/postgres_connection_test.dart | 35 ++++- 3 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 lib/core/database/postgres_service.dart diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index c678e788..e4f50c30 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -1,7 +1,33 @@ import 'package:postgres/postgres.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'postgres_metadata.dart'; +/// Replaces the database in a `postgresql://` / `postgres://` URI (path or +/// `database=` query param). Used when switching DB while keeping URI auth/SSL. +String replaceDatabaseInConnectionString( + String connectionString, + String newDatabase, +) { + final uri = Uri.parse(connectionString.trim()); + if (uri.scheme != 'postgres' && uri.scheme != 'postgresql') { + throw ArgumentError( + 'Invalid connection string scheme: ${uri.scheme}. ' + 'Expected "postgresql" or "postgres".', + ); + } + final params = Map.from(uri.queryParameters); + if (params.containsKey('database')) { + params['database'] = newDatabase; + return uri.replace(queryParameters: params).toString(); + } + if (uri.pathSegments.isNotEmpty && uri.pathSegments.first.isNotEmpty) { + return uri.replace(path: '/$newDatabase').toString(); + } + params['database'] = newDatabase; + return uri.replace(queryParameters: params).toString(); +} + /// PostgreSQL connection using the pure-Dart `postgres` package. class PostgresConnection { PostgresConnection({ @@ -16,6 +42,24 @@ class PostgresConnection { this.connectionString, }); + /// Builds a connection from a saved [ConnectionRow] (host/port or URI). + factory PostgresConnection.fromConnectionRow( + ConnectionRow row, { + String? database, + }) { + return PostgresConnection( + id: row.id ?? 0, + name: row.name, + host: row.host ?? 'localhost', + port: row.port ?? 5432, + username: row.username, + password: row.password, + database: database ?? row.databaseName ?? 'postgres', + useSSL: row.useSSL, + connectionString: row.connectionString, + ); + } + final int id; final String name; final String host; @@ -31,6 +75,9 @@ class PostgresConnection { bool get isConnected => _isConnected && _conn != null; + bool get _usesConnectionString => + connectionString != null && connectionString!.trim().isNotEmpty; + Endpoint _buildEndpoint() { return Endpoint( host: host, @@ -52,10 +99,14 @@ class PostgresConnection { Future connect() async { if (_isConnected && _conn != null) return; try { - _conn = await Connection.open( - _buildEndpoint(), - settings: _buildSettings(), - ); + if (_usesConnectionString) { + _conn = await Connection.openFromUrl(connectionString!.trim()); + } else { + _conn = await Connection.open( + _buildEndpoint(), + settings: _buildSettings(), + ); + } _isConnected = true; } catch (e) { _isConnected = false; @@ -73,6 +124,27 @@ class PostgresConnection { } catch (_) {} } + /// Drops the TCP session immediately (kills pending client I/O). Used when + /// cancelling a long query or [PostgresService.interrupt]. + Future forceClose() async { + _isConnected = false; + final c = _conn; + _conn = null; + try { + await c?.close(force: true); + } catch (_) {} + } + + /// Session-level default for transactions (browse vs SQL editor). + Future setSessionReadOnly(bool readOnly) async { + if (!isConnected) return; + await execute( + readOnly + ? 'SET default_transaction_read_only = ON' + : 'SET default_transaction_read_only = OFF', + ); + } + Future testConnection() async { try { await connect(); @@ -262,8 +334,12 @@ class PostgresConnection { return stats; } - /// Connect to a specific database (creates a new connection). + /// Connect to a specific database (creates a new connection config). Future connectToDatabase(String dbName) async { + final cs = connectionString; + final newCs = (cs != null && cs.trim().isNotEmpty) + ? replaceDatabaseInConnectionString(cs, dbName) + : null; return PostgresConnection( id: id, name: name, @@ -273,6 +349,7 @@ class PostgresConnection { password: password, database: dbName, useSSL: useSSL, + connectionString: newCs, ); } diff --git a/lib/core/database/postgres_service.dart b/lib/core/database/postgres_service.dart new file mode 100644 index 00000000..073090d0 --- /dev/null +++ b/lib/core/database/postgres_service.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Session policy for pooled connections: browse-only vs ad-hoc SQL (writes). +enum PgSessionMode { + /// `SET default_transaction_read_only = ON` after connect. + readOnly, + + /// Read-write session (SQL editor, probes that need catalog writes — rare). + readWrite, +} + +/// Lease for a pooled [PostgresConnection]. Call [release] when the UI is done +/// (typically in [State.dispose]). +class PgLease { + PgLease._(this._service, this._key, this.connection); + + final PostgresService _service; + final String _key; + final PostgresConnection connection; + + bool _released = false; + + /// Returns the connection to the pool (ref-count / idle dispose). + void release() { + if (_released) return; + _released = true; + _service._release(_key); + } +} + +/// Pooled PostgreSQL connections keyed by `(connection id, database, session mode)`. +/// +/// Matches the idea of [MongoService]: reuse TCP sessions instead of opening one +/// per widget. Idle connections are closed after [idleDisposeDelay]. +/// +/// Use [interrupt] to force-close a pooled connection (e.g. user navigates away +/// while a query is still running); the next [acquire] opens a new connection. +class PostgresService { + PostgresService._(); + static final PostgresService instance = PostgresService._(); + + static const Duration idleDisposeDelay = Duration(seconds: 8); + + final Map _pool = {}; + + String _key(int? id, String database, PgSessionMode mode) => + '${id ?? 0}::$database::${mode.name}'; + + /// Obtains a connected [PostgresConnection], incrementing the pool ref-count. + Future acquire( + ConnectionRow row, { + required String database, + PgSessionMode mode = PgSessionMode.readOnly, + }) async { + final key = _key(row.id, database, mode); + var entry = _pool[key]; + if (entry != null) { + entry.idleTimer?.cancel(); + entry.idleTimer = null; + entry.refs++; + if (!entry.connection.isConnected) { + await entry.connection.connect(); + await entry.connection.setSessionReadOnly(mode == PgSessionMode.readOnly); + } + return PgLease._(this, key, entry.connection); + } + + final conn = PostgresConnection.fromConnectionRow(row, database: database); + await conn.connect(); + await conn.setSessionReadOnly(mode == PgSessionMode.readOnly); + entry = _PoolEntry(conn)..refs = 1; + _pool[key] = entry; + return PgLease._(this, key, conn); + } + + void _release(String key) { + final entry = _pool[key]; + if (entry == null) return; + entry.refs--; + if (entry.refs > 0) return; + entry.idleTimer?.cancel(); + entry.idleTimer = Timer(idleDisposeDelay, () { + final e = _pool[key]; + if (e == null || e.refs > 0) return; + e.idleTimer = null; + unawaited(e.connection.disconnect()); + _pool.remove(key); + }); + } + + /// Force-closes the pooled connection for this key (drops client-side I/O; + /// server may still finish the query until it notices disconnect). + /// + /// Safe to call when leaving a screen while `_loading` / long query. + void interrupt( + ConnectionRow row, { + required String database, + PgSessionMode mode = PgSessionMode.readOnly, + }) { + final key = _key(row.id, database, mode); + final entry = _pool.remove(key); + if (entry == null) return; + entry.idleTimer?.cancel(); + unawaited(entry.connection.forceClose()); + } + + /// Closes all pooled connections (e.g. app shutdown). + Future disconnectAll() async { + for (final entry in _pool.values) { + entry.idleTimer?.cancel(); + await entry.connection.forceClose(); + } + _pool.clear(); + } +} + +class _PoolEntry { + _PoolEntry(this.connection); + + final PostgresConnection connection; + int refs = 0; + Timer? idleTimer; +} diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index 73387b3c..083ffcc4 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -115,6 +115,16 @@ void main() { await conn.disconnect(); expect(conn.isConnected, false); }); + + test('forceClose on never-connected instance does not throw', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + await conn.forceClose(); + expect(conn.isConnected, false); + }); }); group('PostgresConnection when not connected', () { @@ -385,7 +395,7 @@ void main() { expect(conn.database, 'mydb'); }); - test('returned connection has connectionString null', () async { + test('returned connection updates URI database', () async { final conn = PostgresConnection( id: 1, name: 'test', @@ -393,7 +403,7 @@ void main() { connectionString: 'postgresql://u:p@h/db', ); final newConn = await conn.connectToDatabase('targetdb'); - expect(newConn.connectionString, isNull); + expect(newConn.connectionString, 'postgresql://u:p@h/targetdb'); expect(newConn.database, 'targetdb'); }); }); @@ -405,4 +415,25 @@ void main() { expect(ex.toString(), 'connection refused'); }); }); + + group('replaceDatabaseInConnectionString', () { + test('replaces database in path', () { + expect( + replaceDatabaseInConnectionString( + 'postgresql://u:p@h:5432/olddb', + 'newdb', + ), + 'postgresql://u:p@h:5432/newdb', + ); + }); + + test('replaces database query param', () { + final out = replaceDatabaseInConnectionString( + 'postgresql://u:p@h:5432/?database=olddb&sslmode=require', + 'newdb', + ); + expect(out, contains('database=newdb')); + expect(out, isNot(contains('database=olddb'))); + }); + }); } From dbb04010e295f83a2f44d2e1843b8bdcc88a270f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 12:51:22 +0300 Subject: [PATCH 2/5] feat(postgresql): URI form, SQL workspace, pooled connections in views Made-with: Cursor --- .flutter-plugins-dependencies | 2 +- .../connections/connections_panel.dart | 55 ++-- .../main_screen/query_editor_tab.dart | 75 ++++- lib/features/main_screen/results_tab.dart | 105 ++++++- lib/features/main_screen/workspace_panel.dart | 6 +- .../postgresql/postgres_browser_views.dart | 181 ++++++----- .../postgresql/postgres_routine_view.dart | 38 +-- .../postgresql/postgres_sequence_view.dart | 39 +-- .../postgresql/postgres_sql_workspace.dart | 291 ++++++++++++++++++ .../postgresql/postgres_stats_view.dart | 40 +-- .../postgresql/postgres_table_view.dart | 39 +-- .../postgresql/postgres_workspace_home.dart | 89 ++++++ .../postgresql_connection_form.dart | 45 ++- .../postgresql_connection_form_test.dart | 1 + 14 files changed, 805 insertions(+), 201 deletions(-) create mode 100644 lib/features/postgresql/postgres_sql_workspace.dart create mode 100644 lib/features/postgresql/postgres_workspace_home.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 0594e7e3..202507b2 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-21 12:14:43.755042","version":"3.41.5","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-21 12:40:42.216584","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 8998bbc4..3751761b 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart' as material show BuildContext, Widget, 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, Material, StatelessWidget, Colors; import 'package:querya_desktop/core/database/mongodb_connection.dart'; -import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/postgres_service.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'; @@ -1244,21 +1244,15 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { _loading = true; _error = null; }); + PgLease? lease; 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, + lease = await PostgresService.instance.acquire( + c, database: c.databaseName ?? 'postgres', - useSSL: c.useSSL, + mode: PgSessionMode.readOnly, ); - await conn.connect(); - final dbs = await conn.listDatabases(); - await conn.disconnect(); + final dbs = await lease.connection.listDatabases(); if (!mounted) return; setState(() { @@ -1271,6 +1265,8 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { _error = e.toString(); _loading = false; }); + } finally { + lease?.release(); } } @@ -1541,21 +1537,15 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { Future _loadSchemas() async { if (!mounted) return; setState(() => _loading = true); + PgLease? lease; 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, + lease = await PostgresService.instance.acquire( + c, database: widget.databaseName, - useSSL: c.useSSL, + mode: PgSessionMode.readOnly, ); - await conn.connect(); - final schemas = await conn.listSchemas(); - await conn.disconnect(); + final schemas = await lease.connection.listSchemas(); if (!mounted) return; setState(() { _schemas = schemas; @@ -1564,6 +1554,8 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { } catch (e) { if (!mounted) return; setState(() => _loading = false); + } finally { + lease?.release(); } } @@ -1852,19 +1844,15 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { Future _loadObjects() async { if (!mounted) return; setState(() => _loading = true); + PgLease? lease; 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, + lease = await PostgresService.instance.acquire( + c, database: widget.databaseName, - useSSL: c.useSSL, + mode: PgSessionMode.readOnly, ); - await conn.connect(); + final conn = lease.connection; final tables = await conn.listTables(schema: widget.schemaName); final views = await conn.listViews(schema: widget.schemaName); List matviews = []; @@ -1876,7 +1864,6 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { } 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; @@ -1890,6 +1877,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { } catch (e) { if (!mounted) return; setState(() => _loading = false); + } finally { + lease?.release(); } } diff --git a/lib/features/main_screen/query_editor_tab.dart b/lib/features/main_screen/query_editor_tab.dart index 1c092236..240e47b6 100644 --- a/lib/features/main_screen/query_editor_tab.dart +++ b/lib/features/main_screen/query_editor_tab.dart @@ -1,23 +1,86 @@ -import 'package:flutter/material.dart' as material show Padding, EdgeInsets, TextStyle; +import 'package:flutter/material.dart' as material + show Padding, EdgeInsets, TextStyle, TextEditingController; import 'package:querya_desktop/shared/widgets/widgets.dart'; class QueryEditorTab extends StatelessWidget { - const QueryEditorTab({super.key}); + const QueryEditorTab({ + super.key, + this.controller, + }); + + /// When null, an internal controller is used (standalone workspace without PG). + final material.TextEditingController? controller; + + @override + Widget build(BuildContext context) { + return _QueryEditorBody(controller: controller); + } +} + +class _QueryEditorBody extends StatefulWidget { + const _QueryEditorBody({this.controller}); + + final material.TextEditingController? controller; + + @override + State<_QueryEditorBody> createState() => _QueryEditorBodyState(); +} + +class _QueryEditorBodyState extends State<_QueryEditorBody> { + late material.TextEditingController _owned; + bool _ownController = false; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _owned = material.TextEditingController(); + _ownController = true; + } else { + _owned = widget.controller!; + } + } + + @override + void didUpdateWidget(covariant _QueryEditorBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + if (_ownController) { + _owned.dispose(); + _ownController = false; + } + if (widget.controller == null) { + _owned = material.TextEditingController(); + _ownController = true; + } else { + _owned = widget.controller!; + } + } + } + + @override + void dispose() { + if (_ownController) { + _owned.dispose(); + } + super.dispose(); + } @override Widget build(BuildContext context) { - return const material.Padding( - padding: material.EdgeInsets.all(12), + return material.Padding( + padding: const material.EdgeInsets.all(12), child: Card( padding: material.EdgeInsets.zero, child: TextField( + controller: _owned, maxLines: null, expands: true, - style: material.TextStyle( + style: const material.TextStyle( fontFamily: 'monospace', fontSize: 13, ), - placeholder: Text('-- Enter SQL here…\nSELECT 1;'), + placeholder: const Text('-- Enter SQL here…\nSELECT 1;'), ), ), ); diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 0597cd30..60829851 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -1,13 +1,110 @@ -import 'package:flutter/material.dart' as material show Center; +import 'package:flutter/material.dart' as material; import 'package:querya_desktop/shared/widgets/widgets.dart'; +/// Query output: grid, loading, error, or placeholder. class ResultsTab extends StatelessWidget { - const ResultsTab({super.key}); + const ResultsTab({ + super.key, + this.columns = const [], + this.rows = const [], + this.errorMessage, + this.isLoading = false, + this.affectedRows, + this.statusLine, + }); + + final List columns; + final List> rows; + final String? errorMessage; + final bool isLoading; + final int? affectedRows; + final String? statusLine; @override Widget build(BuildContext context) { - return material.Center( - child: const Text('Results').muted(), + if (isLoading) { + return const material.Center( + child: material.CircularProgressIndicator(), + ); + } + if (errorMessage != null && errorMessage!.isNotEmpty) { + return material.SingleChildScrollView( + padding: const material.EdgeInsets.all(16), + child: material.SelectableText( + errorMessage!, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Theme.of(context).colorScheme.destructive, + ), + ), + ); + } + if (columns.isEmpty && rows.isEmpty) { + if (statusLine != null) { + return material.Padding( + padding: const material.EdgeInsets.all(16), + child: Align( + alignment: material.Alignment.topLeft, + child: Text(statusLine!).muted().small(), + ), + ); + } + if (affectedRows != null) { + return material.Center( + child: Text('Rows affected: $affectedRows').muted(), + ); + } + return material.Center( + child: const Text('Run a query to see results here.').muted(), + ); + } + + return material.Scrollbar( + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.SingleChildScrollView( + child: material.Table( + border: material.TableBorder.all( + color: Theme.of(context).colorScheme.border.withValues(alpha: 0.35), + ), + defaultColumnWidth: const material.IntrinsicColumnWidth(), + children: [ + material.TableRow( + decoration: material.BoxDecoration( + color: Theme.of(context).colorScheme.muted.withValues(alpha: 0.35), + ), + children: columns + .map( + (c) => material.Padding( + padding: const material.EdgeInsets.all(8), + child: Text(c).semiBold().small(), + ), + ) + .toList(), + ), + ...rows.map( + (r) => material.TableRow( + children: r + .map( + (cell) => material.Padding( + padding: const material.EdgeInsets.all(8), + child: material.SelectableText( + cell, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ), ); } } diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 33de40d2..4965f412 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -8,7 +8,7 @@ import 'package:querya_desktop/features/postgresql/postgres_browser_views.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_routine_view.dart'; import 'package:querya_desktop/features/postgresql/postgres_sequence_view.dart'; -import 'package:querya_desktop/features/postgresql/postgres_stats_view.dart'; +import 'package:querya_desktop/features/postgresql/postgres_workspace_home.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'; @@ -155,8 +155,8 @@ class _WorkspacePanelState extends State { color: theme.colorScheme.background, child: material.SizedBox.expand( child: pg == null - ? PostgresStatsView( - key: ValueKey('pg_stats_${widget.activeConnection!.id}'), + ? PostgresWorkspaceHome( + key: ValueKey('pg_home_${widget.activeConnection!.id}'), connectionRow: widget.activeConnection!, ) : _pgObjectWorkspace( diff --git a/lib/features/postgresql/postgres_browser_views.dart b/lib/features/postgresql/postgres_browser_views.dart index 24b3047b..680ca5d8 100644 --- a/lib/features/postgresql/postgres_browser_views.dart +++ b/lib/features/postgresql/postgres_browser_views.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_metadata.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -30,7 +30,8 @@ class PostgresIndexListView extends material.StatefulWidget { } class _PostgresIndexListViewState extends material.State { - PostgresConnection? _connection; + PgLease? _lease; + bool _loading = true; String? _error; List _rows = []; @@ -45,7 +46,14 @@ class _PostgresIndexListViewState extends material.State @override void dispose() { _scroll.dispose(); - _connection?.disconnect(); + if (_loading) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.readOnly, + ); + } + _lease?.release(); super.dispose(); } @@ -54,22 +62,20 @@ class _PostgresIndexListViewState extends material.State _loading = true; _error = null; }); - _connection?.disconnect(); + _lease?.release(); + _lease = 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, + final lease = await PostgresService.instance.acquire( + widget.connectionRow, database: widget.database, - useSSL: c.useSSL, + mode: PgSessionMode.readOnly, ); - await conn.connect(); - _connection = conn; - final rows = await conn.listIndexesInSchema(widget.schema); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + final rows = await lease.connection.listIndexesInSchema(widget.schema); if (!mounted) return; setState(() { _rows = rows; @@ -199,7 +205,8 @@ class PostgresTriggerListView extends material.StatefulWidget { } class _PostgresTriggerListViewState extends material.State { - PostgresConnection? _connection; + PgLease? _lease; + bool _loading = true; String? _error; List _rows = []; @@ -214,7 +221,14 @@ class _PostgresTriggerListViewState extends material.State { - PostgresConnection? _connection; + PgLease? _lease; + bool _loading = true; String? _error; List _rows = []; @@ -376,7 +389,14 @@ class _PostgresTypeListViewState extends material.State { @override void dispose() { _scroll.dispose(); - _connection?.disconnect(); + if (_loading) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.readOnly, + ); + } + _lease?.release(); super.dispose(); } @@ -385,22 +405,20 @@ class _PostgresTypeListViewState extends material.State { _loading = true; _error = null; }); - _connection?.disconnect(); + _lease?.release(); + _lease = 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, + final lease = await PostgresService.instance.acquire( + widget.connectionRow, database: widget.database, - useSSL: c.useSSL, + mode: PgSessionMode.readOnly, ); - await conn.connect(); - _connection = conn; - final rows = await conn.listUserTypesInSchema(widget.schema); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + final rows = await lease.connection.listUserTypesInSchema(widget.schema); if (!mounted) return; setState(() { _rows = rows; @@ -505,7 +523,8 @@ class PostgresExtensionListView extends material.StatefulWidget { class _PostgresExtensionListViewState extends material.State { - PostgresConnection? _connection; + PgLease? _lease; + bool _loading = true; String? _error; List _rows = []; @@ -520,7 +539,14 @@ class _PostgresExtensionListViewState @override void dispose() { _scroll.dispose(); - _connection?.disconnect(); + if (_loading) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.readOnly, + ); + } + _lease?.release(); super.dispose(); } @@ -529,22 +555,20 @@ class _PostgresExtensionListViewState _loading = true; _error = null; }); - _connection?.disconnect(); + _lease?.release(); + _lease = 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, + final lease = await PostgresService.instance.acquire( + widget.connectionRow, database: widget.database, - useSSL: c.useSSL, + mode: PgSessionMode.readOnly, ); - await conn.connect(); - _connection = conn; - final rows = await conn.listExtensions(); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + final rows = await lease.connection.listExtensions(); if (!mounted) return; setState(() { _rows = rows; @@ -644,7 +668,8 @@ class PostgresFdwListView extends material.StatefulWidget { } class _PostgresFdwListViewState extends material.State { - PostgresConnection? _connection; + PgLease? _lease; + bool _loading = true; String? _error; List _fdws = []; @@ -660,7 +685,14 @@ class _PostgresFdwListViewState extends material.State { @override void dispose() { _scroll.dispose(); - _connection?.disconnect(); + if (_loading) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.readOnly, + ); + } + _lease?.release(); super.dispose(); } @@ -669,21 +701,20 @@ class _PostgresFdwListViewState extends material.State { _loading = true; _error = null; }); - _connection?.disconnect(); + _lease?.release(); + _lease = 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, + final lease = await PostgresService.instance.acquire( + widget.connectionRow, database: widget.database, - useSSL: c.useSSL, + mode: PgSessionMode.readOnly, ); - await conn.connect(); - _connection = conn; + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + final conn = lease.connection; final fdws = await conn.listForeignDataWrappers(); final srv = await conn.listForeignServers(); if (!mounted) return; diff --git a/lib/features/postgresql/postgres_routine_view.dart b/lib/features/postgresql/postgres_routine_view.dart index daccd2f8..34c14316 100644 --- a/lib/features/postgresql/postgres_routine_view.dart +++ b/lib/features/postgresql/postgres_routine_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -24,7 +25,8 @@ class PostgresRoutineView extends material.StatefulWidget { } class _PostgresRoutineViewState extends material.State { - PostgresConnection? _connection; + PgLease? _lease; + bool _loading = true; String? _error; List _overloads = []; @@ -51,14 +53,20 @@ class _PostgresRoutineViewState extends material.State { @override void dispose() { _scrollController.dispose(); - _disconnectCurrent(); + _disconnectCurrent(interruptIfBusy: true); super.dispose(); } - void _disconnectCurrent() { - final conn = _connection; - _connection = null; - conn?.disconnect(); + void _disconnectCurrent({bool interruptIfBusy = false}) { + if (interruptIfBusy && _loading) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.readOnly, + ); + } + _lease?.release(); + _lease = null; } Future _connectAndLoad() async { @@ -70,23 +78,17 @@ class _PostgresRoutineViewState extends material.State { _overloads = []; }); 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, + final lease = await PostgresService.instance.acquire( + widget.connectionRow, database: widget.database, - useSSL: c.useSSL, + mode: PgSessionMode.readOnly, ); - await conn.connect(); if (!mounted) { - conn.disconnect(); + lease.release(); return; } - _connection = conn; + _lease = lease; + final conn = lease.connection; final list = await conn.getFunctionDefinitions( widget.schema, widget.routineName, diff --git a/lib/features/postgresql/postgres_sequence_view.dart b/lib/features/postgresql/postgres_sequence_view.dart index 133d48cc..79aa40cd 100644 --- a/lib/features/postgresql/postgres_sequence_view.dart +++ b/lib/features/postgresql/postgres_sequence_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -24,7 +25,8 @@ class PostgresSequenceView extends material.StatefulWidget { } class _PostgresSequenceViewState extends material.State { - PostgresConnection? _connection; + PgLease? _lease; + bool _loading = true; String? _error; PostgresSequenceDetails? _details; @@ -50,14 +52,21 @@ class _PostgresSequenceViewState extends material.State { @override void dispose() { - _disconnectCurrent(); + _scrollController.dispose(); + _disconnectCurrent(interruptIfBusy: true); super.dispose(); } - void _disconnectCurrent() { - final conn = _connection; - _connection = null; - conn?.disconnect(); + void _disconnectCurrent({bool interruptIfBusy = false}) { + if (interruptIfBusy && _loading) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.readOnly, + ); + } + _lease?.release(); + _lease = null; } Future _connectAndLoad() async { @@ -69,23 +78,17 @@ class _PostgresSequenceViewState extends material.State { _details = 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, + final lease = await PostgresService.instance.acquire( + widget.connectionRow, database: widget.database, - useSSL: c.useSSL, + mode: PgSessionMode.readOnly, ); - await conn.connect(); if (!mounted) { - conn.disconnect(); + lease.release(); return; } - _connection = conn; + _lease = lease; + final conn = lease.connection; final d = await conn.getSequenceDetails( widget.schema, widget.sequenceName, diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart new file mode 100644 index 00000000..c64f77f9 --- /dev/null +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart' as material; +import 'package:postgres/postgres.dart' as pg; +import 'package:querya_desktop/core/database/postgres_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _maxDisplayRows = 5000; + +/// Ad-hoc SQL editor + results for a PostgreSQL connection (pgAdmin-style). +class PostgresSqlWorkspace extends material.StatefulWidget { + const PostgresSqlWorkspace({ + super.key, + required this.connectionRow, + }); + + final ConnectionRow connectionRow; + + @override + material.State createState() => + _PostgresSqlWorkspaceState(); +} + +class _PostgresSqlWorkspaceState extends material.State { + final _sqlController = material.TextEditingController(); + double _topFractionState = 0.65; + + PgLease? _lease; + + bool _running = false; + String? _error; + List _columns = []; + List> _rows = []; + int? _affectedRows; + String? _statusLine; + + Future _ensureLease() async { + if (_lease != null && _lease!.connection.isConnected) return; + _lease?.release(); + _lease = null; + final lease = await PostgresService.instance.acquire( + widget.connectionRow, + database: widget.connectionRow.databaseName ?? 'postgres', + mode: PgSessionMode.readWrite, + ); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + } + + @override + void dispose() { + if (_running) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.connectionRow.databaseName ?? 'postgres', + mode: PgSessionMode.readWrite, + ); + } + _lease?.release(); + _sqlController.dispose(); + super.dispose(); + } + + Future _execute() async { + final sql = _sqlController.text.trim(); + if (sql.isEmpty) return; + + setState(() { + _running = true; + _error = null; + _columns = []; + _rows = []; + _affectedRows = null; + _statusLine = null; + }); + + try { + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) { + setState(() { + _error = 'Could not connect to PostgreSQL.'; + _running = false; + }); + } + return; + } + final result = await conn.execute(sql); + + if (!mounted) return; + + final schema = result.schema; + final cols = []; + for (var i = 0; i < schema.columns.length; i++) { + final c = schema.columns[i]; + cols.add( + c.columnName?.isNotEmpty == true ? c.columnName! : '[$i]', + ); + } + + final outRows = >[]; + var n = 0; + for (final row in result) { + if (n >= _maxDisplayRows) break; + outRows.add(row.map(_cellText).toList()); + n++; + } + + setState(() { + _columns = cols; + _rows = outRows; + _affectedRows = result.affectedRows; + if (cols.isEmpty && outRows.isEmpty) { + _statusLine = + 'Command completed. Rows affected: ${result.affectedRows}.'; + } else { + final truncated = result.length > _maxDisplayRows; + _statusLine = truncated + ? 'Showing first $_maxDisplayRows of ${result.length} row(s).' + : '${result.length} row(s).'; + } + _running = false; + }); + } on pg.ServerException catch (e) { + if (mounted) { + setState(() { + _error = e.message; + _running = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _running = false; + }); + } + } + } + + static String _cellText(Object? v) { + if (v == null) return 'NULL'; + return v.toString(); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final topFlex = (_topFractionState * 100).round().clamp(20, 80); + final bottomFlex = 100 - topFlex; + + return material.LayoutBuilder( + builder: (context, constraints) { + final totalHeight = constraints.maxHeight; + return Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: topFlex, + child: Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _SqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + ), + const Divider(height: 1), + Expanded( + child: QueryEditorTab(controller: _sqlController), + ), + ], + ), + ), + _HorizontalResizeHandle( + totalHeight: totalHeight, + onDrag: (dy) { + if (totalHeight <= 0) return; + setState(() { + _topFractionState = + (_topFractionState + dy / totalHeight).clamp(0.2, 0.85); + }); + }, + ), + Expanded( + flex: bottomFlex, + child: Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: + const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), + ), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _SqlToolbar extends material.StatelessWidget { + const _SqlToolbar({ + required this.onExecute, + required this.running, + }); + + final Future Function()? onExecute; + final bool running; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + return material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + child: material.Row( + children: [ + Text('Query').semiBold().small(), + const Spacer(), + OutlineButton( + onPressed: onExecute, + leading: running + ? material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ) + : const material.Icon(material.Icons.play_arrow_rounded, size: 18), + child: const Text('Execute (F5)'), + ), + ], + ), + ); + } +} + +class _HorizontalResizeHandle extends material.StatelessWidget { + const _HorizontalResizeHandle({ + required this.totalHeight, + required this.onDrag, + }); + + final double totalHeight; + final void Function(double dy) onDrag; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + return material.MouseRegion( + cursor: material.SystemMouseCursors.resizeRow, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onVerticalDragUpdate: (e) => onDrag(e.delta.dy), + child: material.Container( + height: 6, + color: theme.border.withValues(alpha: 0.15), + ), + ), + ); + } +} diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index c61543a8..5115aefd 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/postgres_service.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; @@ -23,7 +24,9 @@ class PostgresStatsView extends material.StatefulWidget { } class _PostgresStatsViewState extends material.State { - PostgresConnection? _connection; + PgLease? _lease; + PostgresConnection? get _connection => _lease?.connection; + Map? _stats; bool _loading = true; String? _error; @@ -48,14 +51,20 @@ class _PostgresStatsViewState extends material.State { @override void dispose() { _timer?.cancel(); - _disconnectCurrent(); + _disconnectCurrent(interruptIfBusy: _loading); super.dispose(); } - void _disconnectCurrent() { - final conn = _connection; - _connection = null; - conn?.disconnect(); + void _disconnectCurrent({bool interruptIfBusy = false}) { + if (interruptIfBusy && _loading) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.connectionRow.databaseName ?? 'postgres', + mode: PgSessionMode.readOnly, + ); + } + _lease?.release(); + _lease = null; } Future _load() async { @@ -68,23 +77,16 @@ class _PostgresStatsViewState extends material.State { _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, + final lease = await PostgresService.instance.acquire( + widget.connectionRow, + database: widget.connectionRow.databaseName ?? 'postgres', + mode: PgSessionMode.readOnly, ); - await conn.connect(); if (!mounted) { - conn.disconnect(); + lease.release(); return; } - _connection = conn; + _lease = lease; await _fetch(); if (mounted) _startTimer(); } catch (e) { diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 3f2473aa..c01aeb39 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -1,6 +1,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/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_editor_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_privileges_dialog.dart'; @@ -37,7 +38,9 @@ class PostgresTableView extends material.StatefulWidget { } class _PostgresTableViewState extends material.State { - PostgresConnection? _connection; + PgLease? _lease; + PostgresConnection? get _connection => _lease?.connection; + bool _loading = true; String? _error; @@ -85,14 +88,20 @@ class _PostgresTableViewState extends material.State { void dispose() { _verticalController.dispose(); _horizontalController.dispose(); - _disconnectCurrent(); + _disconnectCurrent(interruptIfBusy: true); super.dispose(); } - void _disconnectCurrent() { - final conn = _connection; - _connection = null; - conn?.disconnect(); + void _disconnectCurrent({bool interruptIfBusy = false}) { + if (interruptIfBusy && _loading) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.readWrite, + ); + } + _lease?.release(); + _lease = null; } Future _connectAndLoad() async { @@ -110,23 +119,17 @@ class _PostgresTableViewState extends material.State { _customSql = 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, + final lease = await PostgresService.instance.acquire( + widget.connectionRow, database: widget.database, - useSSL: c.useSSL, + // Custom SQL + REFRESH MATERIALIZED VIEW need a read-write session. + mode: PgSessionMode.readWrite, ); - await conn.connect(); if (!mounted) { - conn.disconnect(); + lease.release(); return; } - _connection = conn; + _lease = lease; await _fetch(refreshCount: true); } catch (e) { if (mounted) { diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart new file mode 100644 index 00000000..099f7505 --- /dev/null +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_workspace.dart'; +import 'package:querya_desktop/features/postgresql/postgres_stats_view.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// When a PostgreSQL connection is selected but no tree object: Server stats or SQL editor. +class PostgresWorkspaceHome extends material.StatefulWidget { + const PostgresWorkspaceHome({ + super.key, + required this.connectionRow, + }); + + final ConnectionRow connectionRow; + + @override + material.State createState() => + _PostgresWorkspaceHomeState(); +} + +class _PostgresWorkspaceHomeState extends material.State { + int _tab = 0; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + child: material.Row( + children: [ + Text('PostgreSQL').semiBold().small(), + const Spacer(), + ...List.generate(2, (i) { + final labels = ['Server', 'SQL']; + final selected = _tab == i; + return material.Padding( + padding: const material.EdgeInsets.only(left: 6), + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + onTap: () => setState(() => _tab = i), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: material.BoxDecoration( + color: selected + ? theme.colorScheme.background + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(6), + ), + child: selected + ? Text(labels[i]).small().semiBold() + : Text(labels[i]) + .small() + .muted(), + ), + ), + ), + ); + }), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: _tab == 0 + ? PostgresStatsView( + key: ValueKey('pg_stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + ) + : PostgresSqlWorkspace( + key: ValueKey('pg_sql_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + ), + ), + ], + ); + } +} diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 7d6bd853..36188f89 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -40,6 +40,7 @@ class _PostgresConnectionFormContentState final _databaseController = material.TextEditingController(text: 'postgres'); final _usernameController = material.TextEditingController(text: 'postgres'); final _passwordController = material.TextEditingController(); + final _connectionStringController = material.TextEditingController(); bool _useSSL = false; bool _showPassword = false; @@ -55,11 +56,21 @@ class _PostgresConnectionFormContentState _portController.addListener(_onFieldChanged); _databaseController.addListener(_onFieldChanged); _usernameController.addListener(_onFieldChanged); + _connectionStringController.addListener(_onFieldChanged); } void _onFieldChanged() => setState(() {}); + bool _looksLikePostgresUri(String s) { + final t = s.trim().toLowerCase(); + return t.startsWith('postgres://') || t.startsWith('postgresql://'); + } + bool get _formValid { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) { + return _looksLikePostgresUri(uri); + } final host = _hostController.text.trim(); final db = _databaseController.text.trim(); return host.isNotEmpty && db.isNotEmpty; @@ -91,12 +102,13 @@ class _PostgresConnectionFormContentState _testResult = null; }); try { + final uri = _connectionStringController.text.trim(); final conn = PostgresConnection( id: 0, name: _nameController.text.trim().isEmpty ? 'test' : _nameController.text.trim(), - host: _hostController.text.trim(), + host: uri.isNotEmpty ? 'localhost' : _hostController.text.trim(), port: int.tryParse(_portController.text.trim()) ?? 5432, database: _databaseController.text.trim().isEmpty ? null @@ -108,6 +120,7 @@ class _PostgresConnectionFormContentState ? null : _passwordController.text, useSSL: _useSSL, + connectionString: uri.isEmpty ? null : uri, ); final ok = await conn.testConnection(); if (mounted) _showTestResult(ok ? 'success' : 'failed'); @@ -122,20 +135,25 @@ class _PostgresConnectionFormContentState 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 uri = _connectionStringController.text.trim(); + final displayName = name.isNotEmpty + ? name + : (uri.isNotEmpty + ? 'PostgreSQL (URI)' + : 'PostgreSQL $host:$port/$database'); final row = ConnectionRow( type: 'postgresql', name: displayName, - host: host, - port: port, + host: uri.isNotEmpty ? null : host, + port: uri.isNotEmpty ? null : port, username: _usernameController.text.trim().isEmpty ? null : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, - databaseName: database.isEmpty ? null : database, + databaseName: uri.isNotEmpty ? null : (database.isEmpty ? null : database), useSSL: _useSSL, + connectionString: uri.isEmpty ? null : uri, folderId: widget.folderId, createdAt: DateTime.now().toUtc().toIso8601String(), ); @@ -150,12 +168,14 @@ class _PostgresConnectionFormContentState _portController.removeListener(_onFieldChanged); _databaseController.removeListener(_onFieldChanged); _usernameController.removeListener(_onFieldChanged); + _connectionStringController.removeListener(_onFieldChanged); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); _databaseController.dispose(); _usernameController.dispose(); _passwordController.dispose(); + _connectionStringController.dispose(); super.dispose(); } @@ -225,6 +245,19 @@ class _PostgresConnectionFormContentState placeholder: const Text('My PostgreSQL Server'), ), const Gap(16), + const Text('Connection URI (optional)').small().semiBold(), + const Gap(4), + Text( + 'If set, overrides Host / Port / Database below.', + ).muted().small(), + const Gap(8), + TextField( + controller: _connectionStringController, + placeholder: const Text( + 'postgresql://user:pass@host:5432/dbname?sslmode=require', + ), + ), + const Gap(16), // Host + Port material.Row( children: [ diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index 2c8819ae..c1589417 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -27,6 +27,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('PostgreSQL Connection'), findsOneWidget); + expect(find.text('Connection URI (optional)'), findsOneWidget); expect(find.text('Connection Name'), findsOneWidget); expect(find.text('Host'), findsOneWidget); expect(find.text('Port'), findsOneWidget); From 9b7f99113ce818da857f396f6212370e7e00acec Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 13:04:23 +0300 Subject: [PATCH 3/5] feat(postgresql): extract PostgresConnectionPool and add pool unit tests Made-with: Cursor --- .../database/postgres_connection_pool.dart | 131 ++++++++ lib/core/database/postgres_service.dart | 133 ++------ .../postgres_connection_pool_test.dart | 284 ++++++++++++++++++ 3 files changed, 446 insertions(+), 102 deletions(-) create mode 100644 lib/core/database/postgres_connection_pool.dart create mode 100644 test/core/database/postgres_connection_pool_test.dart diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart new file mode 100644 index 00000000..3a6bd5c1 --- /dev/null +++ b/lib/core/database/postgres_connection_pool.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Session policy for pooled connections: browse-only vs ad-hoc SQL (writes). +enum PgSessionMode { + /// `SET default_transaction_read_only = ON` after connect. + readOnly, + + /// Read-write session (SQL editor, probes that need catalog writes — rare). + readWrite, +} + +/// Creates a connected [PostgresConnection] for the pool (real or fake in tests). +typedef PostgresPoolConnectionFactory = Future Function( + ConnectionRow row, { + required String database, + required PgSessionMode mode, +}); + +/// Lease for a pooled [PostgresConnection]. Call [release] when the UI is done +/// (typically in [State.dispose]). +class PgLease { + PgLease._(this._pool, this._key, this.connection); + + final PostgresConnectionPool _pool; + final String _key; + final PostgresConnection connection; + + bool _released = false; + + /// Returns the connection to the pool (ref-count / idle dispose). + void release() { + if (_released) return; + _released = true; + _pool._release(_key); + } +} + +/// Pooled PostgreSQL connections keyed by `(connection id, database, session mode)`. +/// +/// Use [interrupt] to force-close a pooled connection (e.g. user navigates away +/// while a query is still running); the next [acquire] opens a new connection. +class PostgresConnectionPool { + PostgresConnectionPool({ + required this.createAndConnect, + this.idleDisposeDelay = defaultIdleDisposeDelay, + }); + + static const Duration defaultIdleDisposeDelay = Duration(seconds: 8); + + final PostgresPoolConnectionFactory createAndConnect; + final Duration idleDisposeDelay; + + final Map _pool = {}; + + String keyFor(int? id, String database, PgSessionMode mode) => + '${id ?? 0}::$database::${mode.name}'; + + /// Obtains a connected [PostgresConnection], incrementing the pool ref-count. + Future acquire( + ConnectionRow row, { + required String database, + PgSessionMode mode = PgSessionMode.readOnly, + }) async { + final k = keyFor(row.id, database, mode); + var entry = _pool[k]; + if (entry != null) { + entry.idleTimer?.cancel(); + entry.idleTimer = null; + entry.refs++; + if (!entry.connection.isConnected) { + await entry.connection.connect(); + await entry.connection.setSessionReadOnly(mode == PgSessionMode.readOnly); + } + return PgLease._(this, k, entry.connection); + } + + final conn = await createAndConnect(row, database: database, mode: mode); + entry = _PoolEntry(conn)..refs = 1; + _pool[k] = entry; + return PgLease._(this, k, conn); + } + + void _release(String k) { + final entry = _pool[k]; + if (entry == null) return; + entry.refs--; + if (entry.refs > 0) return; + entry.idleTimer?.cancel(); + entry.idleTimer = Timer(idleDisposeDelay, () { + final e = _pool[k]; + if (e == null || e.refs > 0) return; + e.idleTimer = null; + unawaited(e.connection.disconnect()); + _pool.remove(k); + }); + } + + /// Force-closes the pooled connection for this key (drops client-side I/O; + /// server may still finish the query until it notices disconnect). + void interrupt( + ConnectionRow row, { + required String database, + PgSessionMode mode = PgSessionMode.readOnly, + }) { + final k = keyFor(row.id, database, mode); + final entry = _pool.remove(k); + if (entry == null) return; + entry.idleTimer?.cancel(); + unawaited(entry.connection.forceClose()); + } + + /// Closes all pooled connections (e.g. app shutdown). + Future disconnectAll() async { + for (final entry in _pool.values) { + entry.idleTimer?.cancel(); + await entry.connection.forceClose(); + } + _pool.clear(); + } +} + +class _PoolEntry { + _PoolEntry(this.connection); + + final PostgresConnection connection; + int refs = 0; + Timer? idleTimer; +} diff --git a/lib/core/database/postgres_service.dart b/lib/core/database/postgres_service.dart index 073090d0..820aeeeb 100644 --- a/lib/core/database/postgres_service.dart +++ b/lib/core/database/postgres_service.dart @@ -1,126 +1,55 @@ -import 'dart:async'; - import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/postgres_connection_pool.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -/// Session policy for pooled connections: browse-only vs ad-hoc SQL (writes). -enum PgSessionMode { - /// `SET default_transaction_read_only = ON` after connect. - readOnly, - - /// Read-write session (SQL editor, probes that need catalog writes — rare). - readWrite, -} - -/// Lease for a pooled [PostgresConnection]. Call [release] when the UI is done -/// (typically in [State.dispose]). -class PgLease { - PgLease._(this._service, this._key, this.connection); - - final PostgresService _service; - final String _key; - final PostgresConnection connection; - - bool _released = false; - - /// Returns the connection to the pool (ref-count / idle dispose). - void release() { - if (_released) return; - _released = true; - _service._release(_key); - } +export 'postgres_connection_pool.dart' + show PgLease, PgSessionMode, PostgresConnectionPool; + +Future _defaultCreateAndConnect( + ConnectionRow row, { + required String database, + required PgSessionMode mode, +}) async { + final conn = PostgresConnection.fromConnectionRow(row, database: database); + await conn.connect(); + await conn.setSessionReadOnly(mode == PgSessionMode.readOnly); + return conn; } -/// Pooled PostgreSQL connections keyed by `(connection id, database, session mode)`. -/// -/// Matches the idea of [MongoService]: reuse TCP sessions instead of opening one -/// per widget. Idle connections are closed after [idleDisposeDelay]. +/// Global PostgreSQL connection pool (singleton). /// -/// Use [interrupt] to force-close a pooled connection (e.g. user navigates away -/// while a query is still running); the next [acquire] opens a new connection. +/// For tests of pool logic without a server, use [PostgresConnectionPool] +/// with a fake [PostgresPoolConnectionFactory]. class PostgresService { - PostgresService._(); - static final PostgresService instance = PostgresService._(); + PostgresService._() + : _pool = PostgresConnectionPool( + createAndConnect: _defaultCreateAndConnect, + ); - static const Duration idleDisposeDelay = Duration(seconds: 8); + static final PostgresService instance = PostgresService._(); - final Map _pool = {}; + final PostgresConnectionPool _pool; - String _key(int? id, String database, PgSessionMode mode) => - '${id ?? 0}::$database::${mode.name}'; + /// Same as [PostgresConnectionPool.defaultIdleDisposeDelay]. + static const Duration idleDisposeDelay = + PostgresConnectionPool.defaultIdleDisposeDelay; /// Obtains a connected [PostgresConnection], incrementing the pool ref-count. Future acquire( ConnectionRow row, { required String database, PgSessionMode mode = PgSessionMode.readOnly, - }) async { - final key = _key(row.id, database, mode); - var entry = _pool[key]; - if (entry != null) { - entry.idleTimer?.cancel(); - entry.idleTimer = null; - entry.refs++; - if (!entry.connection.isConnected) { - await entry.connection.connect(); - await entry.connection.setSessionReadOnly(mode == PgSessionMode.readOnly); - } - return PgLease._(this, key, entry.connection); - } - - final conn = PostgresConnection.fromConnectionRow(row, database: database); - await conn.connect(); - await conn.setSessionReadOnly(mode == PgSessionMode.readOnly); - entry = _PoolEntry(conn)..refs = 1; - _pool[key] = entry; - return PgLease._(this, key, conn); - } - - void _release(String key) { - final entry = _pool[key]; - if (entry == null) return; - entry.refs--; - if (entry.refs > 0) return; - entry.idleTimer?.cancel(); - entry.idleTimer = Timer(idleDisposeDelay, () { - final e = _pool[key]; - if (e == null || e.refs > 0) return; - e.idleTimer = null; - unawaited(e.connection.disconnect()); - _pool.remove(key); - }); - } + }) => + _pool.acquire(row, database: database, mode: mode); - /// Force-closes the pooled connection for this key (drops client-side I/O; - /// server may still finish the query until it notices disconnect). - /// - /// Safe to call when leaving a screen while `_loading` / long query. + /// Force-closes the pooled connection for this key. void interrupt( ConnectionRow row, { required String database, PgSessionMode mode = PgSessionMode.readOnly, - }) { - final key = _key(row.id, database, mode); - final entry = _pool.remove(key); - if (entry == null) return; - entry.idleTimer?.cancel(); - unawaited(entry.connection.forceClose()); - } + }) => + _pool.interrupt(row, database: database, mode: mode); /// Closes all pooled connections (e.g. app shutdown). - Future disconnectAll() async { - for (final entry in _pool.values) { - entry.idleTimer?.cancel(); - await entry.connection.forceClose(); - } - _pool.clear(); - } -} - -class _PoolEntry { - _PoolEntry(this.connection); - - final PostgresConnection connection; - int refs = 0; - Timer? idleTimer; + Future disconnectAll() => _pool.disconnectAll(); } diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart new file mode 100644 index 00000000..2a52792c --- /dev/null +++ b/test/core/database/postgres_connection_pool_test.dart @@ -0,0 +1,284 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/postgres_connection_pool.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +ConnectionRow _row({int? id = 1}) => ConnectionRow( + id: id, + type: 'postgresql', + name: 'test', + createdAt: '2020-01-01T00:00:00Z', + ); + +/// In-memory stand-in: no TCP, tracks refcount-related calls. +class FakePostgresConnection extends PostgresConnection { + FakePostgresConnection({int id = 1}) + : super( + id: id, + name: 'fake', + host: 'localhost', + port: 5432, + database: 'postgres', + ); + + bool _connected = false; + int connectCount = 0; + int disconnectCount = 0; + int forceCloseCount = 0; + int setReadOnlyCount = 0; + bool? lastReadOnly; + + @override + bool get isConnected => _connected; + + @override + Future connect() async { + connectCount++; + _connected = true; + } + + @override + Future disconnect() async { + disconnectCount++; + _connected = false; + } + + @override + Future forceClose() async { + forceCloseCount++; + _connected = false; + } + + @override + Future setSessionReadOnly(bool readOnly) async { + setReadOnlyCount++; + lastReadOnly = readOnly; + } +} + +void main() { + group('PostgresConnectionPool keys', () { + test('different databases get different pool slots', () async { + final created = []; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(id: row.id ?? 0); + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + created.add(c); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + final a = await pool.acquire(r, database: 'db_a'); + final b = await pool.acquire(r, database: 'db_b'); + expect(identical(a.connection, b.connection), isFalse); + expect(created.length, 2); + a.release(); + b.release(); + }); + + test('readOnly vs readWrite are different keys', () async { + final created = []; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + created.add(c); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + final ro = await pool.acquire(r, database: 'postgres', mode: PgSessionMode.readOnly); + final rw = await pool.acquire(r, database: 'postgres', mode: PgSessionMode.readWrite); + expect(identical(ro.connection, rw.connection), isFalse); + expect(created.length, 2); + ro.release(); + rw.release(); + }); + + test('keyFor matches pool slot identity', () { + final pool = PostgresConnectionPool( + createAndConnect: (_, {required database, required mode}) async => + throw StateError('unused'), + ); + expect( + pool.keyFor(1, 'postgres', PgSessionMode.readOnly), + '1::postgres::readOnly', + ); + expect( + pool.keyFor(null, 'postgres', PgSessionMode.readWrite), + '0::postgres::readWrite', + ); + }); + }); + + group('PostgresConnectionPool refcount & reuse', () { + test('second acquire reuses same connection without new factory', () async { + FakePostgresConnection? sole; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + sole ??= FakePostgresConnection(); + final c = sole!; + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + final l1 = await pool.acquire(r, database: 'postgres'); + final l2 = await pool.acquire(r, database: 'postgres'); + expect(identical(l1.connection, l2.connection), isTrue); + expect((l1.connection as FakePostgresConnection).connectCount, 1); + l1.release(); + l2.release(); + }); + + test('release then quick acquire cancels idle timer and keeps connection', + () async { + FakePostgresConnection? sole; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + sole ??= FakePostgresConnection(); + final c = sole!; + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + return c; + } + + final pool = PostgresConnectionPool( + createAndConnect: factory, + idleDisposeDelay: const Duration(milliseconds: 50), + ); + final r = _row(); + + final first = await pool.acquire(r, database: 'postgres'); + first.release(); + await Future.delayed(Duration.zero); + final second = await pool.acquire(r, database: 'postgres'); + // If idle timer were not cancelled, disconnect would run after 50ms. + await Future.delayed(const Duration(milliseconds: 80)); + expect(sole!.disconnectCount, 0); + second.release(); + }); + }); + + group('PostgresConnectionPool idle dispose', () { + test('after last release, idle delay triggers disconnect', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + return c; + } + + final pool = PostgresConnectionPool( + createAndConnect: factory, + idleDisposeDelay: const Duration(milliseconds: 20), + ); + final r = _row(); + + final lease = await pool.acquire(r, database: 'postgres'); + final fake = lease.connection as FakePostgresConnection; + lease.release(); + expect(fake.disconnectCount, 0); + await Future.delayed(const Duration(milliseconds: 40)); + expect(fake.disconnectCount, 1); + }); + }); + + group('PostgresConnectionPool interrupt & disconnectAll', () { + test('interrupt removes entry and forceCloses', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + final lease = await pool.acquire(r, database: 'postgres'); + final fake = lease.connection as FakePostgresConnection; + pool.interrupt(r, database: 'postgres'); + expect(fake.forceCloseCount, 1); + lease.release(); // no-op harm: pool entry already gone + final lease2 = await pool.acquire(r, database: 'postgres'); + expect(identical(lease2.connection, fake), isFalse); + lease2.release(); + }); + + test('disconnectAll forceCloses every pooled connection', () async { + final fakes = []; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(id: row.id ?? 0); + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + fakes.add(c); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final a = await pool.acquire(_row(id: 1), database: 'postgres'); + final b = await pool.acquire(_row(id: 2), database: 'postgres'); + await pool.disconnectAll(); + expect(fakes.every((f) => f.forceCloseCount == 1), isTrue); + a.release(); + b.release(); + }); + }); + + group('PgLease idempotency', () { + test('double release does not double-decrement refs', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode == PgSessionMode.readOnly); + return c; + } + + final pool = PostgresConnectionPool( + createAndConnect: factory, + idleDisposeDelay: const Duration(milliseconds: 15), + ); + final lease = await pool.acquire(_row(), database: 'postgres'); + final fake = lease.connection as FakePostgresConnection; + lease.release(); + lease.release(); + await Future.delayed(const Duration(milliseconds: 40)); + expect(fake.disconnectCount, 1); + }); + }); +} From 291a6aa1646748eabea181faefc9d21d7f81df1b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 13:04:28 +0300 Subject: [PATCH 4/5] feat(postgresql): URI ssl/timeouts, SQL workspace tx and stmt timeout; tests Made-with: Cursor --- lib/core/database/postgres_connection.dart | 45 ++- lib/core/database/postgres_sql.dart | 38 ++ .../postgresql/postgres_sql_workspace.dart | 360 ++++++++++++++---- .../postgresql_connection_form.dart | 6 + ...postgres_connection_string_parse_test.dart | 65 ++++ .../database/postgres_connection_test.dart | 18 + test/core/database/postgres_sql_test.dart | 82 ++++ 7 files changed, 535 insertions(+), 79 deletions(-) create mode 100644 lib/core/database/postgres_sql.dart create mode 100644 test/core/database/postgres_connection_string_parse_test.dart create mode 100644 test/core/database/postgres_sql_test.dart diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index e4f50c30..d7705d5f 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -1,6 +1,9 @@ import 'package:postgres/postgres.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +// ignore: implementation_imports +import 'package:postgres/src/connection_string.dart' show parseConnectionString; + import 'postgres_metadata.dart'; /// Replaces the database in a `postgresql://` / `postgres://` URI (path or @@ -96,11 +99,29 @@ class PostgresConnection { ); } + /// [openFromUrl] already parses `sslmode`, `connect_timeout`, `query_timeout` + /// from the URI. If `sslmode` is omitted, we fall back to [useSSL] so the + /// form checkbox still applies; otherwise libpq-style URLs drive TLS mode. Future connect() async { if (_isConnected && _conn != null) return; try { if (_usesConnectionString) { - _conn = await Connection.openFromUrl(connectionString!.trim()); + final parsed = parseConnectionString(connectionString!.trim()); + final sslMode = + parsed.sslMode ?? (useSSL ? SslMode.require : SslMode.disable); + _conn = await Connection.open( + parsed.endpoints.first, + settings: ConnectionSettings( + applicationName: parsed.applicationName, + connectTimeout: + parsed.connectTimeout ?? const Duration(seconds: 10), + encoding: parsed.encoding, + replicationMode: parsed.replicationMode, + queryTimeout: parsed.queryTimeout ?? const Duration(seconds: 30), + securityContext: parsed.securityContext, + sslMode: sslMode, + ), + ); } else { _conn = await Connection.open( _buildEndpoint(), @@ -160,11 +181,29 @@ class PostgresConnection { } } - Future execute(String sql) async { + /// Runs SQL on the underlying session. [timeout] overrides + /// [ConnectionSettings.queryTimeout] for this statement (see `postgres` + /// package). + Future execute(String sql, {Duration? timeout}) async { if (!isConnected || _conn == null) { throw StateError('Not connected to PostgreSQL'); } - return _conn!.execute(sql); + return _conn!.execute(sql, timeout: timeout); + } + + /// Whether the session has an open transaction (PostgreSQL 13+). + /// Returns `null` if the server does not support the probe or an error occurs. + Future inOpenTransaction() async { + if (!isConnected || _conn == null) return null; + try { + final r = await _conn!.execute( + 'SELECT pg_current_xact_id_if_assigned() IS NOT NULL', + ); + if (r.isEmpty) return null; + return r.first[0] as bool; + } catch (_) { + return null; + } } Future> listDatabases() async { diff --git a/lib/core/database/postgres_sql.dart b/lib/core/database/postgres_sql.dart new file mode 100644 index 00000000..aec9d344 --- /dev/null +++ b/lib/core/database/postgres_sql.dart @@ -0,0 +1,38 @@ +// Helpers for ad-hoc SQL workspace (transactions, stripping comments). + +/// Removes leading whitespace and `--` line comments (not `/* */`). +String stripLeadingWhitespaceAndLineComments(String sql) { + var s = sql.trimLeft(); + while (true) { + if (s.isEmpty) return s; + if (s.startsWith('--')) { + final nl = s.indexOf('\n'); + if (nl == -1) return ''; + s = s.substring(nl + 1).trimLeft(); + continue; + } + return s; + } +} + +/// True if the first statement looks like explicit transaction control, so we +/// should not prepend `BEGIN` when autocommit is off. +bool shouldSkipImplicitBegin(String sql) { + final s = stripLeadingWhitespaceAndLineComments(sql); + if (s.isEmpty) return true; + final u = s.toUpperCase(); + + if (u.startsWith('START TRANSACTION')) return true; + if (u.startsWith('BEGIN')) return true; + if (u.startsWith('COMMIT')) return true; + if (u.startsWith('ROLLBACK')) return true; + if (u.startsWith('SAVEPOINT')) return true; + if (u.startsWith('RELEASE SAVEPOINT')) return true; + if (u.startsWith('RELEASE ')) return true; + if (u.startsWith('PREPARE TRANSACTION')) return true; + if (u.startsWith('COMMIT PREPARED')) return true; + if (u.startsWith('ROLLBACK PREPARED')) return true; + if (u.startsWith('END')) return true; + + return false; +} diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index c64f77f9..dc5d0d8b 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:postgres/postgres.dart' as pg; import 'package:querya_desktop/core/database/postgres_service.dart'; +import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -35,6 +37,16 @@ class _PostgresSqlWorkspaceState extends material.State { int? _affectedRows; String? _statusLine; + /// PostgreSQL default: each statement is its own transaction unless you use + /// `BEGIN` / `BEGIN`+implicit when autocommit is off. + bool _autocommit = true; + + /// `null` = use connection / URI [query_timeout] default from driver. + int? _queryTimeoutSeconds; + + /// `null` = unknown (older server or error). + bool? _txOpen; + Future _ensureLease() async { if (_lease != null && _lease!.connection.isConnected) return; _lease?.release(); @@ -51,6 +63,65 @@ class _PostgresSqlWorkspaceState extends material.State { _lease = lease; } + Duration? _statementTimeout() => + _queryTimeoutSeconds == null ? null : Duration(seconds: _queryTimeoutSeconds!); + + Future _refreshTxStatus() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) setState(() => _txOpen = null); + return; + } + final v = await conn.inOpenTransaction(); + if (mounted) setState(() => _txOpen = v); + } + + Future _runTxCommand(String cmd) async { + setState(() { + _running = true; + _error = null; + }); + try { + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) { + setState(() { + _error = 'Could not connect to PostgreSQL.'; + _running = false; + }); + } + return; + } + final to = _statementTimeout(); + await conn.execute(cmd, timeout: to); + if (!mounted) return; + setState(() { + _columns = []; + _rows = []; + _affectedRows = null; + _statusLine = 'OK: $cmd'; + _running = false; + }); + } on pg.ServerException catch (e) { + if (mounted) { + setState(() { + _error = e.message; + _running = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _running = false; + }); + } + } finally { + await _refreshTxStatus(); + } + } + @override void dispose() { if (_running) { @@ -66,7 +137,7 @@ class _PostgresSqlWorkspaceState extends material.State { } Future _execute() async { - final sql = _sqlController.text.trim(); + var sql = _sqlController.text.trim(); if (sql.isEmpty) return; setState(() { @@ -90,7 +161,16 @@ class _PostgresSqlWorkspaceState extends material.State { } return; } - final result = await conn.execute(sql); + + if (!_autocommit) { + final inTx = await conn.inOpenTransaction() ?? false; + if (!inTx && !shouldSkipImplicitBegin(sql)) { + sql = 'BEGIN;\n$sql'; + } + } + + final to = _statementTimeout(); + final result = await conn.execute(sql, timeout: to); if (!mounted) return; @@ -140,6 +220,8 @@ class _PostgresSqlWorkspaceState extends material.State { _running = false; }); } + } finally { + await _refreshTxStatus(); } } @@ -157,65 +239,92 @@ class _PostgresSqlWorkspaceState extends material.State { return material.LayoutBuilder( builder: (context, constraints) { final totalHeight = constraints.maxHeight; - return Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: topFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SqlToolbar( - onExecute: _running ? null : _execute, - running: _running, + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_running) _execute(); + }, + }, + child: material.Focus( + autofocus: true, + child: Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: topFlex, + child: Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _SqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + autocommit: _autocommit, + onAutocommitChanged: (v) => + setState(() => _autocommit = v), + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: (v) => + setState(() => _queryTimeoutSeconds = v), + txOpen: _txOpen, + onBegin: _running + ? null + : () => _runTxCommand('BEGIN'), + onCommit: _running + ? null + : () => _runTxCommand('COMMIT'), + onRollback: _running + ? null + : () => _runTxCommand('ROLLBACK'), + ), + const Divider(height: 1), + Expanded( + child: QueryEditorTab(controller: _sqlController), + ), + ], ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab(controller: _sqlController), + ), + _HorizontalResizeHandle( + totalHeight: totalHeight, + onDrag: (dy) { + if (totalHeight <= 0) return; + setState(() { + _topFractionState = (_topFractionState + dy / totalHeight) + .clamp(0.2, 0.85); + }); + }, + ), + Expanded( + flex: bottomFlex, + child: Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), + ), + ], ), - ], - ), - ), - _HorizontalResizeHandle( - totalHeight: totalHeight, - onDrag: (dy) { - if (totalHeight <= 0) return; - setState(() { - _topFractionState = - (_topFractionState + dy / totalHeight).clamp(0.2, 0.85); - }); - }, + ), + ], ), - Expanded( - flex: bottomFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - height: 44, - padding: - const material.EdgeInsets.symmetric(horizontal: 12), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, - ), - ), - ], - ), - ), - ], + ), ); }, ); @@ -226,37 +335,136 @@ class _SqlToolbar extends material.StatelessWidget { const _SqlToolbar({ required this.onExecute, required this.running, + required this.autocommit, + required this.onAutocommitChanged, + required this.queryTimeoutSeconds, + required this.onQueryTimeoutChanged, + required this.txOpen, + required this.onBegin, + required this.onCommit, + required this.onRollback, }); final Future Function()? onExecute; final bool running; + final bool autocommit; + final void Function(bool) onAutocommitChanged; + final int? queryTimeoutSeconds; + final void Function(int?) onQueryTimeoutChanged; + final bool? txOpen; + final void Function()? onBegin; + final void Function()? onCommit; + final void Function()? onRollback; + + String _txLabel() { + if (txOpen == null) return 'Transaction: —'; + return txOpen! ? 'Transaction: open' : 'Transaction: none'; + } @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); return material.Container( - height: 44, - padding: const material.EdgeInsets.symmetric(horizontal: 12), + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: material.BoxDecoration( color: theme.colorScheme.muted.withValues(alpha: 0.6), ), - child: material.Row( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, children: [ - Text('Query').semiBold().small(), - const Spacer(), - OutlineButton( - onPressed: onExecute, - leading: running - ? material.SizedBox( - width: 16, - height: 16, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.primary, - ), - ) - : const material.Icon(material.Icons.play_arrow_rounded, size: 18), - child: const Text('Execute (F5)'), + material.Row( + children: [ + Text('Query').semiBold().small(), + const Gap(12), + Text(_txLabel()).muted().small(), + const Spacer(), + OutlineButton( + onPressed: onExecute, + leading: running + ? material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ) + : const material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + ), + child: const Text('Execute (F5)'), + ), + ], + ), + const Gap(8), + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + Text('Autocommit').small(), + const Gap(6), + material.Switch( + value: autocommit, + onChanged: running ? null : onAutocommitChanged, + ), + ], + ), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + Text('Stmt timeout').small(), + const Gap(6), + material.DropdownButton( + value: queryTimeoutSeconds, + onChanged: running ? null : onQueryTimeoutChanged, + items: const [ + material.DropdownMenuItem( + value: null, + child: Text('Default'), + ), + material.DropdownMenuItem( + value: 30, + child: Text('30 s'), + ), + material.DropdownMenuItem( + value: 60, + child: Text('60 s'), + ), + material.DropdownMenuItem( + value: 120, + child: Text('120 s'), + ), + material.DropdownMenuItem( + value: 300, + child: Text('5 min'), + ), + material.DropdownMenuItem( + value: 600, + child: Text('10 min'), + ), + ], + ), + ], + ), + OutlineButton( + onPressed: onBegin, + child: const Text('Begin'), + ), + OutlineButton( + onPressed: onCommit, + child: const Text('Commit'), + ), + OutlineButton( + onPressed: onRollback, + child: const Text('Rollback'), + ), + ], ), ], ), diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 36188f89..1f9ac52a 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -250,6 +250,12 @@ class _PostgresConnectionFormContentState Text( 'If set, overrides Host / Port / Database below.', ).muted().small(), + const Gap(4), + Text( + 'Supported query params include sslmode (disable, require, ' + 'verify-ca, verify-full), connect_timeout and query_timeout ' + '(seconds). If sslmode is omitted, Use SSL/TLS below applies.', + ).muted().small(), const Gap(8), TextField( controller: _connectionStringController, diff --git a/test/core/database/postgres_connection_string_parse_test.dart b/test/core/database/postgres_connection_string_parse_test.dart new file mode 100644 index 00000000..96e073ac --- /dev/null +++ b/test/core/database/postgres_connection_string_parse_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:postgres/postgres.dart' show SslMode; +// ignore: implementation_imports +import 'package:postgres/src/connection_string.dart' show parseConnectionString; + +/// Ensures libpq-style URI params match what we document for [PostgresConnection.connect]. +void main() { + group('parseConnectionString (postgres package)', () { + test('sslmode=require maps to SslMode.require', () { + final p = parseConnectionString( + 'postgresql://user:pass@localhost:5432/mydb?sslmode=require', + ); + expect(p.sslMode, SslMode.require); + }); + + test('sslmode=disable maps to SslMode.disable', () { + final p = parseConnectionString( + 'postgresql://localhost/postgres?sslmode=disable', + ); + expect(p.sslMode, SslMode.disable); + }); + + test('sslmode=verify-full maps to SslMode.verifyFull', () { + final p = parseConnectionString( + 'postgresql://localhost/postgres?sslmode=verify-full', + ); + expect(p.sslMode, SslMode.verifyFull); + }); + + test('sslmode=verify-ca maps to SslMode.verifyFull', () { + final p = parseConnectionString( + 'postgresql://localhost/postgres?sslmode=verify-ca', + ); + expect(p.sslMode, SslMode.verifyFull); + }); + + test('omitted sslmode yields null (caller may merge with useSSL)', () { + final p = parseConnectionString('postgresql://localhost/postgres'); + expect(p.sslMode, isNull); + }); + + test('query_timeout sets duration in seconds', () { + final p = parseConnectionString( + 'postgresql://localhost/postgres?query_timeout=120', + ); + expect(p.queryTimeout, const Duration(seconds: 120)); + }); + + test('connect_timeout sets duration in seconds', () { + final p = parseConnectionString( + 'postgresql://localhost/postgres?connect_timeout=15', + ); + expect(p.connectTimeout, const Duration(seconds: 15)); + }); + + test('invalid sslmode throws', () { + expect( + () => parseConnectionString( + 'postgresql://localhost/postgres?sslmode=prefer', + ), + throwsArgumentError, + ); + }); + }); +} diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index 083ffcc4..136d8bf7 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -149,6 +149,24 @@ void main() { ); }); + test('execute with timeout throws StateError when not connected', () { + expect( + () => conn.execute( + 'SELECT 1', + timeout: const Duration(seconds: 1), + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to PostgreSQL'), + )), + ); + }); + + test('inOpenTransaction returns null when not connected', () async { + expect(await conn.inOpenTransaction(), isNull); + }); + test('listDatabases throws StateError', () { expect( () => conn.listDatabases(), diff --git a/test/core/database/postgres_sql_test.dart b/test/core/database/postgres_sql_test.dart new file mode 100644 index 00000000..6b7c70cc --- /dev/null +++ b/test/core/database/postgres_sql_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/postgres_sql.dart'; + +void main() { + group('stripLeadingWhitespaceAndLineComments', () { + test('strips leading line comments', () { + expect( + stripLeadingWhitespaceAndLineComments('-- c\nBEGIN'), + 'BEGIN', + ); + }); + + test('strips multiple line comments', () { + expect( + stripLeadingWhitespaceAndLineComments('-- a\n-- b\nSELECT 1'), + 'SELECT 1', + ); + }); + + test('empty after only comments becomes empty string', () { + expect(stripLeadingWhitespaceAndLineComments('-- only'), ''); + }); + + test('leading whitespace then SQL', () { + expect( + stripLeadingWhitespaceAndLineComments(' \n SELECT 1'), + 'SELECT 1', + ); + }); + }); + + group('shouldSkipImplicitBegin', () { + test('detects BEGIN', () { + expect(shouldSkipImplicitBegin('BEGIN'), isTrue); + expect(shouldSkipImplicitBegin(' begin '), isTrue); + }); + + test('detects BEGIN WORK', () { + expect(shouldSkipImplicitBegin('BEGIN WORK'), isTrue); + }); + + test('detects START TRANSACTION', () { + expect(shouldSkipImplicitBegin('START TRANSACTION'), isTrue); + }); + + test('detects COMMIT and ROLLBACK', () { + expect(shouldSkipImplicitBegin('COMMIT'), isTrue); + expect(shouldSkipImplicitBegin('ROLLBACK'), isTrue); + }); + + test('detects SAVEPOINT and RELEASE SAVEPOINT', () { + expect(shouldSkipImplicitBegin('SAVEPOINT sp'), isTrue); + expect(shouldSkipImplicitBegin('RELEASE SAVEPOINT sp'), isTrue); + }); + + test('detects PREPARE / COMMIT PREPARED', () { + expect(shouldSkipImplicitBegin('PREPARE TRANSACTION'), isTrue); + expect(shouldSkipImplicitBegin('COMMIT PREPARED'), isTrue); + expect(shouldSkipImplicitBegin('ROLLBACK PREPARED'), isTrue); + }); + + test('detects END', () { + expect(shouldSkipImplicitBegin('END'), isTrue); + }); + + test('allows plain SELECT', () { + expect(shouldSkipImplicitBegin('SELECT 1'), isFalse); + }); + + test('after line comments, first statement is used', () { + expect( + shouldSkipImplicitBegin('-- note\nSELECT 1'), + isFalse, + ); + }); + + test('allows INSERT/UPDATE without implicit begin skip', () { + expect(shouldSkipImplicitBegin('INSERT INTO t VALUES (1)'), isFalse); + expect(shouldSkipImplicitBegin('UPDATE t SET x = 1'), isFalse); + }); + }); +} From 776a7d3f71c85a6180f263f686deab07abbf7027 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 21 Mar 2026 13:08:11 +0300 Subject: [PATCH 5/5] fix --- lib/features/postgresql/postgres_sql_workspace.dart | 8 ++++---- lib/features/postgresql/postgres_workspace_home.dart | 2 +- lib/features/postgresql/postgresql_connection_form.dart | 4 ++-- test/core/database/postgres_connection_pool_test.dart | 3 +-- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index dc5d0d8b..3503ed54 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -306,7 +306,7 @@ class _PostgresSqlWorkspaceState extends material.State { color: theme.colorScheme.muted.withValues(alpha: 0.6), ), alignment: material.Alignment.centerLeft, - child: Text('Data Output').semiBold().small(), + child: const Text('Data Output').semiBold().small(), ), const Divider(height: 1), Expanded( @@ -375,7 +375,7 @@ class _SqlToolbar extends material.StatelessWidget { children: [ material.Row( children: [ - Text('Query').semiBold().small(), + const Text('Query').semiBold().small(), const Gap(12), Text(_txLabel()).muted().small(), const Spacer(), @@ -407,7 +407,7 @@ class _SqlToolbar extends material.StatelessWidget { material.Row( mainAxisSize: material.MainAxisSize.min, children: [ - Text('Autocommit').small(), + const Text('Autocommit').small(), const Gap(6), material.Switch( value: autocommit, @@ -418,7 +418,7 @@ class _SqlToolbar extends material.StatelessWidget { material.Row( mainAxisSize: material.MainAxisSize.min, children: [ - Text('Stmt timeout').small(), + const Text('Stmt timeout').small(), const Gap(6), material.DropdownButton( value: queryTimeoutSeconds, diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index 099f7505..b8f553f9 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -35,7 +35,7 @@ class _PostgresWorkspaceHomeState extends material.State ), child: material.Row( children: [ - Text('PostgreSQL').semiBold().small(), + const Text('PostgreSQL').semiBold().small(), const Spacer(), ...List.generate(2, (i) { final labels = ['Server', 'SQL']; diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 1f9ac52a..79a7b779 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -247,11 +247,11 @@ class _PostgresConnectionFormContentState const Gap(16), const Text('Connection URI (optional)').small().semiBold(), const Gap(4), - Text( + const Text( 'If set, overrides Host / Port / Database below.', ).muted().small(), const Gap(4), - Text( + const Text( 'Supported query params include sslmode (disable, require, ' 'verify-ca, verify-full), connect_timeout and query_timeout ' '(seconds). If sslmode is omitted, Use SSL/TLS below applies.', diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart index 2a52792c..ff41f7e9 100644 --- a/test/core/database/postgres_connection_pool_test.dart +++ b/test/core/database/postgres_connection_pool_test.dart @@ -12,9 +12,8 @@ ConnectionRow _row({int? id = 1}) => ConnectionRow( /// In-memory stand-in: no TCP, tracks refcount-related calls. class FakePostgresConnection extends PostgresConnection { - FakePostgresConnection({int id = 1}) + FakePostgresConnection({super.id = 1}) : super( - id: id, name: 'fake', host: 'localhost', port: 5432,