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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 3.5.8

- Upgraded SDK constraints and lints.
- Supporting more URL-based connection-string parameters (mostly for pool).

## 3.5.7.

Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,61 @@ Execute queries in a transaction:

See the API documentation: https://pub.dev/documentation/postgres/latest/

## Connection string URLs

The package supports connection strings for both single connections and connection pools:

```dart
await Connection.openFromUrl('postgresql://localhost/mydb');
await Connection.openFromUrl(
'postgresql://user:pass@db.example.com:5432/production?sslmode=verify-full'
);
await Connection.openFromUrl(
'postgresql://localhost/mydb?connect_timeout=10&query_timeout=60'
);
Pool.withUrl(
'postgresql://localhost/mydb?max_connection_count=10&max_connection_age=3600'
);
```

### URL Format

`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`)
- **Parameters**: query parameters (see below)

### Standard Parameters

These parameters are supported by `Connection.openFromUrl()`:

| Parameter | Type | Description | Example Values |
|-----------|------|-------------|----------------|
| `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` |
| `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` |
| `sslrootcert` | String | Path to root certificate | `sslrootcert=/path/to/ca.pem` |
| `replication` | String | Replication mode | `database` (logical), `true`/`physical`, `false`/`no_select` (none) |
| `query_timeout` | Integer | Query timeout in seconds | `query_timeout=300` |

### Pool-Specific Parameters

These additional parameters are supported by `Pool.withUrl()`:

| Parameter | Type | Description | Example Values |
|-----------|------|-------------|----------------|
| `max_connection_count` | Integer | Maximum number of concurrent connections | `max_connection_count=20` |
| `max_connection_age` | Integer | Maximum connection lifetime in seconds | `max_connection_age=3600` |
| `max_session_use` | Integer | Maximum session duration in seconds | `max_session_use=600` |
| `max_query_count` | Integer | Maximum queries per connection | `max_query_count=1000` |

## Connection pooling

The library supports connection pooling (and masking the connection pool as
Expand Down
1 change: 1 addition & 0 deletions lib/postgres.dart
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ abstract class Connection implements Session, SessionExecutor {
connectTimeout: parsed.connectTimeout,
encoding: parsed.encoding,
replicationMode: parsed.replicationMode,
queryTimeout: parsed.queryTimeout,
securityContext: parsed.securityContext,
sslMode: parsed.sslMode,
),
Expand Down
93 changes: 87 additions & 6 deletions lib/src/connection_string.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@ import '../postgres.dart';

({
Endpoint endpoint,
// standard parameters
String? applicationName,
Duration? connectTimeout,
Encoding? encoding,
ReplicationMode? replicationMode,
SecurityContext? securityContext,
SslMode? sslMode,
// non-standard parameters
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the non-standard parameters perhaps be mentioned in the readme? I assume they'd be very hard to discover otherwise?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, already on it.

Duration? queryTimeout,
// pool parameters
Duration? maxConnectionAge,
int? maxConnectionCount,
Duration? maxSessionUse,
int? maxQueryCount,
})
parseConnectionString(String connectionString) {
parseConnectionString(
String connectionString, {
bool enablePoolSettings = false,
}) {
final uri = Uri.parse(connectionString);

if (uri.scheme != 'postgresql' && uri.scheme != 'postgres') {
Expand All @@ -28,14 +39,24 @@ parseConnectionString(String connectionString) {
final password = uri.userInfo.isEmpty ? null : _parsePassword(uri.userInfo);

final validParams = {
'sslmode',
'sslcert',
'sslkey',
'sslrootcert',
'connect_timeout',
// Note: parameters here should be matched to https://www.postgresql.org/docs/current/libpq-connect.html
'application_name',
'client_encoding',
'connect_timeout',
'replication',
'sslcert',
'sslkey',
'sslmode',
'sslrootcert',
// Note: some parameters are not part of the libpq-connect above
'query_timeout',
// Note: parameters here are only for pool-settings
if (enablePoolSettings) ...[
'max_connection_age',
'max_connection_count',
'max_session_use',
'max_query_count',
],
};

final params = uri.queryParameters;
Expand Down Expand Up @@ -133,6 +154,61 @@ parseConnectionString(String connectionString) {
}
}

Duration? queryTimeout;
if (params.containsKey('query_timeout')) {
final timeoutSeconds = int.tryParse(params['query_timeout']!);
if (timeoutSeconds == null || timeoutSeconds <= 0) {
throw ArgumentError(
'Invalid query_timeout value: ${params['query_timeout']}. Expected positive integer.',
);
}
queryTimeout = Duration(seconds: timeoutSeconds);
}

Duration? maxConnectionAge;
if (enablePoolSettings && params.containsKey('max_connection_age')) {
final ageSeconds = int.tryParse(params['max_connection_age']!);
if (ageSeconds == null || ageSeconds <= 0) {
throw ArgumentError(
'Invalid max_connection_age value: ${params['max_connection_age']}. Expected positive integer.',
);
}
maxConnectionAge = Duration(seconds: ageSeconds);
}

int? maxConnectionCount;
if (enablePoolSettings && params.containsKey('max_connection_count')) {
final count = int.tryParse(params['max_connection_count']!);
if (count == null || count <= 0) {
throw ArgumentError(
'Invalid max_connection_count value: ${params['max_connection_count']}. Expected positive integer.',
);
}
maxConnectionCount = count;
}

Duration? maxSessionUse;
if (enablePoolSettings && params.containsKey('max_session_use')) {
final sessionSeconds = int.tryParse(params['max_session_use']!);
if (sessionSeconds == null || sessionSeconds <= 0) {
throw ArgumentError(
'Invalid max_session_use value: ${params['max_session_use']}. Expected positive integer.',
);
}
maxSessionUse = Duration(seconds: sessionSeconds);
}

int? maxQueryCount;
if (enablePoolSettings && params.containsKey('max_query_count')) {
final count = int.tryParse(params['max_query_count']!);
if (count == null || count <= 0) {
throw ArgumentError(
'Invalid max_query_count value: ${params['max_query_count']}. Expected positive integer.',
);
}
maxQueryCount = count;
}

final endpoint = Endpoint(
host: host,
port: port,
Expand All @@ -149,6 +225,11 @@ parseConnectionString(String connectionString) {
applicationName: applicationName,
encoding: encoding,
replicationMode: replicationMode,
queryTimeout: queryTimeout,
maxConnectionAge: maxConnectionAge,
maxConnectionCount: maxConnectionCount,
maxSessionUse: maxSessionUse,
maxQueryCount: maxQueryCount,
);
}

Expand Down
10 changes: 9 additions & 1 deletion lib/src/pool/pool_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,24 @@ abstract class Pool<L> implements Session, SessionExecutor {
/// 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);
final parsed = parseConnectionString(
connectionString,
enablePoolSettings: true,
);
return PoolImplementation(
roundRobinSelector([parsed.endpoint]),
PoolSettings(
applicationName: parsed.applicationName,
connectTimeout: parsed.connectTimeout,
encoding: parsed.encoding,
queryTimeout: parsed.queryTimeout,
replicationMode: parsed.replicationMode,
securityContext: parsed.securityContext,
sslMode: parsed.sslMode,
maxConnectionAge: parsed.maxConnectionAge,
maxConnectionCount: parsed.maxConnectionCount,
maxSessionUse: parsed.maxSessionUse,
maxQueryCount: parsed.maxQueryCount,
),
);
}
Expand Down
129 changes: 129 additions & 0 deletions test/connection_string_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -387,5 +387,134 @@ void main() {
expect(result.applicationName, equals(''));
});
});

group('Query timeout and pool parameters', () {
test('query_timeout parameter', () {
final result = parseConnectionString(
'postgresql://localhost/test?query_timeout=45',
);
expect(result.queryTimeout, equals(Duration(seconds: 45)));
});

test('query_timeout validation', () {
expect(
() => parseConnectionString(
'postgresql://localhost/test?query_timeout=invalid',
),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('Invalid query_timeout'),
),
),
);
expect(
() => parseConnectionString(
'postgresql://localhost/test?query_timeout=0',
),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('Invalid query_timeout'),
),
),
);
});

test('pool parameters with enablePoolSettings', () {
final result = parseConnectionString(
'postgresql://localhost/test?max_connection_age=3600&max_connection_count=10&max_session_use=7200&max_query_count=1000',
enablePoolSettings: true,
);
expect(result.maxConnectionAge, equals(Duration(seconds: 3600)));
expect(result.maxConnectionCount, equals(10));
expect(result.maxSessionUse, equals(Duration(seconds: 7200)));
expect(result.maxQueryCount, equals(1000));
});

test('pool parameters rejected without enablePoolSettings', () {
expect(
() => parseConnectionString(
'postgresql://localhost/test?max_connection_age=3600',
),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('Unrecognized connection parameter'),
),
),
);
});

test('pool parameter validation', () {
expect(
() => parseConnectionString(
'postgresql://localhost/test?max_connection_age=0',
enablePoolSettings: true,
),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('Invalid max_connection_age'),
),
),
);
expect(
() => parseConnectionString(
'postgresql://localhost/test?max_connection_count=invalid',
enablePoolSettings: true,
),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('Invalid max_connection_count'),
),
),
);
expect(
() => parseConnectionString(
'postgresql://localhost/test?max_session_use=-5',
enablePoolSettings: true,
),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('Invalid max_session_use'),
),
),
);
expect(
() => parseConnectionString(
'postgresql://localhost/test?max_query_count=0',
enablePoolSettings: true,
),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('Invalid max_query_count'),
),
),
);
});

test('all timeout and pool parameters combined', () {
final result = parseConnectionString(
'postgresql://localhost/test?query_timeout=30&max_connection_age=3600&max_connection_count=20&max_session_use=7200&max_query_count=500',
enablePoolSettings: true,
);
expect(result.queryTimeout, equals(Duration(seconds: 30)));
expect(result.maxConnectionAge, equals(Duration(seconds: 3600)));
expect(result.maxConnectionCount, equals(20));
expect(result.maxSessionUse, equals(Duration(seconds: 7200)));
expect(result.maxQueryCount, equals(500));
});
});
});
}