Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flutter-plugins-dependencies
Original file line number Diff line number Diff line change
@@ -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}}
{"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}}
130 changes: 123 additions & 7 deletions lib/core/database/postgres_connection.dart
Original file line number Diff line number Diff line change
@@ -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<String, String>.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({
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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<void> 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;
Expand All @@ -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<void> 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<void> setSessionReadOnly(bool readOnly) async {
if (!isConnected) return;
await execute(
readOnly
? 'SET default_transaction_read_only = ON'
: 'SET default_transaction_read_only = OFF',
);
}

Future<bool> testConnection() async {
try {
await connect();
Expand All @@ -88,11 +181,29 @@ class PostgresConnection {
}
}

Future<Result> execute(String sql) async {
/// Runs SQL on the underlying session. [timeout] overrides
/// [ConnectionSettings.queryTimeout] for this statement (see `postgres`
/// package).
Future<Result> 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<bool?> 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<List<String>> listDatabases() async {
Expand Down Expand Up @@ -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<PostgresConnection> connectToDatabase(String dbName) async {
final cs = connectionString;
final newCs = (cs != null && cs.trim().isNotEmpty)
? replaceDatabaseInConnectionString(cs, dbName)
: null;
return PostgresConnection(
id: id,
name: name,
Expand All @@ -273,6 +388,7 @@ class PostgresConnection {
password: password,
database: dbName,
useSSL: useSSL,
connectionString: newCs,
);
}

Expand Down
131 changes: 131 additions & 0 deletions lib/core/database/postgres_connection_pool.dart
Original file line number Diff line number Diff line change
@@ -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<PostgresConnection> 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<String, _PoolEntry> _pool = {};

String keyFor(int? id, String database, PgSessionMode mode) =>
'${id ?? 0}::$database::${mode.name}';

/// Obtains a connected [PostgresConnection], incrementing the pool ref-count.
Future<PgLease> 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<void> 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;
}
55 changes: 55 additions & 0 deletions lib/core/database/postgres_service.dart
Original file line number Diff line number Diff line change
@@ -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<PostgresConnection> _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<PgLease> 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<void> disconnectAll() => _pool.disconnectAll();
}
Loading
Loading