diff --git a/CHANGELOG.md b/CHANGELOG.md index 9583ff0..a99902a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 3.5.9 + +- Supporting multiple hosts in connection strings via comma-separated hosts or multiple `host` query parameters. +- Unix socket connections via `host` query parameter (automatically detected when path contains `/`). +- Supporting `user`/`username`, `password`, `database`, and `port` as query parameters in connection strings (override URL components). + + ## 3.5.8 - Upgraded SDK constraints and lints. diff --git a/README.md b/README.md index 9aa941b..576ae6e 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,10 @@ Pool.withUrl( `postgresql://[userspec@][hostspec][:port][/dbname][?paramspec]` - **Scheme**: `postgresql://` or `postgres://` -- **User**: `username` or `username:password` -- **Host**: hostname or IP address (defaults to `localhost`) -- **Port**: port number (defaults to `5432`) -- **Database**: database name (defaults to `postgres`) +- **User**: `username` or `username:password` (can also be set via `user`/`username` and `password` query parameters) +- **Host**: hostname or IP address (defaults to `localhost`). Supports multiple hosts via comma-separated list (`host1:5433,host2:5434`) or multiple `host` query parameters (`?host=host1:5433&host=host2:5434`) +- **Port**: port number (defaults to `5432`, can be overridden via `port` query parameter) +- **Database**: database name (defaults to `postgres`, can be overridden via `database` query parameter) - **Parameters**: query parameters (see below) ### Standard Parameters @@ -85,6 +85,11 @@ These parameters are supported by `Connection.openFromUrl()`: | `application_name` | String | Sets the application name | `application_name=myapp` | | `client_encoding` | String | Character encoding | `UTF8`, `LATIN1` | | `connect_timeout` | Integer | Connection timeout in seconds | `connect_timeout=30` | +| `database` | String | Database name (overrides URL path) | `database=mydb` | +| `host` | String | Alternative host specification (supports Unix sockets) | `host=/var/run/postgresql`, `host=host1:5433` | +| `password` | String | Password (overrides URL userspec) | `password=secret` | +| `port` | Integer | Port number (overrides URL port) | `port=5433` | +| `user` / `username` | String | Username (overrides URL userspec) | `user=myuser` | | `sslmode` | String | SSL mode | `disable`, `require`, `verify-ca`, `verify-full` | | `sslcert` | String | Path to client certificate | `sslcert=/path/to/cert.pem` | | `sslkey` | String | Path to client private key | `sslkey=/path/to/key.pem` | diff --git a/lib/postgres.dart b/lib/postgres.dart index 6c2d358..fb4e429 100644 --- a/lib/postgres.dart +++ b/lib/postgres.dart @@ -236,12 +236,12 @@ abstract class Connection implements Session, SessionExecutor { /// 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: When multiple endpoints are specified, only the first one is used. /// 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, + parsed.endpoints.first, settings: ConnectionSettings( applicationName: parsed.applicationName, connectTimeout: parsed.connectTimeout, diff --git a/lib/src/connection_string.dart b/lib/src/connection_string.dart index e212695..2f8e4a2 100644 --- a/lib/src/connection_string.dart +++ b/lib/src/connection_string.dart @@ -4,7 +4,7 @@ import 'dart:io'; import '../postgres.dart'; ({ - Endpoint endpoint, + List endpoints, // standard parameters String? applicationName, Duration? connectTimeout, @@ -24,7 +24,10 @@ parseConnectionString( String connectionString, { bool enablePoolSettings = false, }) { - final uri = Uri.parse(connectionString); + // Pre-process connection string to extract comma-separated hosts from authority + final preProcessed = _preprocessConnectionString(connectionString); + + final uri = Uri.parse(preProcessed.uri); if (uri.scheme != 'postgresql' && uri.scheme != 'postgres') { throw ArgumentError( @@ -32,22 +35,72 @@ parseConnectionString( ); } - 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 params = uri.queryParameters; + + // Database: query parameter overrides path + final database = + params['database'] ?? uri.pathSegments.firstOrNull ?? 'postgres'; + + // Username: 'user' or 'username' query parameter overrides userInfo + final username = + params['user'] ?? + params['username'] ?? + (uri.userInfo.isEmpty ? null : _parseUsername(uri.userInfo)); + + // Password: query parameter overrides userInfo + final password = + params['password'] ?? + (uri.userInfo.isEmpty ? null : _parsePassword(uri.userInfo)); + + // Parse hosts + final hosts = <({String host, int port, bool isUnixSocket})>[]; + + // Add hosts from authority (extracted during preprocessing) + if (preProcessed.hosts.isNotEmpty) { + hosts.addAll(preProcessed.hosts); + } else if (uri.host.isNotEmpty) { + // No comma-separated hosts, use standard URI host + hosts.add(( + host: uri.host, + port: uri.port == 0 ? 5432 : uri.port, + isUnixSocket: false, + )); + } + + // Parse host query parameters + final defaultPort = params['port'] != null + ? int.tryParse(params['port']!) ?? 5432 + : 5432; + + if (uri.queryParametersAll.containsKey('host')) { + final hostParams = uri.queryParametersAll['host'] ?? []; + for (final hostParam in hostParams) { + final parsed = _parseHostPort(hostParam, defaultPort: defaultPort); + hosts.add(parsed); + } + } + + // Default to localhost if no hosts specified + if (hosts.isEmpty) { + hosts.add((host: 'localhost', port: defaultPort, isUnixSocket: false)); + } final validParams = { // Note: parameters here should be matched to https://www.postgresql.org/docs/current/libpq-connect.html 'application_name', 'client_encoding', 'connect_timeout', + 'database', + 'host', + 'password', + 'port', 'replication', 'sslcert', 'sslkey', 'sslmode', 'sslrootcert', + 'user', + 'username', // Note: some parameters are not part of the libpq-connect above 'query_timeout', // Note: parameters here are only for pool-settings @@ -59,7 +112,6 @@ parseConnectionString( ], }; - final params = uri.queryParameters; for (final key in params.keys) { if (!validParams.contains(key)) { throw ArgumentError('Unrecognized connection parameter: $key'); @@ -209,16 +261,21 @@ parseConnectionString( maxQueryCount = count; } - final endpoint = Endpoint( - host: host, - port: port, - database: database, - username: username, - password: password, - ); + final endpoints = hosts + .map( + (h) => Endpoint( + host: h.host, + port: h.port, + database: database, + username: username, + password: password, + isUnixSocket: h.isUnixSocket, + ), + ) + .toList(); return ( - endpoint: endpoint, + endpoints: endpoints, sslMode: sslMode, securityContext: securityContext, connectTimeout: connectTimeout, @@ -249,6 +306,99 @@ String? _parsePassword(String userInfo) { return Uri.decodeComponent(userInfo.substring(colonIndex + 1)); } +({String host, int port, bool isUnixSocket}) _parseHostPort( + String hostPort, { + required int defaultPort, +}) { + // Check if it's a Unix socket (contains '/') + final isUnixSocket = hostPort.contains('/'); + + String host; + int port; + + if (isUnixSocket) { + // Unix socket - don't parse for port (may have colons in filename) + host = hostPort; + port = defaultPort; + } else { + // Regular host - check for port after colon + final colonIndex = hostPort.lastIndexOf(':'); + if (colonIndex != -1) { + host = hostPort.substring(0, colonIndex); + port = int.tryParse(hostPort.substring(colonIndex + 1)) ?? defaultPort; + } else { + host = hostPort; + port = defaultPort; + } + } + + return (host: host, port: port, isUnixSocket: isUnixSocket); +} + +({String uri, List<({String host, int port, bool isUnixSocket})> hosts}) +_preprocessConnectionString(String connectionString) { + // Extract scheme + final schemeEnd = connectionString.indexOf('://'); + if (schemeEnd == -1) { + return (uri: connectionString, hosts: []); + } + + final scheme = connectionString.substring(0, schemeEnd + 3); + final rest = connectionString.substring(schemeEnd + 3); + + // Find where authority ends (at '/', '?', or end of string) + final pathStart = rest.indexOf('/'); + final queryStart = rest.indexOf('?'); + + int authorityEnd; + if (pathStart != -1 && queryStart != -1) { + authorityEnd = pathStart < queryStart ? pathStart : queryStart; + } else if (pathStart != -1) { + authorityEnd = pathStart; + } else if (queryStart != -1) { + authorityEnd = queryStart; + } else { + authorityEnd = rest.length; + } + + final authority = rest.substring(0, authorityEnd); + final remainder = rest.substring(authorityEnd); + + // Check if authority contains comma-separated hosts + if (!authority.contains(',')) { + // No comma-separated hosts, return as-is + return (uri: connectionString, hosts: []); + } + + // Split authority into userinfo and hostlist + final atIndex = authority.indexOf('@'); + final String userInfo; + final String hostlist; + + if (atIndex != -1) { + userInfo = authority.substring(0, atIndex + 1); // includes '@' + hostlist = authority.substring(atIndex + 1); + } else { + userInfo = ''; + hostlist = authority; + } + + // Parse comma-separated hosts + final hostParts = hostlist.split(','); + final hosts = <({String host, int port, bool isUnixSocket})>[]; + + for (final hostPart in hostParts) { + final parsed = _parseHostPort(hostPart.trim(), defaultPort: 5432); + hosts.add(parsed); + } + + // Rebuild URI with only the first host for Uri.parse to work + final firstHost = hosts.isNotEmpty ? hostParts[0] : ''; + final modifiedUri = '$scheme$userInfo$firstHost$remainder'; + + return (uri: modifiedUri, hosts: hosts); +} + SecurityContext _createSecurityContext({ String? certPath, String? keyPath, diff --git a/lib/src/pool/pool_api.dart b/lib/src/pool/pool_api.dart index 640b8a7..b4af7ba 100644 --- a/lib/src/pool/pool_api.dart +++ b/lib/src/pool/pool_api.dart @@ -72,7 +72,7 @@ abstract class Pool implements Session, SessionExecutor { /// 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: Multiple endpoints can be specified via comma-separated hosts or multiple host query parameters. /// Note: Only a subset of settings can be set with parameters. factory Pool.withUrl(String connectionString) { final parsed = parseConnectionString( @@ -80,7 +80,7 @@ abstract class Pool implements Session, SessionExecutor { enablePoolSettings: true, ); return PoolImplementation( - roundRobinSelector([parsed.endpoint]), + roundRobinSelector(parsed.endpoints), PoolSettings( applicationName: parsed.applicationName, connectTimeout: parsed.connectTimeout, diff --git a/pubspec.yaml b/pubspec.yaml index 3a3266e..d05361d 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.8 +version: 3.5.9 homepage: https://github.com/isoos/postgresql-dart topics: - sql diff --git a/test/connection_string_test.dart b/test/connection_string_test.dart index ef6a7d4..32f02b1 100644 --- a/test/connection_string_test.dart +++ b/test/connection_string_test.dart @@ -10,11 +10,12 @@ void main() { 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); + final endpoint = result.endpoints.single; + expect(endpoint.host, equals('localhost')); + expect(endpoint.port, equals(5432)); + expect(endpoint.database, equals('test')); + expect(endpoint.username, isNull); + expect(endpoint.password, isNull); expect(result.applicationName, isNull); expect(result.connectTimeout, isNull); expect(result.encoding, isNull); @@ -28,19 +29,21 @@ void main() { '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')); + final endpoint = result.endpoints.single; + expect(endpoint.host, equals('host')); + expect(endpoint.port, equals(9999)); + expect(endpoint.database, equals('database')); + expect(endpoint.username, equals('user')); + expect(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')); + final endpoint = result.endpoints.single; + expect(endpoint.host, equals('localhost')); + expect(endpoint.port, equals(5432)); + expect(endpoint.database, equals('postgres')); }); test('URL encoded credentials', () { @@ -48,15 +51,17 @@ void main() { 'postgresql://user%40domain:p%40ssw%3Ard@host/db', ); - expect(result.endpoint.username, equals('user@domain')); - expect(result.endpoint.password, equals('p@ssw:rd')); + final endpoint = result.endpoints.single; + expect(endpoint.username, equals('user@domain')); + expect(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')); + final endpoint = result.endpoints.single; + expect(endpoint.host, equals('localhost')); + expect(endpoint.database, equals('test')); }); }); @@ -355,6 +360,21 @@ void main() { }); }); + group('Query parameter overrides', () { + test('user, password, database, and port as query parameters', () { + final result = parseConnectionString( + 'postgresql:///?user=queryuser&password=querypass&database=querydb&port=9876', + ); + + final endpoint = result.endpoints.single; + expect(endpoint.host, equals('localhost')); + expect(endpoint.port, equals(9876)); + expect(endpoint.database, equals('querydb')); + expect(endpoint.username, equals('queryuser')); + expect(endpoint.password, equals('querypass')); + }); + }); + group('Complex scenarios', () { test('multiple parameters combined', () { final result = parseConnectionString( @@ -366,11 +386,12 @@ void main() { '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')); + final endpoint = result.endpoints.single; + expect(endpoint.host, equals('host')); + expect(endpoint.port, equals(5433)); + expect(endpoint.database, equals('mydb')); + expect(endpoint.username, equals('user')); + expect(endpoint.password, equals('pass')); expect(result.sslMode, equals(SslMode.require)); expect(result.connectTimeout, equals(Duration(seconds: 60))); @@ -388,6 +409,86 @@ void main() { }); }); + group('Multiple hosts', () { + test('comma-separated hosts in URI', () { + final result = parseConnectionString( + 'postgresql://host1:5433,host2:5434,host3/mydb', + ); + + expect(result.endpoints, hasLength(3)); + + expect(result.endpoints[0].host, equals('host1')); + expect(result.endpoints[0].port, equals(5433)); + expect(result.endpoints[0].database, equals('mydb')); + expect(result.endpoints[0].isUnixSocket, isFalse); + + expect(result.endpoints[1].host, equals('host2')); + expect(result.endpoints[1].port, equals(5434)); + expect(result.endpoints[1].database, equals('mydb')); + expect(result.endpoints[1].isUnixSocket, isFalse); + + expect(result.endpoints[2].host, equals('host3')); + expect(result.endpoints[2].port, equals(5432)); // default port + expect(result.endpoints[2].database, equals('mydb')); + expect(result.endpoints[2].isUnixSocket, isFalse); + }); + + test('multiple host query parameters', () { + final result = parseConnectionString( + 'postgresql:///mydb?host=host1:5433&host=host2:5434&host=host3', + ); + + expect(result.endpoints, hasLength(3)); + + expect(result.endpoints[0].host, equals('host1')); + expect(result.endpoints[0].port, equals(5433)); + expect(result.endpoints[0].database, equals('mydb')); + expect(result.endpoints[0].isUnixSocket, isFalse); + + expect(result.endpoints[1].host, equals('host2')); + expect(result.endpoints[1].port, equals(5434)); + expect(result.endpoints[1].database, equals('mydb')); + expect(result.endpoints[1].isUnixSocket, isFalse); + + expect(result.endpoints[2].host, equals('host3')); + expect(result.endpoints[2].port, equals(5432)); // default port + expect(result.endpoints[2].database, equals('mydb')); + expect(result.endpoints[2].isUnixSocket, isFalse); + }); + + test('Unix socket as query parameter host', () { + final result = parseConnectionString( + 'postgresql:///mydb?host=/var/run/postgresql', + ); + + expect(result.endpoints, hasLength(1)); + + expect(result.endpoints[0].host, equals('/var/run/postgresql')); + expect(result.endpoints[0].port, equals(5432)); // default port + expect(result.endpoints[0].database, equals('mydb')); + expect(result.endpoints[0].isUnixSocket, isTrue); + }); + + test('Unix socket with colon in path', () { + final result = parseConnectionString( + 'postgresql:///mydb?host=/var/run:with:colons/postgresql', + ); + + expect(result.endpoints, hasLength(1)); + + expect( + result.endpoints[0].host, + equals('/var/run:with:colons/postgresql'), + ); + expect( + result.endpoints[0].port, + equals(5432), + ); // should not parse colons as port + expect(result.endpoints[0].database, equals('mydb')); + expect(result.endpoints[0].isUnixSocket, isTrue); + }); + }); + group('Query timeout and pool parameters', () { test('query_timeout parameter', () { final result = parseConnectionString(