Skip to content

Commit d409e28

Browse files
Merge pull request #21 from QueryaHub/postgres-ench
Postgres ench
2 parents 2c5017c + 776a7d3 commit d409e28

22 files changed

Lines changed: 1847 additions & 210 deletions

‎.flutter-plugins-dependencies‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +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}}
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:40:42.216584","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}}

‎lib/core/database/postgres_connection.dart‎

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
11
import 'package:postgres/postgres.dart';
2+
import 'package:querya_desktop/core/storage/local_db.dart';
3+
4+
// ignore: implementation_imports
5+
import 'package:postgres/src/connection_string.dart' show parseConnectionString;
26

37
import 'postgres_metadata.dart';
48

9+
/// Replaces the database in a `postgresql://` / `postgres://` URI (path or
10+
/// `database=` query param). Used when switching DB while keeping URI auth/SSL.
11+
String replaceDatabaseInConnectionString(
12+
String connectionString,
13+
String newDatabase,
14+
) {
15+
final uri = Uri.parse(connectionString.trim());
16+
if (uri.scheme != 'postgres' && uri.scheme != 'postgresql') {
17+
throw ArgumentError(
18+
'Invalid connection string scheme: ${uri.scheme}. '
19+
'Expected "postgresql" or "postgres".',
20+
);
21+
}
22+
final params = Map<String, String>.from(uri.queryParameters);
23+
if (params.containsKey('database')) {
24+
params['database'] = newDatabase;
25+
return uri.replace(queryParameters: params).toString();
26+
}
27+
if (uri.pathSegments.isNotEmpty && uri.pathSegments.first.isNotEmpty) {
28+
return uri.replace(path: '/$newDatabase').toString();
29+
}
30+
params['database'] = newDatabase;
31+
return uri.replace(queryParameters: params).toString();
32+
}
33+
534
/// PostgreSQL connection using the pure-Dart `postgres` package.
635
class PostgresConnection {
736
PostgresConnection({
@@ -16,6 +45,24 @@ class PostgresConnection {
1645
this.connectionString,
1746
});
1847

48+
/// Builds a connection from a saved [ConnectionRow] (host/port or URI).
49+
factory PostgresConnection.fromConnectionRow(
50+
ConnectionRow row, {
51+
String? database,
52+
}) {
53+
return PostgresConnection(
54+
id: row.id ?? 0,
55+
name: row.name,
56+
host: row.host ?? 'localhost',
57+
port: row.port ?? 5432,
58+
username: row.username,
59+
password: row.password,
60+
database: database ?? row.databaseName ?? 'postgres',
61+
useSSL: row.useSSL,
62+
connectionString: row.connectionString,
63+
);
64+
}
65+
1966
final int id;
2067
final String name;
2168
final String host;
@@ -31,6 +78,9 @@ class PostgresConnection {
3178

3279
bool get isConnected => _isConnected && _conn != null;
3380

81+
bool get _usesConnectionString =>
82+
connectionString != null && connectionString!.trim().isNotEmpty;
83+
3484
Endpoint _buildEndpoint() {
3585
return Endpoint(
3686
host: host,
@@ -49,13 +99,35 @@ class PostgresConnection {
4999
);
50100
}
51101

102+
/// [openFromUrl] already parses `sslmode`, `connect_timeout`, `query_timeout`
103+
/// from the URI. If `sslmode` is omitted, we fall back to [useSSL] so the
104+
/// form checkbox still applies; otherwise libpq-style URLs drive TLS mode.
52105
Future<void> connect() async {
53106
if (_isConnected && _conn != null) return;
54107
try {
55-
_conn = await Connection.open(
56-
_buildEndpoint(),
57-
settings: _buildSettings(),
58-
);
108+
if (_usesConnectionString) {
109+
final parsed = parseConnectionString(connectionString!.trim());
110+
final sslMode =
111+
parsed.sslMode ?? (useSSL ? SslMode.require : SslMode.disable);
112+
_conn = await Connection.open(
113+
parsed.endpoints.first,
114+
settings: ConnectionSettings(
115+
applicationName: parsed.applicationName,
116+
connectTimeout:
117+
parsed.connectTimeout ?? const Duration(seconds: 10),
118+
encoding: parsed.encoding,
119+
replicationMode: parsed.replicationMode,
120+
queryTimeout: parsed.queryTimeout ?? const Duration(seconds: 30),
121+
securityContext: parsed.securityContext,
122+
sslMode: sslMode,
123+
),
124+
);
125+
} else {
126+
_conn = await Connection.open(
127+
_buildEndpoint(),
128+
settings: _buildSettings(),
129+
);
130+
}
59131
_isConnected = true;
60132
} catch (e) {
61133
_isConnected = false;
@@ -73,6 +145,27 @@ class PostgresConnection {
73145
} catch (_) {}
74146
}
75147

148+
/// Drops the TCP session immediately (kills pending client I/O). Used when
149+
/// cancelling a long query or [PostgresService.interrupt].
150+
Future<void> forceClose() async {
151+
_isConnected = false;
152+
final c = _conn;
153+
_conn = null;
154+
try {
155+
await c?.close(force: true);
156+
} catch (_) {}
157+
}
158+
159+
/// Session-level default for transactions (browse vs SQL editor).
160+
Future<void> setSessionReadOnly(bool readOnly) async {
161+
if (!isConnected) return;
162+
await execute(
163+
readOnly
164+
? 'SET default_transaction_read_only = ON'
165+
: 'SET default_transaction_read_only = OFF',
166+
);
167+
}
168+
76169
Future<bool> testConnection() async {
77170
try {
78171
await connect();
@@ -88,11 +181,29 @@ class PostgresConnection {
88181
}
89182
}
90183

91-
Future<Result> execute(String sql) async {
184+
/// Runs SQL on the underlying session. [timeout] overrides
185+
/// [ConnectionSettings.queryTimeout] for this statement (see `postgres`
186+
/// package).
187+
Future<Result> execute(String sql, {Duration? timeout}) async {
92188
if (!isConnected || _conn == null) {
93189
throw StateError('Not connected to PostgreSQL');
94190
}
95-
return _conn!.execute(sql);
191+
return _conn!.execute(sql, timeout: timeout);
192+
}
193+
194+
/// Whether the session has an open transaction (PostgreSQL 13+).
195+
/// Returns `null` if the server does not support the probe or an error occurs.
196+
Future<bool?> inOpenTransaction() async {
197+
if (!isConnected || _conn == null) return null;
198+
try {
199+
final r = await _conn!.execute(
200+
'SELECT pg_current_xact_id_if_assigned() IS NOT NULL',
201+
);
202+
if (r.isEmpty) return null;
203+
return r.first[0] as bool;
204+
} catch (_) {
205+
return null;
206+
}
96207
}
97208

98209
Future<List<String>> listDatabases() async {
@@ -262,8 +373,12 @@ class PostgresConnection {
262373
return stats;
263374
}
264375

265-
/// Connect to a specific database (creates a new connection).
376+
/// Connect to a specific database (creates a new connection config).
266377
Future<PostgresConnection> connectToDatabase(String dbName) async {
378+
final cs = connectionString;
379+
final newCs = (cs != null && cs.trim().isNotEmpty)
380+
? replaceDatabaseInConnectionString(cs, dbName)
381+
: null;
267382
return PostgresConnection(
268383
id: id,
269384
name: name,
@@ -273,6 +388,7 @@ class PostgresConnection {
273388
password: password,
274389
database: dbName,
275390
useSSL: useSSL,
391+
connectionString: newCs,
276392
);
277393
}
278394

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import 'dart:async';
2+
3+
import 'package:querya_desktop/core/database/postgres_connection.dart';
4+
import 'package:querya_desktop/core/storage/local_db.dart';
5+
6+
/// Session policy for pooled connections: browse-only vs ad-hoc SQL (writes).
7+
enum PgSessionMode {
8+
/// `SET default_transaction_read_only = ON` after connect.
9+
readOnly,
10+
11+
/// Read-write session (SQL editor, probes that need catalog writes — rare).
12+
readWrite,
13+
}
14+
15+
/// Creates a connected [PostgresConnection] for the pool (real or fake in tests).
16+
typedef PostgresPoolConnectionFactory = Future<PostgresConnection> Function(
17+
ConnectionRow row, {
18+
required String database,
19+
required PgSessionMode mode,
20+
});
21+
22+
/// Lease for a pooled [PostgresConnection]. Call [release] when the UI is done
23+
/// (typically in [State.dispose]).
24+
class PgLease {
25+
PgLease._(this._pool, this._key, this.connection);
26+
27+
final PostgresConnectionPool _pool;
28+
final String _key;
29+
final PostgresConnection connection;
30+
31+
bool _released = false;
32+
33+
/// Returns the connection to the pool (ref-count / idle dispose).
34+
void release() {
35+
if (_released) return;
36+
_released = true;
37+
_pool._release(_key);
38+
}
39+
}
40+
41+
/// Pooled PostgreSQL connections keyed by `(connection id, database, session mode)`.
42+
///
43+
/// Use [interrupt] to force-close a pooled connection (e.g. user navigates away
44+
/// while a query is still running); the next [acquire] opens a new connection.
45+
class PostgresConnectionPool {
46+
PostgresConnectionPool({
47+
required this.createAndConnect,
48+
this.idleDisposeDelay = defaultIdleDisposeDelay,
49+
});
50+
51+
static const Duration defaultIdleDisposeDelay = Duration(seconds: 8);
52+
53+
final PostgresPoolConnectionFactory createAndConnect;
54+
final Duration idleDisposeDelay;
55+
56+
final Map<String, _PoolEntry> _pool = {};
57+
58+
String keyFor(int? id, String database, PgSessionMode mode) =>
59+
'${id ?? 0}::$database::${mode.name}';
60+
61+
/// Obtains a connected [PostgresConnection], incrementing the pool ref-count.
62+
Future<PgLease> acquire(
63+
ConnectionRow row, {
64+
required String database,
65+
PgSessionMode mode = PgSessionMode.readOnly,
66+
}) async {
67+
final k = keyFor(row.id, database, mode);
68+
var entry = _pool[k];
69+
if (entry != null) {
70+
entry.idleTimer?.cancel();
71+
entry.idleTimer = null;
72+
entry.refs++;
73+
if (!entry.connection.isConnected) {
74+
await entry.connection.connect();
75+
await entry.connection.setSessionReadOnly(mode == PgSessionMode.readOnly);
76+
}
77+
return PgLease._(this, k, entry.connection);
78+
}
79+
80+
final conn = await createAndConnect(row, database: database, mode: mode);
81+
entry = _PoolEntry(conn)..refs = 1;
82+
_pool[k] = entry;
83+
return PgLease._(this, k, conn);
84+
}
85+
86+
void _release(String k) {
87+
final entry = _pool[k];
88+
if (entry == null) return;
89+
entry.refs--;
90+
if (entry.refs > 0) return;
91+
entry.idleTimer?.cancel();
92+
entry.idleTimer = Timer(idleDisposeDelay, () {
93+
final e = _pool[k];
94+
if (e == null || e.refs > 0) return;
95+
e.idleTimer = null;
96+
unawaited(e.connection.disconnect());
97+
_pool.remove(k);
98+
});
99+
}
100+
101+
/// Force-closes the pooled connection for this key (drops client-side I/O;
102+
/// server may still finish the query until it notices disconnect).
103+
void interrupt(
104+
ConnectionRow row, {
105+
required String database,
106+
PgSessionMode mode = PgSessionMode.readOnly,
107+
}) {
108+
final k = keyFor(row.id, database, mode);
109+
final entry = _pool.remove(k);
110+
if (entry == null) return;
111+
entry.idleTimer?.cancel();
112+
unawaited(entry.connection.forceClose());
113+
}
114+
115+
/// Closes all pooled connections (e.g. app shutdown).
116+
Future<void> disconnectAll() async {
117+
for (final entry in _pool.values) {
118+
entry.idleTimer?.cancel();
119+
await entry.connection.forceClose();
120+
}
121+
_pool.clear();
122+
}
123+
}
124+
125+
class _PoolEntry {
126+
_PoolEntry(this.connection);
127+
128+
final PostgresConnection connection;
129+
int refs = 0;
130+
Timer? idleTimer;
131+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import 'package:querya_desktop/core/database/postgres_connection.dart';
2+
import 'package:querya_desktop/core/database/postgres_connection_pool.dart';
3+
import 'package:querya_desktop/core/storage/local_db.dart';
4+
5+
export 'postgres_connection_pool.dart'
6+
show PgLease, PgSessionMode, PostgresConnectionPool;
7+
8+
Future<PostgresConnection> _defaultCreateAndConnect(
9+
ConnectionRow row, {
10+
required String database,
11+
required PgSessionMode mode,
12+
}) async {
13+
final conn = PostgresConnection.fromConnectionRow(row, database: database);
14+
await conn.connect();
15+
await conn.setSessionReadOnly(mode == PgSessionMode.readOnly);
16+
return conn;
17+
}
18+
19+
/// Global PostgreSQL connection pool (singleton).
20+
///
21+
/// For tests of pool logic without a server, use [PostgresConnectionPool]
22+
/// with a fake [PostgresPoolConnectionFactory].
23+
class PostgresService {
24+
PostgresService._()
25+
: _pool = PostgresConnectionPool(
26+
createAndConnect: _defaultCreateAndConnect,
27+
);
28+
29+
static final PostgresService instance = PostgresService._();
30+
31+
final PostgresConnectionPool _pool;
32+
33+
/// Same as [PostgresConnectionPool.defaultIdleDisposeDelay].
34+
static const Duration idleDisposeDelay =
35+
PostgresConnectionPool.defaultIdleDisposeDelay;
36+
37+
/// Obtains a connected [PostgresConnection], incrementing the pool ref-count.
38+
Future<PgLease> acquire(
39+
ConnectionRow row, {
40+
required String database,
41+
PgSessionMode mode = PgSessionMode.readOnly,
42+
}) =>
43+
_pool.acquire(row, database: database, mode: mode);
44+
45+
/// Force-closes the pooled connection for this key.
46+
void interrupt(
47+
ConnectionRow row, {
48+
required String database,
49+
PgSessionMode mode = PgSessionMode.readOnly,
50+
}) =>
51+
_pool.interrupt(row, database: database, mode: mode);
52+
53+
/// Closes all pooled connections (e.g. app shutdown).
54+
Future<void> disconnectAll() => _pool.disconnectAll();
55+
}

0 commit comments

Comments
 (0)