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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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` |
Expand Down
4 changes: 2 additions & 2 deletions lib/postgres.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Connection> openFromUrl(String connectionString) async {
final parsed = parseConnectionString(connectionString);
return open(
parsed.endpoint,
parsed.endpoints.first,
settings: ConnectionSettings(
applicationName: parsed.applicationName,
connectTimeout: parsed.connectTimeout,
Expand Down
182 changes: 166 additions & 16 deletions lib/src/connection_string.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'dart:io';
import '../postgres.dart';

({
Endpoint endpoint,
List<Endpoint> endpoints,
// standard parameters
String? applicationName,
Duration? connectTimeout,
Expand All @@ -24,30 +24,83 @@ 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(
'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 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
Expand All @@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions lib/src/pool/pool_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,15 @@ abstract class Pool<L> 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(
connectionString,
enablePoolSettings: true,
);
return PoolImplementation(
roundRobinSelector([parsed.endpoint]),
roundRobinSelector(parsed.endpoints),
PoolSettings(
applicationName: parsed.applicationName,
connectTimeout: parsed.connectTimeout,
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading