diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e702b..a9681b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 3.5.7. + +- Supporting URL-based connection-string specification in `Connection.openFromUrl` and `Pool.withUrl`. (Note: feature and supported settings is considered experimental.) + ## 3.5.6 - Accept `null` values as part of the binary List encodings. diff --git a/lib/postgres.dart b/lib/postgres.dart index 77ee763..304317b 100644 --- a/lib/postgres.dart +++ b/lib/postgres.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import 'package:postgres/src/connection_string.dart'; import 'package:postgres/src/v3/connection_info.dart'; import 'package:stream_channel/stream_channel.dart'; @@ -230,6 +231,26 @@ abstract class Connection implements Session, SessionExecutor { connectionSettings: settings); } + /// Open a new connection where the endpoint and the settings are encoded as an URL as + /// `postgresql://[userspec@][hostspec][/dbname][?paramspec]` + /// + /// Note: Only a single endpoint is supported. + /// Note: Only a subset of settings can be set with parameters. + static Future openFromUrl(String connectionString) async { + final parsed = parseConnectionString(connectionString); + return open( + parsed.endpoint, + settings: ConnectionSettings( + applicationName: parsed.applicationName, + connectTimeout: parsed.connectTimeout, + encoding: parsed.encoding, + replicationMode: parsed.replicationMode, + securityContext: parsed.securityContext, + sslMode: parsed.sslMode, + ), + ); + } + ConnectionInfo get info; Channels get channels; diff --git a/lib/src/connection_string.dart b/lib/src/connection_string.dart new file mode 100644 index 0000000..4c3f721 --- /dev/null +++ b/lib/src/connection_string.dart @@ -0,0 +1,198 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../postgres.dart'; + +({ + Endpoint endpoint, + String? applicationName, + Duration? connectTimeout, + Encoding? encoding, + ReplicationMode? replicationMode, + SecurityContext? securityContext, + SslMode? sslMode, +}) parseConnectionString(String connectionString) { + final uri = Uri.parse(connectionString); + + if (uri.scheme != 'postgresql' && uri.scheme != 'postgres') { + throw ArgumentError( + 'Invalid connection string scheme: ${uri.scheme}. Expected "postgresql" or "postgres".'); + } + + final host = uri.host.isEmpty ? 'localhost' : uri.host; + final port = uri.port == 0 ? 5432 : uri.port; + final database = uri.pathSegments.firstOrNull ?? 'postgres'; + final username = uri.userInfo.isEmpty ? null : _parseUsername(uri.userInfo); + final password = uri.userInfo.isEmpty ? null : _parsePassword(uri.userInfo); + + final validParams = { + 'sslmode', + 'sslcert', + 'sslkey', + 'sslrootcert', + 'connect_timeout', + 'application_name', + 'client_encoding', + 'replication' + }; + + final params = uri.queryParameters; + for (final key in params.keys) { + if (!validParams.contains(key)) { + throw ArgumentError('Unrecognized connection parameter: $key'); + } + } + + SslMode? sslMode; + if (params.containsKey('sslmode')) { + switch (params['sslmode']) { + case 'disable': + sslMode = SslMode.disable; + break; + case 'require': + sslMode = SslMode.require; + break; + case 'verify-ca': + case 'verify-full': + sslMode = SslMode.verifyFull; + break; + default: + throw ArgumentError( + 'Invalid sslmode value: ${params['sslmode']}. Expected: disable, require, verify-ca, verify-full'); + } + } + + SecurityContext? securityContext; + if (params.containsKey('sslcert') || + params.containsKey('sslkey') || + params.containsKey('sslrootcert')) { + try { + securityContext = _createSecurityContext( + certPath: params['sslcert'], + keyPath: params['sslkey'], + caPath: params['sslrootcert'], + ); + } catch (e) { + // re-throw with more context about connection string parsing + throw ArgumentError('SSL configuration error in connection string: $e'); + } + } + + Duration? connectTimeout; + if (params.containsKey('connect_timeout')) { + final timeoutSeconds = int.tryParse(params['connect_timeout']!); + if (timeoutSeconds == null || timeoutSeconds <= 0) { + throw ArgumentError( + 'Invalid connect_timeout value: ${params['connect_timeout']}. Expected positive integer.'); + } + connectTimeout = Duration(seconds: timeoutSeconds); + } + + final applicationName = params['application_name']; + + Encoding? encoding; + if (params.containsKey('client_encoding')) { + switch (params['client_encoding']?.toUpperCase()) { + case 'UTF8': + case 'UTF-8': + encoding = utf8; + break; + case 'LATIN1': + case 'ISO-8859-1': + encoding = latin1; + break; + default: + throw ArgumentError( + 'Unsupported client_encoding: ${params['client_encoding']}. Supported: UTF8, LATIN1'); + } + } + + ReplicationMode? replicationMode; + if (params.containsKey('replication')) { + switch (params['replication']) { + case 'database': + replicationMode = ReplicationMode.logical; + break; + case 'true': + case 'physical': + replicationMode = ReplicationMode.physical; + break; + case 'false': + case 'no_select': + replicationMode = ReplicationMode.none; + break; + default: + throw ArgumentError( + 'Invalid replication value: ${params['replication']}. Expected: database, true, physical, false, no_select'); + } + } + + final endpoint = Endpoint( + host: host, + port: port, + database: database, + username: username, + password: password, + ); + + return ( + endpoint: endpoint, + sslMode: sslMode, + securityContext: securityContext, + connectTimeout: connectTimeout, + applicationName: applicationName, + encoding: encoding, + replicationMode: replicationMode, + ); +} + +String? _parseUsername(String userInfo) { + final colonIndex = userInfo.indexOf(':'); + if (colonIndex == -1) { + return Uri.decodeComponent(userInfo); + } + return Uri.decodeComponent(userInfo.substring(0, colonIndex)); +} + +String? _parsePassword(String userInfo) { + final colonIndex = userInfo.indexOf(':'); + if (colonIndex == -1) { + return null; + } + return Uri.decodeComponent(userInfo.substring(colonIndex + 1)); +} + +SecurityContext _createSecurityContext({ + String? certPath, + String? keyPath, + String? caPath, +}) { + final context = SecurityContext(); + + if (certPath != null) { + try { + context.useCertificateChain(certPath); + } catch (e) { + throw ArgumentError('Failed to load SSL certificate from $certPath: $e'); + } + } + + if (keyPath != null) { + try { + context.usePrivateKey(keyPath); + } catch (e) { + throw ArgumentError('Failed to load SSL private key from $keyPath: $e'); + } + } + + if (caPath != null) { + try { + context.setTrustedCertificates(caPath); + } catch (e) { + throw ArgumentError( + 'Failed to load SSL CA certificates from $caPath: $e'); + } + } + + return context; +} diff --git a/lib/src/pool/pool_api.dart b/lib/src/pool/pool_api.dart index 4f4b33b..c617ed1 100644 --- a/lib/src/pool/pool_api.dart +++ b/lib/src/pool/pool_api.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'package:postgres/src/connection_string.dart'; import '../../postgres.dart'; import 'pool_impl.dart'; @@ -70,6 +71,26 @@ abstract class Pool implements Session, SessionExecutor { }) => PoolImplementation(roundRobinSelector(endpoints), settings); + /// Creates a new pool where the endpoint and the settings are encoded as an URL as + /// `postgresql://[userspec@][hostspec][/dbname][?paramspec]` + /// + /// Note: Only a single endpoint is supported for now. + /// Note: Only a subset of settings can be set with parameters. + factory Pool.withUrl(String connectionString) { + final parsed = parseConnectionString(connectionString); + return PoolImplementation( + roundRobinSelector([parsed.endpoint]), + PoolSettings( + applicationName: parsed.applicationName, + connectTimeout: parsed.connectTimeout, + encoding: parsed.encoding, + replicationMode: parsed.replicationMode, + securityContext: parsed.securityContext, + sslMode: parsed.sslMode, + ), + ); + } + /// Acquires a connection from this pool, opening a new one if necessary, and /// calls [fn] with it. /// diff --git a/lib/src/pool/pool_impl.dart b/lib/src/pool/pool_impl.dart index e70ee0a..669fbeb 100644 --- a/lib/src/pool/pool_impl.dart +++ b/lib/src/pool/pool_impl.dart @@ -145,7 +145,7 @@ class PoolImplementation implements Pool { // one. connection = await _selectOrCreate( selection.endpoint, - ResolvedConnectionSettings(settings, this._settings), + ResolvedConnectionSettings(settings, _settings), ); sw.start(); diff --git a/pubspec.yaml b/pubspec.yaml index 8559026..c7a91e1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: postgres description: PostgreSQL database driver. Supports binary protocol, connection pooling and statement reuse. -version: 3.5.6 +version: 3.5.7 homepage: https://github.com/isoos/postgresql-dart topics: - sql diff --git a/test/connection_string_test.dart b/test/connection_string_test.dart new file mode 100644 index 0000000..f74ff8c --- /dev/null +++ b/test/connection_string_test.dart @@ -0,0 +1,345 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart'; +import 'package:postgres/src/connection_string.dart'; +import 'package:test/test.dart'; + +void main() { + group('Connection String Parser', () { + group('Basic parsing', () { + test('minimal connection string', () { + final result = parseConnectionString('postgresql://localhost/test'); + + expect(result.endpoint.host, equals('localhost')); + expect(result.endpoint.port, equals(5432)); + expect(result.endpoint.database, equals('test')); + expect(result.endpoint.username, isNull); + expect(result.endpoint.password, isNull); + expect(result.applicationName, isNull); + expect(result.connectTimeout, isNull); + expect(result.encoding, isNull); + expect(result.replicationMode, isNull); + expect(result.securityContext, isNull); + expect(result.sslMode, isNull); + }); + + test('full connection string with credentials', () { + final result = parseConnectionString( + 'postgresql://user:password@host:9999/database'); + + expect(result.endpoint.host, equals('host')); + expect(result.endpoint.port, equals(9999)); + expect(result.endpoint.database, equals('database')); + expect(result.endpoint.username, equals('user')); + expect(result.endpoint.password, equals('password')); + }); + + test('default values when components missing', () { + final result = parseConnectionString('postgresql:///'); + + expect(result.endpoint.host, equals('localhost')); + expect(result.endpoint.port, equals(5432)); + expect(result.endpoint.database, equals('postgres')); + }); + + test('URL encoded credentials', () { + final result = parseConnectionString( + 'postgresql://user%40domain:p%40ssw%3Ard@host/db'); + + expect(result.endpoint.username, equals('user@domain')); + expect(result.endpoint.password, equals('p@ssw:rd')); + }); + + test('postgres scheme alias', () { + final result = parseConnectionString('postgres://localhost/test'); + + expect(result.endpoint.host, equals('localhost')); + expect(result.endpoint.database, equals('test')); + }); + }); + + group('SSL parameters', () { + test('sslmode disable', () { + final result = parseConnectionString( + 'postgresql://localhost/test?sslmode=disable'); + + expect(result.sslMode, equals(SslMode.disable)); + expect(result.securityContext, isNull); + }); + + test('sslmode require', () { + final result = parseConnectionString( + 'postgresql://localhost/test?sslmode=require'); + + expect(result.sslMode, equals(SslMode.require)); + }); + + test('sslmode verify-ca', () { + final result = parseConnectionString( + 'postgresql://localhost/test?sslmode=verify-ca'); + + expect(result.sslMode, equals(SslMode.verifyFull)); + }); + + test('sslmode verify-full', () { + final result = parseConnectionString( + 'postgresql://localhost/test?sslmode=verify-full'); + + expect(result.sslMode, equals(SslMode.verifyFull)); + }); + + test('SSL certificate paths create SecurityContext', () { + // This test will fail if certificate files don't exist, which is expected behavior + expect( + () => parseConnectionString( + 'postgresql://localhost/test?sslcert=/path/to/cert.pem&sslkey=/path/to/key.pem&sslrootcert=/path/to/ca.pem'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('SSL configuration error'), + )), + ); + }); + + test('single SSL certificate parameter triggers SSL loading', () { + expect( + () => parseConnectionString( + 'postgresql://localhost/test?sslcert=/path/to/cert.pem'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('SSL configuration error'), + )), + ); + }); + }); + + group('Timeout and application name', () { + test('connect_timeout parameter', () { + final result = parseConnectionString( + 'postgresql://localhost/test?connect_timeout=30'); + + expect(result.connectTimeout, equals(Duration(seconds: 30))); + }); + + test('application_name parameter', () { + final result = parseConnectionString( + 'postgresql://localhost/test?application_name=my_app'); + + expect(result.applicationName, equals('my_app')); + }); + + test('URL encoded application name', () { + final result = parseConnectionString( + 'postgresql://localhost/test?application_name=my%20app'); + + expect(result.applicationName, equals('my app')); + }); + + test('combined timeout and application name', () { + final result = parseConnectionString( + 'postgresql://localhost/test?connect_timeout=45&application_name=test_suite'); + + expect(result.connectTimeout, equals(Duration(seconds: 45))); + expect(result.applicationName, equals('test_suite')); + }); + }); + + group('Encoding and replication', () { + test('client_encoding UTF8', () { + final result = parseConnectionString( + 'postgresql://localhost/test?client_encoding=UTF8'); + + expect(result.encoding, equals(utf8)); + }); + + test('client_encoding UTF-8 (with dash)', () { + final result = parseConnectionString( + 'postgresql://localhost/test?client_encoding=UTF-8'); + + expect(result.encoding, equals(utf8)); + }); + + test('client_encoding LATIN1', () { + final result = parseConnectionString( + 'postgresql://localhost/test?client_encoding=LATIN1'); + + expect(result.encoding, equals(latin1)); + }); + + test('client_encoding ISO-8859-1', () { + final result = parseConnectionString( + 'postgresql://localhost/test?client_encoding=ISO-8859-1'); + + expect(result.encoding, equals(latin1)); + }); + + test('replication database (logical)', () { + final result = parseConnectionString( + 'postgresql://localhost/test?replication=database'); + + expect( + result.replicationMode, equals(ReplicationMode.logical)); + }); + + test('replication true (physical)', () { + final result = parseConnectionString( + 'postgresql://localhost/test?replication=true'); + + expect( + result.replicationMode, equals(ReplicationMode.physical)); + }); + + test('replication physical', () { + final result = parseConnectionString( + 'postgresql://localhost/test?replication=physical'); + + expect( + result.replicationMode, equals(ReplicationMode.physical)); + }); + + test('replication false (none)', () { + final result = parseConnectionString( + 'postgresql://localhost/test?replication=false'); + + expect(result.replicationMode, equals(ReplicationMode.none)); + }); + + test('replication no_select (none)', () { + final result = parseConnectionString( + 'postgresql://localhost/test?replication=no_select'); + + expect(result.replicationMode, equals(ReplicationMode.none)); + }); + }); + + group('Error handling', () { + test('invalid scheme throws ArgumentError', () { + expect( + () => parseConnectionString('mysql://localhost/test'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Invalid connection string scheme: mysql'), + )), + ); + }); + + test('unrecognized parameter throws ArgumentError', () { + expect( + () => parseConnectionString( + 'postgresql://localhost/test?invalid_param=value'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unrecognized connection parameter: invalid_param'), + )), + ); + }); + + test('invalid sslmode throws ArgumentError', () { + expect( + () => parseConnectionString( + 'postgresql://localhost/test?sslmode=invalid'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Invalid sslmode value: invalid'), + )), + ); + }); + + test('invalid connect_timeout throws ArgumentError', () { + expect( + () => parseConnectionString( + 'postgresql://localhost/test?connect_timeout=not_a_number'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Invalid connect_timeout value: not_a_number'), + )), + ); + }); + + test('zero connect_timeout throws ArgumentError', () { + expect( + () => parseConnectionString( + 'postgresql://localhost/test?connect_timeout=0'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Invalid connect_timeout value: 0'), + )), + ); + }); + + test('negative connect_timeout throws ArgumentError', () { + expect( + () => parseConnectionString( + 'postgresql://localhost/test?connect_timeout=-5'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Invalid connect_timeout value: -5'), + )), + ); + }); + + test('unsupported client_encoding throws ArgumentError', () { + expect( + () => parseConnectionString( + 'postgresql://localhost/test?client_encoding=ASCII'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unsupported client_encoding: ASCII'), + )), + ); + }); + + test('invalid replication value throws ArgumentError', () { + expect( + () => parseConnectionString( + 'postgresql://localhost/test?replication=invalid'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Invalid replication value: invalid'), + )), + ); + }); + }); + + group('Complex scenarios', () { + test('multiple parameters combined', () { + final result = + parseConnectionString('postgresql://user:pass@host:5433/mydb?' + 'sslmode=require&' + 'connect_timeout=60&' + 'application_name=integration_test&' + 'client_encoding=UTF8&' + 'replication=database'); + + expect(result.endpoint.host, equals('host')); + expect(result.endpoint.port, equals(5433)); + expect(result.endpoint.database, equals('mydb')); + expect(result.endpoint.username, equals('user')); + expect(result.endpoint.password, equals('pass')); + + expect(result.sslMode, equals(SslMode.require)); + expect(result.connectTimeout, equals(Duration(seconds: 60))); + expect(result.applicationName, equals('integration_test')); + expect(result.encoding, equals(utf8)); + expect( + result.replicationMode, equals(ReplicationMode.logical)); + }); + + test('empty parameter values are handled', () { + final result = parseConnectionString( + 'postgresql://localhost/test?application_name='); + + expect(result.applicationName, equals('')); + }); + }); + }); +} diff --git a/test/timeout_test.dart b/test/timeout_test.dart index 3d9e4c5..b512baf 100644 --- a/test/timeout_test.dart +++ b/test/timeout_test.dart @@ -80,10 +80,9 @@ void main() { test('Query times out, next query in the queue runs', () async { final rs = await conn.execute('SELECT 1'); - //ignore: unawaited_futures - conn + unawaited(conn .execute('SELECT pg_sleep(2)', timeout: Duration(seconds: 1)) - .catchError((_) => rs); + .catchError((_) => rs)); expect(await conn.execute('SELECT 1'), [ [1]