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/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index c678e788..d7705d5f 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -1,7 +1,36 @@ 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 +/// `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 +45,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 +78,9 @@ class PostgresConnection { bool get isConnected => _isConnected && _conn != null; + bool get _usesConnectionString => + connectionString != null && connectionString!.trim().isNotEmpty; + Endpoint _buildEndpoint() { return Endpoint( host: host, @@ -49,13 +99,35 @@ 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 { - _conn = await Connection.open( - _buildEndpoint(), - settings: _buildSettings(), - ); + if (_usesConnectionString) { + 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(), + settings: _buildSettings(), + ); + } _isConnected = true; } catch (e) { _isConnected = false; @@ -73,6 +145,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(); @@ -88,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 { @@ -262,8 +373,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 +388,7 @@ class PostgresConnection { password: password, database: dbName, useSSL: useSSL, + connectionString: newCs, ); } 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 new file mode 100644 index 00000000..820aeeeb --- /dev/null +++ b/lib/core/database/postgres_service.dart @@ -0,0 +1,55 @@ +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'; + +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; +} + +/// Global PostgreSQL connection pool (singleton). +/// +/// For tests of pool logic without a server, use [PostgresConnectionPool] +/// with a fake [PostgresPoolConnectionFactory]. +class PostgresService { + PostgresService._() + : _pool = PostgresConnectionPool( + createAndConnect: _defaultCreateAndConnect, + ); + + static final PostgresService instance = PostgresService._(); + + final PostgresConnectionPool _pool; + + /// 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, + }) => + _pool.acquire(row, database: database, mode: mode); + + /// Force-closes the pooled connection for this key. + void interrupt( + ConnectionRow row, { + required String database, + PgSessionMode mode = PgSessionMode.readOnly, + }) => + _pool.interrupt(row, database: database, mode: mode); + + /// Closes all pooled connections (e.g. app shutdown). + Future disconnectAll() => _pool.disconnectAll(); +} 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/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..3503ed54 --- /dev/null +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -0,0 +1,499 @@ +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'; +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; + + /// 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(); + _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; + } + + 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) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.connectionRow.databaseName ?? 'postgres', + mode: PgSessionMode.readWrite, + ); + } + _lease?.release(); + _sqlController.dispose(); + super.dispose(); + } + + Future _execute() async { + var 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; + } + + 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; + + 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; + }); + } + } finally { + await _refreshTxStatus(); + } + } + + 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 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), + ), + ], + ), + ), + _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: const 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, + 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( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + const 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: [ + const Text('Autocommit').small(), + const Gap(6), + material.Switch( + value: autocommit, + onChanged: running ? null : onAutocommitChanged, + ), + ], + ), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + const 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'), + ), + ], + ), + ], + ), + ); + } +} + +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..b8f553f9 --- /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: [ + const 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..79a7b779 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,25 @@ class _PostgresConnectionFormContentState placeholder: const Text('My PostgreSQL Server'), ), const Gap(16), + const Text('Connection URI (optional)').small().semiBold(), + const Gap(4), + const Text( + 'If set, overrides Host / Port / Database below.', + ).muted().small(), + const Gap(4), + 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.', + ).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/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart new file mode 100644 index 00000000..ff41f7e9 --- /dev/null +++ b/test/core/database/postgres_connection_pool_test.dart @@ -0,0 +1,283 @@ +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({super.id = 1}) + : super( + 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); + }); + }); +} 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 73387b3c..136d8bf7 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', () { @@ -139,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(), @@ -385,7 +413,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 +421,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 +433,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'))); + }); + }); } 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); + }); + }); +} 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);