├── .github └── workflows │ └── dart.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example └── example.dart ├── lib ├── messages.dart ├── postgres.dart └── src │ ├── auth │ ├── auth.dart │ ├── clear_text_authenticator.dart │ ├── md5_authenticator.dart │ └── sasl_authenticator.dart │ ├── buffer.dart │ ├── exceptions.dart │ ├── message_window.dart │ ├── messages │ ├── client_messages.dart │ ├── logical_replication_messages.dart │ ├── server_messages.dart │ └── shared_messages.dart │ ├── pool │ ├── pool_api.dart │ └── pool_impl.dart │ ├── replication.dart │ ├── time_converters.dart │ ├── types.dart │ ├── types │ ├── binary_codec.dart │ ├── codec.dart │ ├── generic_type.dart │ ├── geo_types.dart │ ├── range_types.dart │ ├── text_codec.dart │ ├── text_search.dart │ └── type_registry.dart │ ├── utils │ └── package_pool_ext.dart │ └── v3 │ ├── connection.dart │ ├── connection_info.dart │ ├── database_info.dart │ ├── protocol.dart │ ├── query_description.dart │ ├── resolved_settings.dart │ └── variable_tokenizer.dart ├── pubspec.yaml └── test ├── bytes_example_test.dart ├── connection_test.dart ├── decode_test.dart ├── docker.dart ├── encoding_test.dart ├── error_handling_test.dart ├── event_after_closing_test.dart ├── framer_test.dart ├── json_test.dart ├── non_ascii_connection_strings_test.dart ├── not_enough_bytes_test.dart ├── notification_test.dart ├── pg_configs ├── pg_hba.conf └── postgresql.conf ├── pool_test.dart ├── query_test.dart ├── server_messages_test.dart ├── text_search_test.dart ├── time_converter_test.dart ├── timeout_test.dart ├── transaction_isolation_test.dart ├── transaction_test.dart ├── types_test.dart ├── unexpected_protocol_bytes_test.dart ├── utils └── package_pool_ext_test.dart ├── v3_close_test.dart ├── v3_logical_replication_test.dart ├── v3_test.dart └── variable_tokenizer_test.dart /.github/workflows/dart.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Dart 7 | 8 | on: 9 | push: 10 | branches: [ master ] 11 | pull_request: 12 | branches: [ master ] 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | # Note: This workflow uses the latest stable version of the Dart SDK. 23 | # You can specify other versions if desired, see documentation here: 24 | # https://github.com/dart-lang/setup-dart/blob/main/README.md 25 | # - uses: dart-lang/setup-dart@v1 26 | - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 27 | 28 | - name: Install dependencies 29 | run: dart pub get 30 | 31 | # Uncomment this step to verify the use of 'dart format' on each commit. 32 | # - name: Verify formatting 33 | # run: dart format --output=none --set-exit-if-changed . 34 | 35 | # Consider passing '--fatal-infos' for slightly stricter analysis. 36 | - name: Analyze project source 37 | run: dart analyze 38 | 39 | # pre-pull the image so `usePostgresDocker` does not delay which may causes 40 | # tests to timeout 41 | - name: pull latest postgres image 42 | run: docker pull postgres:latest 43 | 44 | # Saves coverage information in the "coverage" folder. 45 | - name: Run tests 46 | run: dart test --coverage=coverage 47 | 48 | - name: Test example 49 | run: | 50 | docker run --detach --name postgres_for_dart_test -p 127.0.0.1:5432:5432 -e POSTGRES_USER=user -e POSTGRES_DATABASE=database -e POSTGRES_PASSWORD=pass postgres 51 | dart run example/example.dart 52 | docker container rm --force postgres_for_dart_test 53 | 54 | # https://www.bradcypert.com/how-to-upload-coverage-to-codecov-for-dart/ 55 | - name: Install coverage tools 56 | run: dart pub global activate coverage 57 | 58 | - name: format coverage 59 | run: $HOME/.pub-cache/bin/format_coverage --lcov --in=coverage --out=coverage.lcov --report-on=lib 60 | 61 | - name: Upload coverage reports to Codecov 62 | uses: codecov/codecov-action@v3 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .packages 3 | .dart_tool/ 4 | .pub/ 5 | build/ 6 | packages 7 | # Remove the following pattern if you wish to check in your lock file 8 | pubspec.lock 9 | 10 | # Files created by dart2js 11 | *.dart.js 12 | *.part.js 13 | *.js.deps 14 | *.js.map 15 | *.info.json 16 | 17 | # Directory created by dartdoc 18 | doc/api/ 19 | 20 | # JetBrains IDEs 21 | .idea/ 22 | *.iml 23 | *.ipr 24 | *.iws 25 | 26 | # VS Code IDE 27 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016- 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostgreSQL client 2 | 3 | [![CI](https://github.com/isoos/postgresql-dart/actions/workflows/dart.yml/badge.svg)](https://github.com/isoos/postgresql-dart/actions/workflows/dart.yml) 4 | 5 | A library for connecting to and querying PostgreSQL databases (see [Postgres Protocol](https://www.postgresql.org/docs/13/protocol-overview.html)). This driver uses the more efficient and secure extended query format of the PostgreSQL protocol. 6 | 7 | ## Usage 8 | 9 | Create a `Connection`: 10 | 11 | ```dart 12 | final conn = await Connection.open(Endpoint( 13 | host: 'localhost', 14 | database: 'postgres', 15 | username: 'user', 16 | password: 'pass', 17 | )); 18 | ``` 19 | 20 | Execute queries with `execute`: 21 | 22 | ```dart 23 | final result = await conn.execute("SELECT 'foo'"); 24 | print(result[0][0]); // first row and first field 25 | ``` 26 | 27 | Named parameters, returning rows as map of column names: 28 | 29 | ```dart 30 | final result = await conn.execute( 31 | Sql.named('SELECT * FROM a_table WHERE id=@id'), 32 | parameters: {'id': 'xyz'}, 33 | ); 34 | print(result.first.toColumnMap()); 35 | ``` 36 | 37 | Execute queries in a transaction: 38 | 39 | ```dart 40 | await conn.runTx((s) async { 41 | final rs = await s.execute('SELECT count(*) FROM foo'); 42 | await s.execute( 43 | r'UPDATE a_table SET totals=$1 WHERE id=$2', 44 | parameters: [rs[0][0], 'xyz'], 45 | ); 46 | }); 47 | ``` 48 | 49 | See the API documentation: https://pub.dev/documentation/postgres/latest/ 50 | 51 | ## Connection pooling 52 | 53 | The library supports connection pooling (and masking the connection pool as 54 | regular session executor). 55 | 56 | ## Custom type codecs 57 | 58 | The library supports registering custom type codecs (and generic object encoders) 59 | through the`ConnectionSettings.typeRegistry`. 60 | 61 | ## Streaming replication protocol 62 | 63 | The library supports connecting to PostgreSQL using the [Streaming Replication Protocol][]. 64 | See [Connection][] documentation for more info. 65 | An example can also be found at the following repository: [postgresql-dart-replication-example][] 66 | 67 | [Streaming Replication Protocol]: https://www.postgresql.org/docs/13/protocol-replication.html 68 | [Connection]: https://pub.dev/documentation/postgres/latest/postgres/Connection/Connection.html 69 | [postgresql-dart-replication-example]: https://github.com/osaxma/postgresql-dart-replication-example 70 | 71 | ## Other notes 72 | 73 | This library originally started as [StableKernel's postgres library](https://github.com/stablekernel/postgresql-dart), 74 | but got a full API overhaul and partial rewrite of the internals. 75 | 76 | Please file feature requests and bugs at the [issue tracker][tracker]. 77 | 78 | [tracker]: https://github.com/isoos/postgresql-dart/issues 79 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file allows you to configure the Dart analyzer. 2 | # 3 | # The commented part below is just for inspiration. Read the guide here: 4 | # https://github.com/dart-lang/sdk/tree/master/pkg/analyzer#configuring-the-analyzer 5 | 6 | include: package:lints/recommended.yaml 7 | 8 | analyzer: 9 | errors: 10 | unused_element: error 11 | unused_import: error 12 | unused_local_variable: error 13 | dead_code: error 14 | 15 | linter: 16 | rules: 17 | # see catalogue here: http://dart-lang.github.io/linter/lints/ 18 | - annotate_overrides 19 | - avoid_dynamic_calls 20 | - avoid_unused_constructor_parameters 21 | - await_only_futures 22 | - camel_case_types 23 | - cancel_subscriptions 24 | - directives_ordering 25 | # - empty_catches 26 | - empty_statements 27 | - hash_and_equals 28 | - collection_methods_unrelated_type 29 | - no_adjacent_strings_in_list 30 | - no_default_cases 31 | - no_duplicate_case_values 32 | - non_constant_identifier_names 33 | - noop_primitive_operations 34 | - only_throw_errors 35 | - overridden_fields 36 | - prefer_collection_literals 37 | - prefer_conditional_assignment 38 | - prefer_contains 39 | - prefer_final_fields 40 | - prefer_final_in_for_each 41 | - prefer_final_locals 42 | - prefer_initializing_formals 43 | - prefer_interpolation_to_compose_strings 44 | - prefer_is_empty 45 | - prefer_is_not_empty 46 | - prefer_single_quotes 47 | - prefer_typing_uninitialized_variables 48 | - recursive_getters 49 | - slash_for_doc_comments 50 | - test_types_in_equals 51 | - throw_in_finally 52 | - type_init_formals 53 | - unawaited_futures 54 | - unnecessary_brace_in_string_interps 55 | - unnecessary_getters_setters 56 | - unnecessary_lambdas 57 | - unnecessary_new 58 | - unnecessary_null_aware_assignments 59 | - unnecessary_null_checks 60 | - unnecessary_parenthesis 61 | - unnecessary_raw_strings 62 | - unnecessary_statements 63 | - unnecessary_this 64 | - unrelated_type_equality_checks 65 | - use_raw_strings 66 | - use_rethrow_when_possible 67 | - use_super_parameters 68 | - valid_regexps 69 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | /// Example for the `postgres` package. 2 | /// 3 | /// Running the example requires access to a postgres server. If you have docker 4 | /// installed, you can start a postgres server to run this example with 5 | /// 6 | /// ``` 7 | /// docker run --detach --name postgres_for_dart_test -p 127.0.0.1:5432:5432 -e POSTGRES_USER=user -e POSTGRES_DATABASE=database -e POSTGRES_PASSWORD=pass postgres 8 | /// ``` 9 | /// 10 | /// To stop and clear the database server once you're done testing, run 11 | /// 12 | /// ``` 13 | /// docker container rm --force postgres_for_dart_test 14 | /// ``` 15 | library; 16 | 17 | import 'package:postgres/postgres.dart'; 18 | 19 | void main() async { 20 | final conn = await Connection.open( 21 | Endpoint( 22 | host: 'localhost', 23 | database: 'postgres', 24 | username: 'user', 25 | password: 'pass', 26 | ), 27 | // The postgres server hosted locally doesn't have SSL by default. If you're 28 | // accessing a postgres server over the Internet, the server should support 29 | // SSL and you should swap out the mode with `SslMode.verifyFull`. 30 | settings: ConnectionSettings(sslMode: SslMode.disable), 31 | ); 32 | print('has connection!'); 33 | 34 | // Simple query without results 35 | await conn.execute('CREATE TABLE IF NOT EXISTS a_table (' 36 | ' id TEXT NOT NULL, ' 37 | ' totals INTEGER NOT NULL DEFAULT 0' 38 | ')'); 39 | 40 | // simple query 41 | final result0 = await conn.execute("SELECT 'foo'"); 42 | print(result0[0][0]); // first row, first column 43 | 44 | // Using prepared statements to supply values 45 | final result1 = await conn.execute( 46 | r'INSERT INTO a_table (id) VALUES ($1)', 47 | parameters: ['example row'], 48 | ); 49 | print('Inserted ${result1.affectedRows} rows'); 50 | 51 | // name parameter query 52 | final result2 = await conn.execute( 53 | Sql.named('SELECT * FROM a_table WHERE id=@id'), 54 | parameters: {'id': 'example row'}, 55 | ); 56 | print(result2.first.toColumnMap()); 57 | 58 | // transaction 59 | await conn.runTx((s) async { 60 | final rs = await s.execute('SELECT count(*) FROM a_table'); 61 | await s.execute( 62 | r'UPDATE a_table SET totals=$1 WHERE id=$2', 63 | parameters: [rs[0][0], 'xyz'], 64 | ); 65 | }); 66 | 67 | // prepared statement 68 | final statement = await conn.prepare(Sql("SELECT 'foo';")); 69 | final result3 = await statement.run([]); 70 | print(result3); 71 | await statement.dispose(); 72 | 73 | // preared statement with types 74 | final anotherStatement = 75 | await conn.prepare(Sql(r'SELECT $1;', types: [Type.bigInteger])); 76 | final bound = anotherStatement.bind([1]); 77 | final subscription = bound.listen((row) { 78 | print('row: $row'); 79 | }); 80 | await subscription.asFuture(); 81 | await subscription.cancel(); 82 | print(await subscription.affectedRows); 83 | print(await subscription.schema); 84 | 85 | await conn.close(); 86 | } 87 | -------------------------------------------------------------------------------- /lib/messages.dart: -------------------------------------------------------------------------------- 1 | export 'src/buffer.dart' show PgByteDataWriter; 2 | export 'src/messages/client_messages.dart'; 3 | export 'src/messages/logical_replication_messages.dart' 4 | hide tryAsyncParseLogicalReplicationMessage; 5 | export 'src/messages/server_messages.dart' hide parseXLogDataMessage; 6 | export 'src/messages/shared_messages.dart'; 7 | -------------------------------------------------------------------------------- /lib/src/auth/auth.dart: -------------------------------------------------------------------------------- 1 | import 'package:crypto/crypto.dart'; 2 | import 'package:sasl_scram/sasl_scram.dart'; 3 | 4 | import '../../messages.dart'; 5 | import 'clear_text_authenticator.dart'; 6 | import 'md5_authenticator.dart'; 7 | import 'sasl_authenticator.dart'; 8 | 9 | enum AuthenticationScheme { 10 | md5, 11 | scramSha256, 12 | clear, 13 | } 14 | 15 | /// A small interface to obtain the username and password used for a postgres 16 | /// connection, as well as sending messages. 17 | /// 18 | /// We want to share the authentication implementation in both the current 19 | /// implementation and the implementation for the upcoming v3 API. As well as 20 | /// both incompatible APIs are supported, we need this level of indirection so 21 | /// that the auth mechanism can talk to both implementations. 22 | class PostgresAuthConnection { 23 | final String? username; 24 | final String? password; 25 | 26 | final void Function(ClientMessage) sendMessage; 27 | 28 | PostgresAuthConnection(this.username, this.password, this.sendMessage); 29 | } 30 | 31 | abstract class PostgresAuthenticator { 32 | static String? name; 33 | late final PostgresAuthConnection connection; 34 | 35 | PostgresAuthenticator(this.connection); 36 | 37 | void onMessage(AuthenticationMessage message); 38 | } 39 | 40 | PostgresAuthenticator createAuthenticator(PostgresAuthConnection connection, 41 | AuthenticationScheme authenticationScheme) { 42 | switch (authenticationScheme) { 43 | case AuthenticationScheme.md5: 44 | return MD5Authenticator(connection); 45 | case AuthenticationScheme.scramSha256: 46 | final credentials = UsernamePasswordCredential( 47 | username: connection.username, password: connection.password); 48 | return PostgresSaslAuthenticator( 49 | connection, ScramAuthenticator('SCRAM-SHA-256', sha256, credentials)); 50 | case AuthenticationScheme.clear: 51 | return ClearAuthenticator(connection); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/src/auth/clear_text_authenticator.dart: -------------------------------------------------------------------------------- 1 | import '../buffer.dart'; 2 | import '../messages/client_messages.dart'; 3 | import '../messages/server_messages.dart'; 4 | import 'auth.dart'; 5 | 6 | class ClearAuthenticator extends PostgresAuthenticator { 7 | ClearAuthenticator(super.connection); 8 | 9 | @override 10 | void onMessage(AuthenticationMessage message) { 11 | final authMessage = ClearMessage(connection.password!); 12 | connection.sendMessage(authMessage); 13 | } 14 | } 15 | 16 | class ClearMessage extends ClientMessage { 17 | final String _password; 18 | 19 | ClearMessage(this._password); 20 | 21 | @override 22 | void applyToBuffer(PgByteDataWriter buffer) { 23 | buffer.writeUint8(ClientMessageId.password); 24 | buffer.writeLengthEncodedString(_password); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/auth/md5_authenticator.dart: -------------------------------------------------------------------------------- 1 | import 'package:buffer/buffer.dart'; 2 | import 'package:crypto/crypto.dart'; 3 | 4 | import '../buffer.dart'; 5 | import '../messages/client_messages.dart'; 6 | import '../messages/server_messages.dart'; 7 | import 'auth.dart'; 8 | 9 | class MD5Authenticator extends PostgresAuthenticator { 10 | static final String name = 'MD5'; 11 | 12 | MD5Authenticator(super.connection); 13 | 14 | @override 15 | void onMessage(AuthenticationMessage message) { 16 | final reader = ByteDataReader()..add(message.bytes); 17 | final salt = reader.read(4, copy: true); 18 | 19 | final authMessage = 20 | AuthMD5Message(connection.username!, connection.password!, salt); 21 | 22 | connection.sendMessage(authMessage); 23 | } 24 | } 25 | 26 | class AuthMD5Message extends ClientMessage { 27 | final String _hashedAuthString; 28 | 29 | AuthMD5Message._(this._hashedAuthString); 30 | factory AuthMD5Message( 31 | String username, String password, List saltBytes) { 32 | final passwordHash = md5.convert('$password$username'.codeUnits).toString(); 33 | final saltString = String.fromCharCodes(saltBytes); 34 | final md5Hash = 35 | md5.convert('$passwordHash$saltString'.codeUnits).toString(); 36 | return AuthMD5Message._('md5$md5Hash'); 37 | } 38 | 39 | @override 40 | void applyToBuffer(PgByteDataWriter buffer) { 41 | buffer.writeUint8(ClientMessageId.password); 42 | buffer.writeLengthEncodedString(_hashedAuthString); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/src/auth/sasl_authenticator.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:sasl_scram/sasl_scram.dart'; 4 | 5 | import '../buffer.dart'; 6 | import '../exceptions.dart'; 7 | import '../messages/client_messages.dart'; 8 | import '../messages/server_messages.dart'; 9 | import 'auth.dart'; 10 | 11 | /// Structure for SASL Authenticator 12 | class PostgresSaslAuthenticator extends PostgresAuthenticator { 13 | final SaslAuthenticator authenticator; 14 | 15 | PostgresSaslAuthenticator(super.connection, this.authenticator); 16 | 17 | @override 18 | void onMessage(AuthenticationMessage message) { 19 | ClientMessage msg; 20 | switch (message.type) { 21 | case AuthenticationMessageType.sasl: 22 | final bytesToSend = authenticator.handleMessage( 23 | SaslMessageType.AuthenticationSASL, message.bytes); 24 | if (bytesToSend == null) { 25 | throw PgException('KindSASL: No bytes to send'); 26 | } 27 | msg = SaslClientFirstMessage(bytesToSend, authenticator.mechanism.name); 28 | break; 29 | case AuthenticationMessageType.saslContinue: 30 | final bytesToSend = authenticator.handleMessage( 31 | SaslMessageType.AuthenticationSASLContinue, message.bytes); 32 | if (bytesToSend == null) { 33 | throw PgException('KindSASLContinue: No bytes to send'); 34 | } 35 | msg = SaslClientLastMessage(bytesToSend); 36 | break; 37 | case AuthenticationMessageType.saslFinal: 38 | authenticator.handleMessage( 39 | SaslMessageType.AuthenticationSASLFinal, message.bytes); 40 | return; 41 | default: 42 | throw PgException( 43 | 'Unsupported authentication type ${message.type}, closing connection.'); 44 | } 45 | connection.sendMessage(msg); 46 | } 47 | } 48 | 49 | class SaslClientFirstMessage extends ClientMessage { 50 | final Uint8List bytesToSendToServer; 51 | final String mechanismName; 52 | 53 | SaslClientFirstMessage(this.bytesToSendToServer, this.mechanismName); 54 | 55 | @override 56 | void applyToBuffer(PgByteDataWriter buffer) { 57 | buffer.writeUint8(ClientMessageId.password); 58 | 59 | final encodedMechanismName = buffer.encodeString(mechanismName); 60 | final msgLength = bytesToSendToServer.length; 61 | // No Identifier bit + 4 byte counts (for whole length) + mechanism bytes + zero byte + 4 byte counts (for msg length) + msg bytes 62 | final length = 4 + encodedMechanismName.bytesLength + 1 + 4 + msgLength; 63 | 64 | buffer.writeUint32(length); 65 | buffer.writeEncodedString(encodedMechanismName); 66 | 67 | // do not add the msg byte count for whatever reason 68 | buffer.writeUint32(msgLength); 69 | buffer.write(bytesToSendToServer); 70 | } 71 | } 72 | 73 | class SaslClientLastMessage extends ClientMessage { 74 | Uint8List bytesToSendToServer; 75 | 76 | SaslClientLastMessage(this.bytesToSendToServer); 77 | 78 | @override 79 | void applyToBuffer(PgByteDataWriter buffer) { 80 | buffer.writeUint8(ClientMessageId.password); 81 | 82 | // No Identifier bit + 4 byte counts (for msg length) + msg bytes 83 | final length = 4 + bytesToSendToServer.length; 84 | 85 | buffer.writeUint32(length); 86 | buffer.write(bytesToSendToServer); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/src/buffer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:buffer/buffer.dart'; 4 | import 'package:postgres/src/types/codec.dart'; 5 | 6 | /// This class doesn't add much over using `List` instead, however, 7 | /// it creates a nice explicit type difference from both `String` and `List`, 8 | /// and it allows better use for the string encoding that delimits the value with `0`. 9 | class EncodedString { 10 | final List _bytes; 11 | EncodedString._(this._bytes); 12 | 13 | int get bytesLength => _bytes.length; 14 | } 15 | 16 | class PgByteDataWriter extends ByteDataWriter { 17 | final Encoding encoding; 18 | 19 | PgByteDataWriter({ 20 | super.bufferLength, 21 | required this.encoding, 22 | }); 23 | 24 | late final encodingName = encodeString(encoding.name); 25 | 26 | EncodedString encodeString(String value) { 27 | return EncodedString._(encoding.encode(value)); 28 | } 29 | 30 | void writeEncodedString(EncodedString value) { 31 | write(value._bytes); 32 | writeInt8(0); 33 | } 34 | 35 | void writeLengthEncodedString(String value) { 36 | final encoded = encodeString(value); 37 | writeUint32(5 + encoded.bytesLength); 38 | write(encoded._bytes); 39 | writeInt8(0); 40 | } 41 | } 42 | 43 | const _emptyString = ''; 44 | 45 | class PgByteDataReader extends ByteDataReader { 46 | final CodecContext codecContext; 47 | 48 | PgByteDataReader({ 49 | required this.codecContext, 50 | }); 51 | 52 | Encoding get encoding => codecContext.encoding; 53 | 54 | String readNullTerminatedString() { 55 | final bytes = readUntilTerminatingByte(0); 56 | if (bytes.isEmpty) { 57 | return _emptyString; 58 | } 59 | return codecContext.encoding.decode(bytes); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/src/exceptions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:collection/collection.dart'; 5 | 6 | import 'messages/server_messages.dart'; 7 | 8 | /// The severity level of a [PgException]. 9 | /// 10 | /// [panic] and [fatal] errors will close the connection. 11 | enum Severity { 12 | /// A [PgException] with this severity indicates the throwing connection is now closed. 13 | panic, 14 | 15 | /// A [PgException] with this severity indicates the throwing connection is now closed. 16 | fatal, 17 | 18 | /// A [PgException] with this severity indicates the throwing connection encountered an error when executing a query and the query has failed. 19 | error, 20 | 21 | /// Currently unsupported. 22 | warning, 23 | 24 | /// Currently unsupported. 25 | notice, 26 | 27 | /// Currently unsupported. 28 | debug, 29 | 30 | /// Currently unsupported. 31 | info, 32 | 33 | /// Currently unsupported. 34 | log, 35 | 36 | /// A [PgException] with this severity indicates a failed a precondition or other error that doesn't originate from the database. 37 | unknown, 38 | ; 39 | 40 | static Severity _parseServerMessage(String? value) { 41 | switch (value) { 42 | case 'ERROR': 43 | return Severity.error; 44 | case 'FATAL': 45 | return Severity.fatal; 46 | case 'PANIC': 47 | return Severity.panic; 48 | case 'WARNING': 49 | return Severity.warning; 50 | case 'NOTICE': 51 | return Severity.notice; 52 | case 'DEBUG': 53 | return Severity.debug; 54 | case 'INFO': 55 | return Severity.info; 56 | case 'LOG': 57 | return Severity.log; 58 | default: 59 | return Severity.unknown; 60 | } 61 | } 62 | } 63 | 64 | /// Exception thrown by the package (client or server side). 65 | class PgException implements Exception { 66 | /// The severity of the exception. 67 | final Severity severity; 68 | 69 | /// A message indicating the error. 70 | final String message; 71 | 72 | PgException( 73 | this.message, { 74 | this.severity = Severity.error, 75 | }); 76 | 77 | @override 78 | String toString() => '$severity $message'; 79 | } 80 | 81 | /// Exception thrown when server certificate validate failed. 82 | class BadCertificateException extends PgException { 83 | final X509Certificate certificate; 84 | 85 | BadCertificateException(this.certificate) 86 | : super('Bad server certificate.', severity: Severity.fatal); 87 | } 88 | 89 | /// Exception thrown by the server. 90 | class ServerException extends PgException { 91 | /// An index into an executed query string where an error occurred, if by provided by the database. 92 | final int? position; 93 | 94 | /// An index into a query string generated by the database, if provided. 95 | final int? internalPosition; 96 | 97 | final int? lineNumber; 98 | 99 | /// The PostgreSQL error code. 100 | /// 101 | /// May be null if the exception was not generated by the database. 102 | final String? code; 103 | 104 | /// Additional details if provided by the database. 105 | final String? detail; 106 | 107 | /// A hint on how to remedy an error, if provided by the database. 108 | final String? hint; 109 | 110 | final String? internalQuery; 111 | final String? trace; 112 | 113 | final String? schemaName; 114 | final String? tableName; 115 | final String? columnName; 116 | final String? dataTypeName; 117 | final String? constraintName; 118 | final String? fileName; 119 | final String? routineName; 120 | 121 | ServerException._( 122 | super.message, { 123 | required super.severity, 124 | this.position, 125 | this.internalPosition, 126 | this.lineNumber, 127 | this.code, 128 | this.detail, 129 | this.hint, 130 | this.internalQuery, 131 | this.trace, 132 | this.schemaName, 133 | this.tableName, 134 | this.columnName, 135 | this.dataTypeName, 136 | this.constraintName, 137 | this.fileName, 138 | this.routineName, 139 | }); 140 | 141 | ServerException._from(ServerException original) 142 | : this._( 143 | original.message, 144 | severity: original.severity, 145 | position: original.position, 146 | internalPosition: original.internalPosition, 147 | lineNumber: original.lineNumber, 148 | code: original.code, 149 | detail: original.detail, 150 | hint: original.hint, 151 | internalQuery: original.internalQuery, 152 | trace: original.trace, 153 | schemaName: original.schemaName, 154 | tableName: original.tableName, 155 | columnName: original.columnName, 156 | dataTypeName: original.dataTypeName, 157 | constraintName: original.constraintName, 158 | fileName: original.fileName, 159 | routineName: original.routineName, 160 | ); 161 | 162 | @override 163 | String toString() { 164 | final buff = StringBuffer('$severity $code: $message'); 165 | if (detail != null) { 166 | buff.write(' detail: $detail'); 167 | } 168 | if (hint != null) { 169 | buff.write(' hint: $hint'); 170 | } 171 | if (tableName != null) { 172 | buff.write(' table: $tableName'); 173 | } 174 | if (columnName != null) { 175 | buff.write(' column: $columnName'); 176 | } 177 | if (constraintName != null) { 178 | buff.write(' constraint $constraintName'); 179 | } 180 | return buff.toString(); 181 | } 182 | } 183 | 184 | ServerException buildExceptionFromErrorFields(List errorFields) { 185 | String? findString(int identifier) => 186 | errorFields.firstWhereOrNull((ErrorField e) => e.id == identifier)?.text; 187 | 188 | int? findInt(int identifier) { 189 | final i = findString(identifier); 190 | return i == null ? null : int.parse(i); 191 | } 192 | 193 | return ServerException._( 194 | findString(ErrorFieldId.message) ?? 'Server error.', 195 | severity: Severity._parseServerMessage(findString(ErrorFieldId.severity)), 196 | position: findInt(ErrorFieldId.position), 197 | internalPosition: findInt(ErrorFieldId.internalPosition), 198 | lineNumber: findInt(ErrorFieldId.line), 199 | code: findString(ErrorFieldId.code), 200 | detail: findString(ErrorFieldId.detail), 201 | hint: findString(ErrorFieldId.hint), 202 | internalQuery: findString(ErrorFieldId.internalQuery), 203 | trace: findString(ErrorFieldId.where), 204 | schemaName: findString(ErrorFieldId.schema), 205 | tableName: findString(ErrorFieldId.table), 206 | columnName: findString(ErrorFieldId.column), 207 | dataTypeName: findString(ErrorFieldId.dataType), 208 | constraintName: findString(ErrorFieldId.constraint), 209 | fileName: findString(ErrorFieldId.file), 210 | routineName: findString(ErrorFieldId.routine), 211 | ); 212 | } 213 | 214 | PgException transformServerException(ServerException ex) { 215 | // TODO: consider adding more exception from https://www.postgresql.org/docs/current/errcodes-appendix.html 216 | return switch (ex.code) { 217 | '23505' => UniqueViolationException._(ex), 218 | '23503' => ForeignKeyViolationException._(ex), 219 | '57014' => _PgQueryCancelledException._( 220 | ex, 221 | // [ex.message, ex.trace].whereType().join(' '), 222 | ), 223 | _ => ex, 224 | }; 225 | } 226 | 227 | class _PgQueryCancelledException extends ServerException 228 | implements TimeoutException { 229 | @override 230 | late final duration = null; 231 | 232 | _PgQueryCancelledException._(super.original) : super._from(); 233 | } 234 | 235 | class UniqueViolationException extends ServerException { 236 | UniqueViolationException._(super.original) : super._from(); 237 | } 238 | 239 | class ForeignKeyViolationException extends ServerException { 240 | ForeignKeyViolationException._(super.original) : super._from(); 241 | } 242 | -------------------------------------------------------------------------------- /lib/src/message_window.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:buffer/buffer.dart'; 5 | import 'package:charcode/ascii.dart'; 6 | import 'package:postgres/src/types/codec.dart'; 7 | 8 | import 'buffer.dart'; 9 | import 'messages/server_messages.dart'; 10 | import 'messages/shared_messages.dart'; 11 | 12 | const int _headerByteSize = 5; 13 | 14 | typedef _ServerMessageFn = FutureOr Function( 15 | PgByteDataReader reader, int length); 16 | 17 | Map _messageTypeMap = { 18 | 49: (_, __) => ParseCompleteMessage(), 19 | 50: (_, __) => BindCompleteMessage(), 20 | 65: (r, _) => NotificationResponseMessage.parse(r), 21 | 67: (r, _) => CommandCompleteMessage.parse(r), 22 | 68: (r, _) => DataRowMessage.parse(r), 23 | 69: ErrorResponseMessage.parse, 24 | 73: (_, __) => EmptyQueryResponseMessage(), 25 | 75: (r, _) => BackendKeyMessage.parse(r), 26 | 82: AuthenticationMessage.parse, 27 | 83: (r, l) => ParameterStatusMessage.parse(r), 28 | 84: (r, _) => RowDescriptionMessage.parse(r), 29 | 87: (r, _) => CopyBothResponseMessage.parse(r), 30 | 90: ReadyForQueryMessage.parse, 31 | 100: _parseCopyDataMessage, 32 | 110: (_, __) => NoDataMessage(), 33 | 116: (r, _) => ParameterDescriptionMessage.parse(r), 34 | $3: (_, __) => CloseCompleteMessage(), 35 | $N: NoticeMessage.parse, 36 | }; 37 | 38 | class _BytesFrame { 39 | final int type; 40 | final int length; 41 | final Uint8List bytes; 42 | 43 | _BytesFrame(this.type, this.length, this.bytes); 44 | } 45 | 46 | StreamTransformer bytesToMessageParser() { 47 | return StreamTransformer.fromHandlers( 48 | handleData: (data, sink) {}, 49 | ); 50 | } 51 | 52 | final _emptyData = Uint8List(0); 53 | 54 | class _BytesToFrameParser 55 | extends StreamTransformerBase { 56 | final CodecContext _codecContext; 57 | 58 | _BytesToFrameParser(this._codecContext); 59 | 60 | @override 61 | Stream<_BytesFrame> bind(Stream stream) async* { 62 | final reader = PgByteDataReader(codecContext: _codecContext); 63 | 64 | int? type; 65 | int expectedLength = 0; 66 | 67 | await for (final bytes in stream) { 68 | reader.add(bytes); 69 | 70 | while (true) { 71 | if (type == null && reader.remainingLength >= _headerByteSize) { 72 | type = reader.readUint8(); 73 | expectedLength = reader.readUint32() - 4; 74 | } 75 | 76 | // special case 77 | if (type == SharedMessageId.copyDone) { 78 | // unlike other messages, CopyDoneMessage only takes the length as an 79 | // argument (must be the full length including the length bytes) 80 | yield _BytesFrame(type!, expectedLength, _emptyData); 81 | type = null; 82 | expectedLength = 0; 83 | continue; 84 | } 85 | 86 | if (type != null && expectedLength <= reader.remainingLength) { 87 | final data = reader.read(expectedLength); 88 | yield _BytesFrame(type, expectedLength, data); 89 | type = null; 90 | expectedLength = 0; 91 | continue; 92 | } 93 | 94 | break; 95 | } 96 | } 97 | } 98 | } 99 | 100 | class BytesToMessageParser 101 | extends StreamTransformerBase { 102 | final CodecContext _codecContext; 103 | 104 | BytesToMessageParser(this._codecContext); 105 | 106 | @override 107 | Stream bind(Stream stream) { 108 | return stream 109 | .transform(_BytesToFrameParser(_codecContext)) 110 | .asyncMap((frame) async { 111 | // special case 112 | if (frame.type == SharedMessageId.copyDone) { 113 | // unlike other messages, CopyDoneMessage only takes the length as an 114 | // argument (must be the full length including the length bytes) 115 | return CopyDoneMessage(frame.length + 4); 116 | } 117 | 118 | final msgMaker = _messageTypeMap[frame.type]; 119 | if (msgMaker == null) { 120 | return UnknownMessage(frame.type, frame.bytes); 121 | } 122 | 123 | return await msgMaker( 124 | PgByteDataReader(codecContext: _codecContext)..add(frame.bytes), 125 | frame.bytes.length); 126 | }); 127 | } 128 | } 129 | 130 | /// Copy Data message is a wrapper around data stream messages 131 | /// such as replication messages. 132 | /// Returns a [ReplicationMessage] if the message contains such message. 133 | /// Otherwise, it'll just return the provided bytes as [CopyDataMessage]. 134 | Future _parseCopyDataMessage( 135 | PgByteDataReader reader, int length) async { 136 | final code = reader.readUint8(); 137 | if (code == ReplicationMessageId.primaryKeepAlive) { 138 | return PrimaryKeepAliveMessage.parse(reader); 139 | } else if (code == ReplicationMessageId.xLogData) { 140 | // ignore: deprecated_member_use_from_same_package 141 | return XLogDataMessage.parse( 142 | reader.read(length - 1), 143 | reader.encoding, 144 | codecContext: reader.codecContext, 145 | ); 146 | } else { 147 | final bb = BytesBuffer(); 148 | bb.addByte(code); 149 | bb.add(reader.read(length - 1)); 150 | return CopyDataMessage(bb.toBytes()); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /lib/src/messages/shared_messages.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import '../buffer.dart'; 4 | import 'client_messages.dart'; 5 | import 'server_messages.dart'; 6 | 7 | /// Either a [ServerMessage] or a [ClientMessage]. 8 | abstract class Message { 9 | const Message(); 10 | } 11 | 12 | abstract class ReplicationMessageId { 13 | static const int primaryKeepAlive = 107; // k 14 | static const int xLogData = 119; // w 15 | static const int hotStandbyFeedback = 104; // h 16 | static const int standbyStatusUpdate = 114; // r 17 | } 18 | 19 | /// An abstraction for all client and server replication messages 20 | /// 21 | /// For more details, see [Streaming Replication Protocol][] 22 | /// 23 | /// [Streaming Replication Protocol]: https://www.postgresql.org/docs/current/protocol-replication.html 24 | abstract class ReplicationMessage {} 25 | 26 | abstract class SharedMessageId { 27 | static const int copyDone = 99; // c 28 | static const int copyData = 100; // d 29 | } 30 | 31 | /// Messages that are shared between both the server and the client 32 | /// 33 | /// For more details, see [Message Formats][] 34 | /// 35 | /// [Message Formats]: https://www.postgresql.org/docs/current/protocol-message-formats.html 36 | abstract class SharedMessage extends ClientMessage implements ServerMessage {} 37 | 38 | /// A COPY data message. 39 | class CopyDataMessage extends SharedMessage { 40 | /// Data that forms part of a COPY data stream. Messages sent from the backend 41 | /// will always correspond to single data rows, but messages sent by frontends 42 | /// might divide the data stream arbitrarily. 43 | final Uint8List bytes; 44 | 45 | CopyDataMessage(this.bytes); 46 | 47 | @override 48 | void applyToBuffer(PgByteDataWriter buffer) { 49 | buffer.writeUint8(SharedMessageId.copyData); 50 | buffer.writeInt32(bytes.length + 4); 51 | buffer.write(bytes); 52 | } 53 | } 54 | 55 | /// A COPY-complete indicator. 56 | class CopyDoneMessage extends SharedMessage { 57 | /// Length of message contents in bytes, including self. 58 | late final int length; 59 | 60 | CopyDoneMessage(this.length); 61 | 62 | @override 63 | void applyToBuffer(PgByteDataWriter buffer) { 64 | buffer.writeUint8(SharedMessageId.copyDone); 65 | buffer.writeInt32(length); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/src/pool/pool_api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | import '../../postgres.dart'; 6 | import 'pool_impl.dart'; 7 | 8 | class PoolSettings extends ConnectionSettings { 9 | /// The maximum number of concurrent sessions. 10 | final int? maxConnectionCount; 11 | 12 | /// The maximum duration a connection is kept open. 13 | /// New sessions won't be scheduled after this limit is reached. 14 | final Duration? maxConnectionAge; 15 | 16 | /// The maximum duration a connection is used by sessions. 17 | /// New sessions won't be scheduled after this limit is reached. 18 | final Duration? maxSessionUse; 19 | 20 | /// The maximum number of queries to be run on a connection. 21 | /// New sessions won't be scheduled after this limit is reached. 22 | /// 23 | /// NOTE: not yet implemented 24 | final int? maxQueryCount; 25 | 26 | const PoolSettings({ 27 | this.maxConnectionCount, 28 | this.maxConnectionAge, 29 | this.maxSessionUse, 30 | this.maxQueryCount, 31 | super.applicationName, 32 | super.connectTimeout, 33 | super.sslMode, 34 | super.securityContext, 35 | super.encoding, 36 | super.timeZone, 37 | super.replicationMode, 38 | super.transformer, 39 | super.queryTimeout, 40 | super.queryMode, 41 | super.ignoreSuperfluousParameters, 42 | super.onOpen, 43 | super.typeRegistry, 44 | }); 45 | } 46 | 47 | /// A connection pool that may select endpoints based on the requested locality 48 | /// [L] of the data. 49 | /// 50 | /// A data locality can be an arbitrary value that the pool's [EndpointSelector] 51 | /// understands, and may return a connection based on, e.g: 52 | /// - an action, which applies to different database or user, 53 | /// - a tenant, which may be specified for multi-tenant applications, 54 | /// - a table and key, which may be specified for distributed databases 55 | /// (e.g. CockroachDB) for selecting the node that is the leader for the 56 | /// specified data range. 57 | /// - a primary/replica status, whic may be specified for cases where stale or 58 | /// read-only data is acceptable 59 | abstract class Pool implements Session, SessionExecutor { 60 | factory Pool.withSelector( 61 | EndpointSelector selector, { 62 | PoolSettings? settings, 63 | }) => 64 | PoolImplementation(selector, settings); 65 | 66 | /// Creates a connection pool from a fixed list of endpoints. 67 | factory Pool.withEndpoints( 68 | List endpoints, { 69 | PoolSettings? settings, 70 | }) => 71 | PoolImplementation(roundRobinSelector(endpoints), settings); 72 | 73 | /// Acquires a connection from this pool, opening a new one if necessary, and 74 | /// calls [fn] with it. 75 | /// 76 | /// The connection must not be used after [fn] returns as it could be used by 77 | /// another [withConnection] call later. 78 | Future withConnection( 79 | Future Function(Connection connection) fn, { 80 | ConnectionSettings? settings, 81 | L? locality, 82 | }); 83 | 84 | @override 85 | Future run( 86 | Future Function(Session session) fn, { 87 | SessionSettings? settings, 88 | L? locality, 89 | }); 90 | 91 | @override 92 | Future runTx( 93 | Future Function(TxSession session) fn, { 94 | TransactionSettings? settings, 95 | L? locality, 96 | }); 97 | 98 | // TODO: decide whether PgSession.execute and prepare methods should also take locality parameter 99 | } 100 | 101 | typedef EndpointSelector = FutureOr Function( 102 | EndpointSelectorContext context); 103 | 104 | final class EndpointSelectorContext { 105 | final L? locality; 106 | // TODO: expose currently open/idle connections/endpoints 107 | // TODO: expose usage and latency information about endpoints 108 | 109 | @internal 110 | EndpointSelectorContext({ 111 | required this.locality, 112 | }); 113 | } 114 | 115 | class EndpointSelection { 116 | final Endpoint endpoint; 117 | // TODO: add optional SessionSettings + merge with defaults 118 | 119 | EndpointSelection({ 120 | required this.endpoint, 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /lib/src/pool/pool_impl.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:collection/collection.dart'; 4 | import 'package:pool/pool.dart' as pool; 5 | import 'package:postgres/src/utils/package_pool_ext.dart'; 6 | 7 | import '../../postgres.dart'; 8 | import '../v3/connection.dart'; 9 | import '../v3/resolved_settings.dart'; 10 | 11 | EndpointSelector roundRobinSelector(List endpoints) { 12 | int nextIndex = 0; 13 | return (EndpointSelectorContext context) { 14 | final endpoint = endpoints[nextIndex]; 15 | nextIndex = (nextIndex + 1) % endpoints.length; 16 | return EndpointSelection(endpoint: endpoint); 17 | }; 18 | } 19 | 20 | class PoolImplementation implements Pool { 21 | final EndpointSelector _selector; 22 | final ResolvedPoolSettings _settings; 23 | 24 | final _connections = <_PoolConnection>[]; 25 | late final _maxConnectionCount = _settings.maxConnectionCount; 26 | late final _semaphore = pool.Pool(_maxConnectionCount); 27 | late final _connectLock = pool.Pool(1); 28 | bool _closing = false; 29 | 30 | PoolImplementation(this._selector, PoolSettings? settings) 31 | : _settings = ResolvedPoolSettings(settings); 32 | 33 | @override 34 | bool get isOpen => !_closing; 35 | 36 | @override 37 | Future get closed => _semaphore.done; 38 | 39 | @override 40 | Future close({bool force = false}) async { 41 | _closing = true; 42 | final semaphoreFuture = _semaphore.close(); 43 | 44 | // Connections are closed when they are returned to the pool if it's closed. 45 | // We still need to close statements that are currently unused. 46 | for (final connection in [..._connections]) { 47 | if (force || !connection._isInUse) { 48 | await connection._dispose(force: force); 49 | } 50 | } 51 | 52 | await semaphoreFuture; 53 | } 54 | 55 | @override 56 | Future execute( 57 | Object query, { 58 | Object? parameters, 59 | bool ignoreRows = false, 60 | QueryMode? queryMode, 61 | Duration? timeout, 62 | }) { 63 | return withConnection((connection) => connection.execute( 64 | query, 65 | parameters: parameters, 66 | ignoreRows: ignoreRows, 67 | queryMode: queryMode, 68 | timeout: timeout, 69 | )); 70 | } 71 | 72 | @override 73 | Future prepare(Object query) async { 74 | final statementCompleter = Completer(); 75 | 76 | unawaited(withConnection((connection) async { 77 | _PoolStatement? poolStatement; 78 | 79 | try { 80 | final statement = await connection.prepare(query); 81 | poolStatement = _PoolStatement(statement); 82 | } on Object catch (e, s) { 83 | // Could not prepare the statement, inform the caller and stop occupying 84 | // the connection. 85 | statementCompleter.completeError(e, s); 86 | return; 87 | } 88 | 89 | // Otherwise, make the future returned by prepare complete with the 90 | // statement. 91 | statementCompleter.complete(poolStatement); 92 | 93 | // And keep this connection reserved until the statement has been disposed. 94 | return poolStatement._disposed.future; 95 | })); 96 | 97 | return statementCompleter.future; 98 | } 99 | 100 | @override 101 | Future run( 102 | Future Function(Session session) fn, { 103 | SessionSettings? settings, 104 | L? locality, 105 | }) { 106 | return withConnection( 107 | (connection) => connection.run(fn, settings: settings), 108 | locality: locality, 109 | ); 110 | } 111 | 112 | @override 113 | Future runTx( 114 | Future Function(TxSession session) fn, { 115 | TransactionSettings? settings, 116 | L? locality, 117 | }) { 118 | return withConnection( 119 | (connection) => connection.runTx( 120 | fn, 121 | settings: settings, 122 | ), 123 | locality: locality, 124 | ); 125 | } 126 | 127 | @override 128 | Future withConnection( 129 | Future Function(Connection connection) fn, { 130 | ConnectionSettings? settings, 131 | L? locality, 132 | }) async { 133 | final resource = 134 | await _semaphore.requestWithTimeout(_settings.connectTimeout); 135 | _PoolConnection? connection; 136 | bool reuse = true; 137 | final sw = Stopwatch(); 138 | try { 139 | final context = EndpointSelectorContext( 140 | locality: locality, 141 | ); 142 | final selection = await _selector(context); 143 | 144 | // Find an existing connection that is currently unused, or open another 145 | // one. 146 | connection = await _selectOrCreate( 147 | selection.endpoint, 148 | ResolvedConnectionSettings(settings, this._settings), 149 | ); 150 | 151 | sw.start(); 152 | try { 153 | return await fn(connection); 154 | } catch (_) { 155 | reuse = false; 156 | rethrow; 157 | } 158 | } finally { 159 | resource.release(); 160 | sw.stop(); 161 | 162 | // If the pool has been closed, this connection needs to be closed as 163 | // well. 164 | if (connection != null) { 165 | connection._elapsedInUse += sw.elapsed; 166 | if (_closing || !reuse || !connection.isOpen) { 167 | await connection._dispose(); 168 | } else { 169 | // Allow the connection to be re-used later. 170 | connection._isInUse = false; 171 | connection._lastReturned = DateTime.now(); 172 | } 173 | } 174 | } 175 | } 176 | 177 | Future<_PoolConnection> _selectOrCreate( 178 | Endpoint endpoint, ResolvedConnectionSettings settings) async { 179 | final oldc = 180 | _connections.firstWhereOrNull((c) => c._mayReuse(endpoint, settings)); 181 | if (oldc != null) { 182 | // NOTE: It is important to update the _isInUse flag here, otherwise 183 | // race conditions may create conflicts. 184 | oldc._isInUse = true; 185 | return oldc; 186 | } 187 | 188 | return await _connectLock 189 | .withRequestTimeout(timeout: _settings.connectTimeout, (_) async { 190 | while (_connections.length >= _maxConnectionCount) { 191 | final candidates = 192 | _connections.where((c) => c._isInUse == false).toList(); 193 | if (candidates.isEmpty) { 194 | throw StateError('The pool should not be in this state.'); 195 | } 196 | final selected = candidates.reduce( 197 | (a, b) => a._lastReturned.isBefore(b._lastReturned) ? a : b); 198 | await selected._dispose(); 199 | } 200 | 201 | final newc = _PoolConnection( 202 | this, 203 | endpoint, 204 | settings, 205 | await PgConnectionImplementation.connect( 206 | endpoint, 207 | connectionSettings: settings, 208 | ), 209 | ); 210 | newc._isInUse = true; 211 | // NOTE: It is important to update _connections list after the isInUse 212 | // flag is set, otherwise race conditions may create conflicts or 213 | // pool close may miss the connection. 214 | _connections.add(newc); 215 | return newc; 216 | }); 217 | } 218 | } 219 | 220 | /// An opened [Connection] we're able to use in [Pool.withConnection]. 221 | class _PoolConnection implements Connection { 222 | final _opened = DateTime.now(); 223 | final PoolImplementation _pool; 224 | final Endpoint _endpoint; 225 | final ResolvedConnectionSettings _connectionSettings; 226 | final PgConnectionImplementation _connection; 227 | Duration _elapsedInUse = Duration.zero; 228 | DateTime _lastReturned = DateTime.now(); 229 | bool _isInUse = false; 230 | 231 | _PoolConnection( 232 | this._pool, this._endpoint, this._connectionSettings, this._connection); 233 | 234 | bool _mayReuse(Endpoint endpoint, ResolvedConnectionSettings settings) { 235 | if (_isInUse || endpoint != _endpoint || _isExpired() || !isOpen) { 236 | return false; 237 | } 238 | if (!_connectionSettings.isMatchingConnection(settings)) { 239 | return false; 240 | } 241 | return true; 242 | } 243 | 244 | bool _isExpired() { 245 | final age = DateTime.now().difference(_opened); 246 | if (age >= _pool._settings.maxConnectionAge) { 247 | return true; 248 | } 249 | if (_elapsedInUse >= _pool._settings.maxSessionUse) { 250 | return true; 251 | } 252 | if (_connection.queryCount >= _pool._settings.maxQueryCount) { 253 | return true; 254 | } 255 | return false; 256 | } 257 | 258 | Future _dispose({bool force = false}) async { 259 | _pool._connections.remove(this); 260 | await _connection.close(force: force); 261 | } 262 | 263 | @override 264 | bool get isOpen => _connection.isOpen; 265 | 266 | @override 267 | Future get closed => _connection.closed; 268 | 269 | @override 270 | ConnectionInfo get info => _connection.info; 271 | 272 | @override 273 | Channels get channels { 274 | throw UnsupportedError( 275 | 'Channels are not supported in pools because they would require keeping ' 276 | 'the connection open even after `withConnection` has returned.', 277 | ); 278 | } 279 | 280 | @override 281 | Future close({bool force = false}) async { 282 | // Don't forward the close call unless forcing. The underlying connection should be re-used 283 | // when another pool connection is requested. 284 | 285 | if (force) { 286 | await _connection.close(force: force); 287 | } 288 | } 289 | 290 | @override 291 | Future execute( 292 | Object query, { 293 | Object? parameters, 294 | bool ignoreRows = false, 295 | QueryMode? queryMode, 296 | Duration? timeout, 297 | }) { 298 | return _connection.execute( 299 | query, 300 | parameters: parameters, 301 | ignoreRows: ignoreRows, 302 | queryMode: queryMode, 303 | timeout: timeout, 304 | ); 305 | } 306 | 307 | @override 308 | Future prepare(Object query) { 309 | return _connection.prepare(query); 310 | } 311 | 312 | @override 313 | Future run( 314 | Future Function(Session session) fn, { 315 | SessionSettings? settings, 316 | }) { 317 | return _connection.run(fn, settings: settings); 318 | } 319 | 320 | @override 321 | Future runTx( 322 | Future Function(TxSession session) fn, { 323 | TransactionSettings? settings, 324 | }) { 325 | return _connection.runTx( 326 | fn, 327 | settings: settings, 328 | ); 329 | } 330 | } 331 | 332 | class _PoolStatement implements Statement { 333 | final _disposed = Completer(); 334 | final Statement _underlying; 335 | 336 | _PoolStatement(this._underlying); 337 | 338 | @override 339 | ResultStream bind(Object? parameters) => _underlying.bind(parameters); 340 | 341 | @override 342 | Future dispose() async { 343 | _disposed.complete(); 344 | await _underlying.dispose(); 345 | } 346 | 347 | @override 348 | Future run( 349 | Object? parameters, { 350 | Duration? timeout, 351 | }) { 352 | return _underlying.run(parameters, timeout: timeout); 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /lib/src/replication.dart: -------------------------------------------------------------------------------- 1 | // TODO: these types could move to a common "connection_config.dart" file 2 | 3 | /// Streaming Replication Protocol Options 4 | /// 5 | /// [physical] or [logical] are used to start the connection a streaming 6 | /// replication mode. 7 | /// 8 | /// See [Protocol Replication][] for more details. 9 | /// 10 | /// [Protocol Replication]: https://www.postgresql.org/docs/current/protocol-replication.html 11 | enum ReplicationMode { 12 | physical('true'), 13 | logical('database'), 14 | none('false'); 15 | 16 | final String value; 17 | 18 | const ReplicationMode(this.value); 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/time_converters.dart: -------------------------------------------------------------------------------- 1 | final _microsecFromUnixEpochToY2K = 2 | DateTime.utc(2000, 1, 1).microsecondsSinceEpoch; 3 | 4 | DateTime dateTimeFromMicrosecondsSinceY2k(int microSecondsSinceY2K) { 5 | final microsecSinceUnixEpoch = 6 | _microsecFromUnixEpochToY2K + microSecondsSinceY2K; 7 | return DateTime.fromMicrosecondsSinceEpoch(microsecSinceUnixEpoch, 8 | isUtc: true); 9 | } 10 | 11 | int dateTimeToMicrosecondsSinceY2k(DateTime time) { 12 | final microsecSinceUnixEpoch = time.toUtc().microsecondsSinceEpoch; 13 | return microsecSinceUnixEpoch - _microsecFromUnixEpochToY2K; 14 | } 15 | -------------------------------------------------------------------------------- /lib/src/types/codec.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:typed_data'; 4 | 5 | import '../buffer.dart'; 6 | import '../types.dart'; 7 | import '../v3/connection_info.dart'; 8 | import '../v3/database_info.dart'; 9 | import 'type_registry.dart'; 10 | 11 | /// Represents the [bytes] of a received (field) or sent (parameter) value. 12 | /// 13 | /// The [format] describes whether the [bytes] are formatted as text (e.g. `12`) 14 | /// or bytes (e.g. `0x0c`). 15 | class EncodedValue { 16 | /// The encoded bytes of the value. 17 | final Uint8List? bytes; 18 | 19 | /// The format of the [bytes]. 20 | final EncodingFormat format; 21 | 22 | /// The type OID - if available. 23 | final int? typeOid; 24 | 25 | EncodedValue( 26 | this.bytes, { 27 | required this.format, 28 | this.typeOid, 29 | }); 30 | 31 | EncodedValue.binary( 32 | this.bytes, { 33 | this.typeOid, 34 | }) : format = EncodingFormat.binary; 35 | 36 | EncodedValue.text( 37 | this.bytes, { 38 | this.typeOid, 39 | }) : format = EncodingFormat.text; 40 | 41 | EncodedValue.null$({ 42 | this.format = EncodingFormat.binary, 43 | this.typeOid, 44 | }) : bytes = null; 45 | 46 | bool get isBinary => format == EncodingFormat.binary; 47 | bool get isText => format == EncodingFormat.text; 48 | } 49 | 50 | /// Describes whether the bytes are formatted as [text] (e.g. `12`) or 51 | /// [binary] (e.g. `0x0c`) 52 | enum EncodingFormat { 53 | binary, 54 | text, 55 | ; 56 | 57 | static EncodingFormat fromBinaryFlag(bool isBinary) => 58 | isBinary ? binary : text; 59 | } 60 | 61 | /// Encodes the [input] value and returns an [EncodedValue] object. 62 | /// 63 | /// May return `null` if the encoder is not able to convert the [input] value. 64 | typedef EncoderFn = FutureOr Function( 65 | TypedValue input, CodecContext context); 66 | 67 | /// Encoder and decoder for a value stored in Postgresql. 68 | abstract class Codec { 69 | /// Encodes the [input] value and returns an [EncodedValue] object. 70 | /// 71 | /// May return `null` if the codec is not able to encode the [input]. 72 | FutureOr encode(TypedValue input, CodecContext context); 73 | 74 | /// Decodes the [input] value and returns a Dart value object. 75 | /// 76 | /// May return [UndecodedBytes] or the same [input] instance if the codec 77 | /// is not able to decode the [input]. 78 | FutureOr decode(EncodedValue input, CodecContext context); 79 | } 80 | 81 | /// Provides access to connection and database information, and also to additional codecs. 82 | class CodecContext { 83 | final ConnectionInfo connectionInfo; 84 | final DatabaseInfo databaseInfo; 85 | final Encoding encoding; 86 | final TypeRegistry typeRegistry; 87 | 88 | CodecContext({ 89 | required this.connectionInfo, 90 | required this.databaseInfo, 91 | required this.encoding, 92 | required this.typeRegistry, 93 | }); 94 | 95 | factory CodecContext.withDefaults({ 96 | ConnectionInfo? connectionInfo, 97 | DatabaseInfo? databaseInfo, 98 | Encoding? encoding, 99 | TypeRegistry? typeRegistry, 100 | }) { 101 | return CodecContext( 102 | connectionInfo: connectionInfo ?? ConnectionInfo(), 103 | databaseInfo: databaseInfo ?? DatabaseInfo(), 104 | encoding: encoding ?? utf8, 105 | typeRegistry: typeRegistry ?? TypeRegistry(), 106 | ); 107 | } 108 | 109 | PgByteDataReader newPgByteDataReader([Uint8List? bytes]) { 110 | final reader = PgByteDataReader(codecContext: this); 111 | if (bytes != null) { 112 | reader.add(bytes); 113 | } 114 | return reader; 115 | } 116 | 117 | PgByteDataWriter newPgByteDataWriter() { 118 | return PgByteDataWriter(encoding: encoding); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /lib/src/types/generic_type.dart: -------------------------------------------------------------------------------- 1 | import '../types.dart'; 2 | import 'binary_codec.dart'; 3 | import 'codec.dart'; 4 | import 'text_codec.dart'; 5 | 6 | class UnknownType extends Type { 7 | const UnknownType(super.oid); 8 | } 9 | 10 | class UnspecifiedType extends Type { 11 | const UnspecifiedType() : super(null); 12 | } 13 | 14 | /// NOTE: do not use this type in client code. 15 | class GenericType extends Type { 16 | const GenericType(super.oid); 17 | } 18 | 19 | class GenericCodec extends Codec { 20 | final int oid; 21 | 22 | /// Whether the `null` value is handled as a special case by this codec. 23 | /// 24 | /// By default Dart `null` values are encoded as SQL `NULL` values, and 25 | /// [Codec] will not recieve the `null` value on its [encode] method. 26 | /// 27 | /// When the flag is set (`true`) the [Codec.encode] will recieve `null` 28 | /// as `input` value. 29 | final bool encodesNull; 30 | 31 | GenericCodec( 32 | this.oid, { 33 | this.encodesNull = false, 34 | }); 35 | 36 | @override 37 | EncodedValue? encode(TypedValue input, CodecContext context) { 38 | final value = input.value; 39 | if (!encodesNull && value == null) { 40 | return null; 41 | } 42 | final encoder = PostgresBinaryEncoder(oid); 43 | final bytes = encoder.convert(value, context.encoding); 44 | return EncodedValue.binary(bytes); 45 | } 46 | 47 | @override 48 | Object? decode(EncodedValue input, CodecContext context) { 49 | final bytes = input.bytes; 50 | if (bytes == null) { 51 | return null; 52 | } 53 | if (input.isBinary) { 54 | return PostgresBinaryDecoder.convert(context, oid, bytes); 55 | } else { 56 | return PostgresTextDecoder.convert(context, oid, bytes); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/types/geo_types.dart: -------------------------------------------------------------------------------- 1 | import 'dart:core'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | /// Describes PostgreSQL's geometric type: `point`. 6 | @immutable 7 | class Point { 8 | /// also referred as `x` 9 | final double latitude; 10 | 11 | /// also referred as `y` 12 | final double longitude; 13 | 14 | const Point(this.latitude, this.longitude); 15 | 16 | @override 17 | bool operator ==(Object other) => 18 | identical(this, other) || 19 | other is Point && 20 | runtimeType == other.runtimeType && 21 | latitude == other.latitude && 22 | longitude == other.longitude; 23 | 24 | @override 25 | int get hashCode => Object.hash(latitude, longitude); 26 | 27 | @override 28 | String toString() => 'Point($latitude,$longitude)'; 29 | } 30 | 31 | /// Describes PostgreSQL's geometric type: `line`. 32 | /// 33 | /// A line as defined by the linear equation _ax + by + c = 0_. 34 | /// 35 | /// See https://www.postgresql.org/docs/current/datatype-geometric.html#DATATYPE-LINE 36 | /// 37 | @immutable 38 | @sealed 39 | final class Line { 40 | final double a, b, c; 41 | 42 | Line(this.a, this.b, this.c) { 43 | if (a == 0 && b == 0) { 44 | throw ArgumentError('Line: a and b cannot both be zero'); 45 | } 46 | } 47 | 48 | @override 49 | String toString() => 'Line($a,$b,$c)'; 50 | 51 | @override 52 | bool operator ==(Object other) => 53 | identical(this, other) || 54 | other is Line && a == other.a && b == other.b && c == other.c; 55 | 56 | @override 57 | int get hashCode => Object.hash(a, b, c); 58 | } 59 | 60 | /// Describes PostgreSQL's geometric type: `lseg`. 61 | @immutable 62 | @sealed 63 | final class LineSegment { 64 | final Point p1, p2; 65 | 66 | LineSegment(this.p1, this.p2); 67 | 68 | @override 69 | String toString() => 'LineSegment($p1,$p2)'; 70 | 71 | @override 72 | bool operator ==(Object other) => 73 | identical(this, other) || 74 | other is LineSegment && p1 == other.p1 && p2 == other.p2; 75 | 76 | @override 77 | int get hashCode => Object.hash(p1, p2); 78 | } 79 | 80 | /// Describes PostgreSQL's geometric type: `box`. 81 | @immutable 82 | @sealed 83 | final class Box { 84 | late final Point _p1, _p2; 85 | 86 | /// Construct a [Box]. 87 | /// 88 | /// Reorders point coordinates as needed to store the upper right and lower left corners, in that order. 89 | /// 90 | /// https://www.postgresql.org/docs/current/datatype-geometric.html#DATATYPE-GEOMETRIC-BOXES 91 | Box(Point p1, Point p2) { 92 | final (x1, x2) = p1.latitude >= p2.latitude 93 | ? (p1.latitude, p2.latitude) 94 | : (p2.latitude, p1.latitude); 95 | final (y1, y2) = p1.longitude >= p2.longitude 96 | ? (p1.longitude, p2.longitude) 97 | : (p2.longitude, p1.longitude); 98 | _p1 = Point(x1, y1); 99 | _p2 = Point(x2, y2); 100 | } 101 | 102 | Point get p1 => _p1; 103 | 104 | Point get p2 => _p2; 105 | 106 | @override 107 | String toString() => 'Box($p1,$p2)'; 108 | 109 | @override 110 | bool operator ==(Object other) => 111 | identical(this, other) || 112 | other is Box && p1 == other.p1 && p2 == other.p2; 113 | 114 | @override 115 | int get hashCode => Object.hash(p1, p2); 116 | } 117 | 118 | /// Describes PostgreSQL's geometric type: `polygon`. 119 | @immutable 120 | @sealed 121 | final class Polygon { 122 | final List _points; 123 | 124 | List get points => _points; 125 | 126 | Polygon(Iterable points) : _points = List.unmodifiable(points) { 127 | if (_points.isEmpty) { 128 | throw ArgumentError('$runtimeType: at least one point required'); 129 | } 130 | } 131 | 132 | @override 133 | String toString() => 'Polygon(${points.map((e) => e.toString()).join(',')})'; 134 | 135 | @override 136 | bool operator ==(Object other) => 137 | identical(this, other) || 138 | other is Polygon && _allPointsAreEqual(other.points); 139 | 140 | bool _allPointsAreEqual(List otherPoints) { 141 | if (points.length != otherPoints.length) return false; 142 | for (int i = 0; i < points.length; i++) { 143 | if (points[i] != otherPoints[i]) return false; 144 | } 145 | return true; 146 | } 147 | 148 | @override 149 | int get hashCode => Object.hashAll(points); 150 | } 151 | 152 | /// Describes PostgreSQL's geometric type: `path`. 153 | @sealed 154 | final class Path extends Polygon { 155 | final bool open; 156 | 157 | Path(super.points, {required this.open}); 158 | 159 | @override 160 | String toString() => 161 | 'Path(${open ? 'open' : 'closed'},${points.map((e) => e.toString()).join(',')})'; 162 | 163 | @override 164 | bool operator ==(Object other) => 165 | identical(this, other) || 166 | other is Path && open == other.open && _allPointsAreEqual(other.points); 167 | 168 | @override 169 | int get hashCode => Object.hashAll([...points, open]); 170 | } 171 | 172 | @immutable 173 | @sealed 174 | final class Circle { 175 | final Point center; 176 | final double radius; 177 | 178 | Circle(this.center, this.radius) { 179 | if (radius < 0) throw ArgumentError('Circle: radius must not negative'); 180 | } 181 | 182 | @override 183 | String toString() => 'Circle($center,$radius)'; 184 | 185 | @override 186 | bool operator ==(Object other) => 187 | identical(this, other) || 188 | other is Circle && radius == other.radius && center == other.center; 189 | 190 | @override 191 | int get hashCode => Object.hash(center, radius); 192 | } 193 | -------------------------------------------------------------------------------- /lib/src/types/range_types.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | /// Describes PostgreSQL range bound state 4 | /// 5 | /// https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-INCLUSIVITY 6 | enum Bound { 7 | /// Equivalent to '(' or ')' 8 | exclusive, 9 | 10 | /// Equivalent to '[' or ']' 11 | inclusive, 12 | } 13 | 14 | /// Describes the bounds of PostgreSQL range types. 15 | /// 16 | /// https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-INCLUSIVITY 17 | @immutable 18 | @sealed 19 | final class Bounds { 20 | late final Bound lower; 21 | late final Bound upper; 22 | 23 | Bounds(this.lower, this.upper); 24 | 25 | /// Construct a [Bounds] instance from a PostgreSQL [flag] value. 26 | /// 27 | /// 28 | /// PostgreSQL stores a flag byte that determines range bounds and 29 | /// whether a range is empty. The default lower and upper bounds are `exclusive`. 30 | /// 31 | /// A range's flags byte contains these bits: 32 | /// - #define RANGE_EMPTY 0x01 (range is empty) 33 | /// - #define RANGE_LB_INC 0x02 (lower bound is inclusive) 34 | /// - #define RANGE_UB_INC 0x04 (upper bound is inclusive) 35 | /// - #define RANGE_LB_INF 0x08 (lower bound is -infinity) 36 | /// - #define RANGE_UB_INF 0x10 (upper bound is +infinity) 37 | /// 38 | /// - #define RANGE_LB_NULL 0x20 (lower bound is null (NOT USED)) 39 | /// - #define RANGE_UB_NULL 0x40 (upper bound is null (NOT USED)) 40 | /// - #define RANGE_CONTAIN_EMPTY 0x80 (marks a GiST internal-page entry whose subtree contains some empty ranges) 41 | /// 42 | /// See https://www.npgsql.org/doc/dev/type-representations.html 43 | Bounds.fromFlag(int flag) { 44 | switch (flag) { 45 | case 0 || 1 || 8 || 16 || 24: 46 | lower = Bound.exclusive; 47 | upper = Bound.exclusive; 48 | case 2 || 18: 49 | lower = Bound.inclusive; 50 | upper = Bound.exclusive; 51 | case 4 || 12: 52 | lower = Bound.exclusive; 53 | upper = Bound.inclusive; 54 | case 6: 55 | lower = Bound.inclusive; 56 | upper = Bound.inclusive; 57 | default: 58 | throw UnimplementedError('Range flag $flag not implemented'); 59 | } 60 | } 61 | 62 | int get _lowerFlag { 63 | switch (lower) { 64 | case Bound.exclusive: 65 | return 0; 66 | case Bound.inclusive: 67 | return 2; 68 | } 69 | } 70 | 71 | int get _upperFlag { 72 | switch (upper) { 73 | case Bound.exclusive: 74 | return 0; 75 | case Bound.inclusive: 76 | return 4; 77 | } 78 | } 79 | 80 | @override 81 | String toString() => 'Bounds(${lower.name},${upper.name})'; 82 | 83 | @override 84 | bool operator ==(Object other) => 85 | identical(this, other) || 86 | other is Bounds && lower == other.lower && upper == other.upper; 87 | 88 | @override 89 | int get hashCode => _lowerFlag + _upperFlag; 90 | } 91 | 92 | abstract interface class Range { 93 | late final T? _lower; 94 | late final T? _upper; 95 | late final Bounds _bounds; 96 | 97 | Bounds get bounds => _bounds; 98 | 99 | T? get lower => _lower; 100 | 101 | T? get upper => _upper; 102 | 103 | int get flag { 104 | switch ((lower == null, upper == null)) { 105 | case (true, true): 106 | return 24; 107 | case (true, false): 108 | return 8 + bounds._upperFlag; 109 | case (false, true): 110 | return 16 + bounds._lowerFlag; 111 | case (false, false): 112 | return lower == upper ? 1 : bounds._lowerFlag + bounds._upperFlag; 113 | } 114 | } 115 | 116 | _throwIfLowerGreaterThanUpper(T? lower, T? upper) { 117 | if (_lowerGreaterThanUpper(lower, upper)) { 118 | throw ArgumentError( 119 | 'Range: lower bound must be less than or equal to upper bound'); 120 | } 121 | } 122 | 123 | bool _lowerGreaterThanUpper(T? lower, T? upper) => 124 | lower != null && 125 | upper != null && 126 | lower is Comparable && 127 | lower.compareTo(upper as Comparable) > 0; 128 | 129 | @override 130 | String toString() => '$runtimeType($lower,$upper,$bounds)'; 131 | 132 | @override 133 | bool operator ==(Object other) { 134 | if (identical(this, other)) return true; 135 | if (other is Range && runtimeType == other.runtimeType) { 136 | return flag == 1 137 | ? flag == other.flag 138 | : flag == other.flag && lower == other.lower && upper == other.upper; 139 | } 140 | return false; 141 | } 142 | 143 | @override 144 | int get hashCode => flag == 1 ? flag : Object.hash(lower, upper, flag); 145 | } 146 | 147 | abstract interface class DiscreteRange extends Range { 148 | DiscreteRange(T? lower, T? upper, Bounds bounds) { 149 | _throwIfLowerGreaterThanUpper(lower, upper); 150 | final (l, lBound) = _canonicalizeLower(lower, bounds.lower); 151 | final (u, uBound) = _canonicalizeUpper(upper, bounds.upper); 152 | _lower = _lowerGreaterThanUpper(l, u) ? lower : l; 153 | _upper = u; 154 | _bounds = Bounds(lBound, uBound); 155 | } 156 | 157 | (T?, Bound) _canonicalizeLower(T? lower, Bound bound); 158 | 159 | (T?, Bound) _canonicalizeUpper(T? upper, Bound bound); 160 | } 161 | 162 | /// Describes PostgreSQL's builtin `int4range` and `int8range` 163 | /// 164 | /// https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-BUILTIN 165 | @sealed 166 | final class IntRange extends DiscreteRange { 167 | IntRange(super.lower, super.upper, super.bounds); 168 | 169 | /// Construct an empty [IntRange] 170 | IntRange.empty() : super(0, 0, Bounds(Bound.inclusive, Bound.exclusive)); 171 | 172 | @override 173 | (int?, Bound) _canonicalizeLower(int? lower, Bound bound) { 174 | if (lower == null) return (null, Bound.exclusive); 175 | if (bound == Bound.exclusive) return (lower + 1, Bound.inclusive); 176 | return (lower, bound); 177 | } 178 | 179 | @override 180 | (int?, Bound) _canonicalizeUpper(int? upper, Bound bound) { 181 | if (upper == null) return (null, Bound.exclusive); 182 | if (bound == Bound.inclusive) return (upper + 1, Bound.exclusive); 183 | return (upper, bound); 184 | } 185 | } 186 | 187 | final _z0 = DateTime.utc(1970); 188 | 189 | /// Describes PostgreSQL's builtin `daterange` 190 | /// 191 | /// https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-BUILTIN 192 | @sealed 193 | final class DateRange extends DiscreteRange { 194 | DateRange(super.lower, super.upper, super.bounds); 195 | 196 | /// Construct an empty [DateRange] 197 | DateRange.empty() : super(_z0, _z0, Bounds(Bound.inclusive, Bound.exclusive)); 198 | 199 | /// Remove hours, minutes, seconds, milliseconds and microseconds from [DateTime] 200 | DateTime? _removeTime(DateTime? dt) { 201 | if (dt == null) return null; 202 | final days = dt.microsecondsSinceEpoch ~/ Duration.microsecondsPerDay; 203 | final microseconds = days * Duration.microsecondsPerDay; 204 | return DateTime.fromMicrosecondsSinceEpoch(microseconds, isUtc: true); 205 | } 206 | 207 | @override 208 | (DateTime?, Bound) _canonicalizeLower(DateTime? lower, Bound bound) { 209 | if (lower == null) return (null, Bound.exclusive); 210 | if (bound == Bound.exclusive) { 211 | return (_removeTime(lower.add(Duration(days: 1))), Bound.inclusive); 212 | } 213 | return (_removeTime(lower), bound); 214 | } 215 | 216 | @override 217 | (DateTime?, Bound) _canonicalizeUpper(DateTime? upper, Bound bound) { 218 | if (upper == null) return (null, Bound.exclusive); 219 | if (bound == Bound.inclusive) { 220 | return (_removeTime(upper.add(Duration(days: 1))), Bound.exclusive); 221 | } 222 | return (_removeTime(upper), bound); 223 | } 224 | } 225 | 226 | /// Describes PostgreSQL's continuous builtin range types: 227 | /// - `numrange` 228 | /// - `tsrange` 229 | /// - `tstzrange` 230 | /// 231 | /// https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-BUILTIN 232 | interface class ContinuousRange extends Range { 233 | ContinuousRange(T? lower, T? upper, Bounds bounds) { 234 | _throwIfLowerGreaterThanUpper(lower, upper); 235 | _lower = lower; 236 | _upper = upper; 237 | _bounds = _canonicalizeNullBounds(bounds); 238 | } 239 | 240 | /// Infinity (or `null`-valued) bounds are always stored as [Bound.exclusive] 241 | /// 242 | /// https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-INFINITE 243 | Bounds _canonicalizeNullBounds(Bounds bounds) { 244 | switch ((lower == null, upper == null)) { 245 | case (true, true): 246 | return Bounds(Bound.exclusive, Bound.exclusive); 247 | case (false, true): 248 | return Bounds(bounds.lower, Bound.exclusive); 249 | case (true, false): 250 | return Bounds(Bound.exclusive, bounds.upper); 251 | case (false, false): 252 | return bounds; 253 | } 254 | } 255 | } 256 | 257 | @sealed 258 | final class DateTimeRange extends ContinuousRange { 259 | DateTimeRange(super.lower, super.upper, super.bounds); 260 | 261 | /// Construct an empty [TsRange] 262 | DateTimeRange.empty() 263 | : super(_z0, _z0, Bounds(Bound.inclusive, Bound.exclusive)); 264 | } 265 | -------------------------------------------------------------------------------- /lib/src/types/text_codec.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | 4 | import '../exceptions.dart'; 5 | import '../types.dart'; 6 | import 'codec.dart'; 7 | import 'geo_types.dart'; 8 | import 'type_registry.dart'; 9 | 10 | class PostgresTextEncoder { 11 | const PostgresTextEncoder(); 12 | 13 | String convert(Object? input, {bool escapeStrings = true}) { 14 | final value = tryConvert(input, escapeStrings: escapeStrings); 15 | if (value != null) { 16 | return value; 17 | } 18 | throw PgException("Could not infer type of value '$input'."); 19 | } 20 | 21 | String? tryConvert(Object? input, {bool escapeStrings = false}) { 22 | if (input is int) { 23 | return _encodeNumber(input); 24 | } 25 | 26 | if (input is double) { 27 | return _encodeDouble(input); 28 | } 29 | 30 | if (input is String) { 31 | return _encodeString(input, escapeStrings); 32 | } 33 | 34 | if (input is DateTime) { 35 | return _encodeDateTime(input, isDateOnly: false); 36 | } 37 | 38 | if (input is bool) { 39 | return _encodeBoolean(input); 40 | } 41 | 42 | if (input is Map) { 43 | return _encodeJSON(input, escapeStrings); 44 | } 45 | 46 | if (input is Point) { 47 | return _encodePoint(input); 48 | } 49 | 50 | if (input is List) { 51 | return _encodeList(input); 52 | } 53 | 54 | return null; 55 | } 56 | 57 | String _encodeString(String text, bool escapeStrings) { 58 | if (!escapeStrings) { 59 | return text; 60 | } 61 | 62 | final backslashCodeUnit = r'\'.codeUnitAt(0); 63 | final quoteCodeUnit = "'".codeUnitAt(0); 64 | 65 | var quoteCount = 0; 66 | var backslashCount = 0; 67 | final it = RuneIterator(text); 68 | while (it.moveNext()) { 69 | if (it.current == backslashCodeUnit) { 70 | backslashCount++; 71 | } else if (it.current == quoteCodeUnit) { 72 | quoteCount++; 73 | } 74 | } 75 | 76 | final buf = StringBuffer(); 77 | 78 | if (backslashCount > 0) { 79 | buf.write(' E'); 80 | } 81 | 82 | buf.write("'"); 83 | 84 | if (quoteCount == 0 && backslashCount == 0) { 85 | buf.write(text); 86 | } else { 87 | for (final i in text.codeUnits) { 88 | if (i == quoteCodeUnit || i == backslashCodeUnit) { 89 | buf.writeCharCode(i); 90 | buf.writeCharCode(i); 91 | } else { 92 | buf.writeCharCode(i); 93 | } 94 | } 95 | } 96 | 97 | buf.write("'"); 98 | 99 | return buf.toString(); 100 | } 101 | 102 | String _encodeNumber(num value) { 103 | if (value.isNaN) { 104 | return "'nan'"; 105 | } 106 | 107 | if (value.isInfinite) { 108 | return value.isNegative ? "'-infinity'" : "'infinity'"; 109 | } 110 | 111 | return value.toInt().toString(); 112 | } 113 | 114 | String _encodeDouble(double value) { 115 | if (value.isNaN) { 116 | return "'nan'"; 117 | } 118 | 119 | if (value.isInfinite) { 120 | return value.isNegative ? "'-infinity'" : "'infinity'"; 121 | } 122 | 123 | return value.toString(); 124 | } 125 | 126 | String _encodeBoolean(bool value) { 127 | return value ? 'TRUE' : 'FALSE'; 128 | } 129 | 130 | String _encodeDateTime(DateTime value, {bool isDateOnly = false}) { 131 | var string = value.toIso8601String(); 132 | 133 | if (isDateOnly) { 134 | string = string.split('T').first; 135 | } else { 136 | if (!value.isUtc) { 137 | final timezoneHourOffset = value.timeZoneOffset.inHours; 138 | final timezoneMinuteOffset = value.timeZoneOffset.inMinutes % 60; 139 | 140 | var hourComponent = timezoneHourOffset.abs().toString().padLeft(2, '0'); 141 | final minuteComponent = 142 | timezoneMinuteOffset.abs().toString().padLeft(2, '0'); 143 | 144 | if (timezoneHourOffset >= 0) { 145 | hourComponent = '+$hourComponent'; 146 | } else { 147 | hourComponent = '-$hourComponent'; 148 | } 149 | 150 | final timezoneString = [hourComponent, minuteComponent].join(':'); 151 | string = [string, timezoneString].join(''); 152 | } 153 | } 154 | 155 | if (string.substring(0, 1) == '-') { 156 | string = '${string.substring(1)} BC'; 157 | } else if (string.substring(0, 1) == '+') { 158 | string = string.substring(1); 159 | } 160 | 161 | return "'$string'"; 162 | } 163 | 164 | String _encodeJSON(dynamic value, bool escapeStrings) { 165 | if (value == null) { 166 | return 'null'; 167 | } 168 | 169 | if (value is String) { 170 | return "'${json.encode(value)}'"; 171 | } 172 | 173 | return _encodeString(json.encode(value), escapeStrings); 174 | } 175 | 176 | String _encodePoint(Point value) { 177 | return '(${_encodeDouble(value.latitude)}, ${_encodeDouble(value.longitude)})'; 178 | } 179 | 180 | String _encodeList(List value) { 181 | if (value.isEmpty) { 182 | return '{}'; 183 | } 184 | 185 | final first = value.first as Object?; 186 | final type = value.fold(first.runtimeType, (type, item) { 187 | if (type == item.runtimeType) { 188 | return type; 189 | } else if ((type == int || type == double) && item is num) { 190 | return double; 191 | } else { 192 | return Map; 193 | } 194 | }); 195 | 196 | if (type == bool) { 197 | return '{${value.cast().map((s) => s.toString()).join(',')}}'; 198 | } 199 | 200 | if (type == int || type == double) { 201 | return '{${value.cast().map((s) => s is double ? _encodeDouble(s) : _encodeNumber(s)).join(',')}}'; 202 | } 203 | 204 | if (type == String) { 205 | return '{${value.cast().map((s) { 206 | final escaped = s.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); 207 | return '"$escaped"'; 208 | }).join(',')}}'; 209 | } 210 | 211 | if (type == Map) { 212 | return '{${value.map((s) { 213 | final escaped = 214 | json.encode(s).replaceAll(r'\', r'\\').replaceAll('"', r'\"'); 215 | 216 | return '"$escaped"'; 217 | }).join(',')}}'; 218 | } 219 | 220 | throw PgException("Could not infer array type of value '$value'."); 221 | } 222 | } 223 | 224 | class PostgresTextDecoder { 225 | static Object? convert(CodecContext context, int typeOid, Uint8List di) { 226 | String asText() => context.encoding.decode(di); 227 | // ignore: unnecessary_cast 228 | switch (typeOid) { 229 | case TypeOid.character: 230 | case TypeOid.name: 231 | case TypeOid.text: 232 | case TypeOid.varChar: 233 | return asText(); 234 | case TypeOid.integer: 235 | case TypeOid.smallInteger: 236 | case TypeOid.bigInteger: 237 | return int.parse(asText()); 238 | case TypeOid.real: 239 | case TypeOid.double: 240 | return double.parse(asText()); 241 | case TypeOid.boolean: 242 | // In text data format when using simple query protocol, "true" & "false" 243 | // are represented as `t` and `f`, respectively. 244 | // we will check for both just in case 245 | // TODO: should we check for other representations (e.g. `1`, `on`, `y`, 246 | // and `yes`)? 247 | final t = asText(); 248 | return t == 't' || t == 'true'; 249 | 250 | case TypeOid.voidType: 251 | // TODO: is returning `null` here is the appripriate thing to do? 252 | return null; 253 | 254 | case TypeOid.timestampWithTimezone: 255 | case TypeOid.timestampWithoutTimezone: 256 | final raw = DateTime.parse(asText()); 257 | return DateTime.utc( 258 | raw.year, 259 | raw.month, 260 | raw.day, 261 | raw.hour, 262 | raw.minute, 263 | raw.second, 264 | raw.millisecond, 265 | raw.microsecond, 266 | ); 267 | 268 | case TypeOid.numeric: 269 | return asText(); 270 | 271 | case TypeOid.date: 272 | final raw = DateTime.parse(asText()); 273 | return DateTime.utc(raw.year, raw.month, raw.day); 274 | 275 | case TypeOid.json: 276 | case TypeOid.jsonb: 277 | return jsonDecode(asText()); 278 | 279 | case TypeOid.interval: 280 | case TypeOid.byteArray: 281 | case TypeOid.uuid: 282 | case TypeOid.point: 283 | case TypeOid.booleanArray: 284 | case TypeOid.integerArray: 285 | case TypeOid.bigIntegerArray: 286 | case TypeOid.textArray: 287 | case TypeOid.doubleArray: 288 | case TypeOid.varCharArray: 289 | case TypeOid.jsonbArray: 290 | case TypeOid.regtype: 291 | // TODO: implement proper decoding of the above 292 | return UndecodedBytes( 293 | typeOid: typeOid, 294 | bytes: di, 295 | isBinary: false, 296 | encoding: context.encoding, 297 | ); 298 | } 299 | return UndecodedBytes( 300 | typeOid: typeOid, 301 | bytes: di, 302 | isBinary: false, 303 | encoding: context.encoding, 304 | ); 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /lib/src/types/type_registry.dart: -------------------------------------------------------------------------------- 1 | import 'package:buffer/buffer.dart'; 2 | 3 | import '../exceptions.dart'; 4 | import '../types.dart'; 5 | import 'codec.dart'; 6 | import 'generic_type.dart'; 7 | import 'text_codec.dart'; 8 | import 'text_search.dart'; 9 | 10 | /// See: https://github.com/postgres/postgres/blob/master/src/include/catalog/pg_type.dat 11 | class TypeOid { 12 | static const bigInteger = 20; 13 | static const bigIntegerArray = 1016; 14 | static const bigIntegerRange = 3926; 15 | static const boolean = 16; 16 | static const booleanArray = 1000; 17 | static const box = 603; 18 | static const byteArray = 17; 19 | static const character = 1042; 20 | static const circle = 718; 21 | static const date = 1082; 22 | static const dateArray = 1182; 23 | static const dateRange = 3912; 24 | static const double = 701; 25 | static const doubleArray = 1022; 26 | static const integer = 23; 27 | static const integerArray = 1007; 28 | static const integerRange = 3904; 29 | static const interval = 1186; 30 | static const json = 114; 31 | static const jsonb = 3802; 32 | static const jsonbArray = 3807; 33 | static const line = 628; 34 | static const lineSegment = 601; 35 | static const name = 19; 36 | static const numeric = 1700; 37 | static const numericRange = 3906; 38 | static const path = 602; 39 | static const point = 600; 40 | static const polygon = 604; 41 | static const real = 700; 42 | static const regtype = 2206; 43 | static const smallInteger = 21; 44 | static const smallIntegerArray = 1005; 45 | static const text = 25; 46 | static const textArray = 1009; 47 | static const time = 1083; 48 | static const timeArray = 1183; 49 | static const timestamp = 1114; 50 | static const timestampArray = 1115; 51 | static const timestampRange = 3908; 52 | 53 | /// Please use [TypeOid.timestamp] instead. 54 | static const timestampWithoutTimezone = timestamp; 55 | static const timestampTz = 1184; 56 | static const timestampTzArray = 1185; 57 | static const timestampTzRange = 3910; 58 | 59 | /// Please use [TypeOid.timestampTz] instead. 60 | static const timestampWithTimezone = timestampTz; 61 | static const tsquery = 3615; 62 | static const tsvector = 3614; 63 | static const uuid = 2950; 64 | static const uuidArray = 2951; 65 | static const varChar = 1043; 66 | static const varCharArray = 1015; 67 | static const voidType = 2278; 68 | } 69 | 70 | final _builtInTypes = { 71 | Type.unspecified, 72 | Type.character, 73 | Type.name, 74 | Type.text, 75 | Type.varChar, 76 | Type.integer, 77 | Type.smallInteger, 78 | Type.bigInteger, 79 | Type.real, 80 | Type.double, 81 | Type.boolean, 82 | Type.voidType, 83 | Type.time, 84 | Type.timestampWithTimezone, 85 | Type.timestampWithoutTimezone, 86 | Type.interval, 87 | Type.numeric, 88 | Type.byteArray, 89 | Type.date, 90 | Type.json, 91 | Type.jsonb, 92 | Type.uuid, 93 | Type.point, 94 | Type.line, 95 | Type.lineSegment, 96 | Type.box, 97 | Type.polygon, 98 | Type.path, 99 | Type.circle, 100 | Type.booleanArray, 101 | Type.smallIntegerArray, 102 | Type.integerArray, 103 | Type.bigIntegerArray, 104 | Type.textArray, 105 | Type.doubleArray, 106 | Type.dateArray, 107 | Type.timeArray, 108 | Type.timestampArray, 109 | Type.timestampTzArray, 110 | Type.uuidArray, 111 | Type.varCharArray, 112 | Type.jsonbArray, 113 | Type.regtype, 114 | Type.integerRange, 115 | Type.bigIntegerRange, 116 | Type.dateRange, 117 | // Type.numrange, 118 | Type.timestampRange, 119 | Type.timestampTzRange, 120 | Type.tsvector, 121 | Type.tsquery, 122 | }; 123 | 124 | final _builtInCodecs = { 125 | TypeOid.character: GenericCodec(TypeOid.character), 126 | TypeOid.name: GenericCodec(TypeOid.name), 127 | TypeOid.text: GenericCodec(TypeOid.text), 128 | TypeOid.varChar: GenericCodec(TypeOid.varChar), 129 | TypeOid.integer: GenericCodec(TypeOid.integer), 130 | TypeOid.smallInteger: GenericCodec(TypeOid.smallInteger), 131 | TypeOid.bigInteger: GenericCodec(TypeOid.bigInteger), 132 | TypeOid.real: GenericCodec(TypeOid.real), 133 | TypeOid.double: GenericCodec(TypeOid.double), 134 | TypeOid.boolean: GenericCodec(TypeOid.boolean), 135 | TypeOid.voidType: GenericCodec(TypeOid.voidType), 136 | TypeOid.time: GenericCodec(TypeOid.time), 137 | TypeOid.timestampWithTimezone: GenericCodec(TypeOid.timestampWithTimezone), 138 | TypeOid.timestampWithoutTimezone: 139 | GenericCodec(TypeOid.timestampWithoutTimezone), 140 | TypeOid.interval: GenericCodec(TypeOid.interval), 141 | TypeOid.numeric: GenericCodec(TypeOid.numeric), 142 | TypeOid.byteArray: GenericCodec(TypeOid.byteArray), 143 | TypeOid.date: GenericCodec(TypeOid.date), 144 | TypeOid.json: GenericCodec(TypeOid.json, encodesNull: true), 145 | TypeOid.jsonb: GenericCodec(TypeOid.jsonb, encodesNull: true), 146 | TypeOid.uuid: GenericCodec(TypeOid.uuid), 147 | TypeOid.point: GenericCodec(TypeOid.point), 148 | TypeOid.line: GenericCodec(TypeOid.line), 149 | TypeOid.lineSegment: GenericCodec(TypeOid.lineSegment), 150 | TypeOid.box: GenericCodec(TypeOid.box), 151 | TypeOid.polygon: GenericCodec(TypeOid.polygon), 152 | TypeOid.path: GenericCodec(TypeOid.path), 153 | TypeOid.circle: GenericCodec(TypeOid.circle), 154 | TypeOid.booleanArray: GenericCodec(TypeOid.booleanArray), 155 | TypeOid.smallIntegerArray: GenericCodec(TypeOid.smallIntegerArray), 156 | TypeOid.integerArray: GenericCodec(TypeOid.integerArray), 157 | TypeOid.bigIntegerArray: GenericCodec(TypeOid.bigIntegerArray), 158 | TypeOid.textArray: GenericCodec(TypeOid.textArray), 159 | TypeOid.doubleArray: GenericCodec(TypeOid.doubleArray), 160 | TypeOid.dateArray: GenericCodec(TypeOid.dateArray), 161 | TypeOid.timeArray: GenericCodec(TypeOid.timeArray), 162 | TypeOid.timestampArray: GenericCodec(TypeOid.timestampArray), 163 | TypeOid.timestampTzArray: GenericCodec(TypeOid.timestampTzArray), 164 | TypeOid.uuidArray: GenericCodec(TypeOid.uuidArray), 165 | TypeOid.varCharArray: GenericCodec(TypeOid.varCharArray), 166 | TypeOid.jsonbArray: GenericCodec(TypeOid.jsonbArray), 167 | TypeOid.regtype: GenericCodec(TypeOid.regtype), 168 | TypeOid.integerRange: GenericCodec(TypeOid.integerRange), 169 | TypeOid.bigIntegerRange: GenericCodec(TypeOid.bigIntegerRange), 170 | TypeOid.dateRange: GenericCodec(TypeOid.dateRange), 171 | // TypeOid.numrange: GenericTypeCodec(TypeOid.numrange), 172 | TypeOid.timestampRange: GenericCodec(TypeOid.timestampRange), 173 | TypeOid.timestampTzRange: GenericCodec(TypeOid.timestampTzRange), 174 | TypeOid.tsvector: TsVectorCodec(), 175 | TypeOid.tsquery: TsQueryCodec(), 176 | }; 177 | 178 | final _builtInTypeNames = { 179 | 'bigint': Type.bigInteger, 180 | 'boolean': Type.boolean, 181 | 'bytea': Type.byteArray, 182 | 'bpchar': Type.character, 183 | 'char': Type.character, 184 | 'character': Type.character, 185 | 'date': Type.date, 186 | 'daterange': Type.dateRange, 187 | 'double precision': Type.double, 188 | 'float4': Type.real, 189 | 'float8': Type.double, 190 | 'int': Type.integer, 191 | 'int2': Type.smallInteger, 192 | 'int4': Type.integer, 193 | 'int4range': Type.integerRange, 194 | 'int8': Type.bigInteger, 195 | 'int8range': Type.bigIntegerRange, 196 | 'integer': Type.integer, 197 | 'interval': Type.interval, 198 | 'json': Type.json, 199 | 'jsonb': Type.jsonb, 200 | 'name': Type.name, 201 | 'numeric': Type.numeric, 202 | // 'numrange': Type.numrange, 203 | 'point': Type.point, 204 | 'line': Type.line, 205 | 'lseg': Type.lineSegment, 206 | 'box': Type.box, 207 | 'polygon': Type.polygon, 208 | 'path': Type.path, 209 | 'circle': Type.circle, 210 | 'real': Type.real, 211 | 'regtype': Type.regtype, 212 | 'serial4': Type.serial, 213 | 'serial8': Type.bigSerial, 214 | 'smallint': Type.smallInteger, 215 | 'text': Type.text, 216 | 'time': Type.time, 217 | 'timestamp': Type.timestampWithoutTimezone, 218 | 'timestamptz': Type.timestampWithTimezone, 219 | 'tsrange': Type.timestampRange, 220 | 'tstzrange': Type.timestampTzRange, 221 | 'tsquery': Type.tsquery, 222 | 'tsvector': Type.tsvector, 223 | 'varchar': Type.varChar, 224 | 'uuid': Type.uuid, 225 | '_bool': Type.booleanArray, 226 | '_date': Type.dateArray, 227 | '_float8': Type.doubleArray, 228 | '_int2': Type.smallIntegerArray, 229 | '_int4': Type.integerArray, 230 | '_int8': Type.bigIntegerArray, 231 | '_time': Type.timeArray, 232 | '_timestamp': Type.timestampArray, 233 | '_timestamptz': Type.timestampTzArray, 234 | '_jsonb': Type.jsonbArray, 235 | '_text': Type.textArray, 236 | '_uuid': Type.uuidArray, 237 | '_varchar': Type.varCharArray, 238 | }; 239 | 240 | /// Contains the static registry of type mapping from substitution names to 241 | /// type OIDs, their codec and the generic type encoders (for un-typed values). 242 | class TypeRegistry { 243 | final _byTypeOid = {}; 244 | final _bySubstitutionName = {}; 245 | final _codecs = {}; 246 | final _encoders = []; 247 | 248 | TypeRegistry({ 249 | /// Override or extend the built-in codecs using the type OID as key. 250 | Map? codecs, 251 | 252 | /// When encoding a non-typed parameter for a query, try to use these 253 | /// encoders in their specified order. The encoders will be called 254 | /// before the the built-in (generic) text encoders. 255 | Iterable? encoders, 256 | }) { 257 | _bySubstitutionName.addAll(_builtInTypeNames); 258 | _codecs.addAll(_builtInCodecs); 259 | for (final type in _builtInTypes) { 260 | if (type.oid != null && type.oid! > 0) { 261 | _byTypeOid[type.oid!] = type; 262 | } 263 | } 264 | if (codecs != null) { 265 | _codecs.addAll(codecs); 266 | } 267 | _encoders.addAll([ 268 | ...?encoders, 269 | _defaultTextEncoder, 270 | ]); 271 | } 272 | 273 | Future encode(TypedValue input, CodecContext context) async { 274 | // check for codec 275 | final typeOid = input.type.oid; 276 | final codec = typeOid == null ? null : _codecs[typeOid]; 277 | if (codec != null) { 278 | final r = await codec.encode(input, context); 279 | if (r != null) { 280 | return r; 281 | } 282 | } 283 | 284 | // fallback encoders 285 | for (final encoder in _encoders) { 286 | final encoded = await encoder(input, context); 287 | if (encoded != null) { 288 | return encoded; 289 | } 290 | } 291 | throw PgException("Could not infer type of value '${input.value}'."); 292 | } 293 | 294 | Future decode(EncodedValue value, CodecContext context) async { 295 | final typeOid = value.typeOid; 296 | if (typeOid == null) { 297 | throw ArgumentError('`EncodedValue.typeOid` was not provided.'); 298 | } 299 | 300 | // check for codec 301 | final codec = _codecs[typeOid]; 302 | if (codec != null) { 303 | final r = await codec.decode(value, context); 304 | if (r != value && r is! UndecodedBytes) { 305 | return r; 306 | } 307 | } 308 | 309 | // fallback decoding 310 | final bytes = value.bytes; 311 | if (bytes == null) { 312 | return null; 313 | } 314 | return UndecodedBytes( 315 | typeOid: typeOid, 316 | bytes: bytes, 317 | isBinary: value.isBinary, 318 | encoding: context.encoding, 319 | ); 320 | } 321 | } 322 | 323 | final _textEncoder = const PostgresTextEncoder(); 324 | 325 | extension TypeRegistryExt on TypeRegistry { 326 | Type resolveOid(int oid) { 327 | return _byTypeOid[oid] ?? UnknownType(oid); 328 | } 329 | 330 | Type? resolveSubstitution(String name) { 331 | if (name == 'read') { 332 | print( 333 | 'WARNING: Use `real` instead of `read` - will be removed in a future release.'); 334 | return Type.real; 335 | } 336 | return _bySubstitutionName[name]; 337 | } 338 | } 339 | 340 | EncodedValue? _defaultTextEncoder(TypedValue input, CodecContext context) { 341 | final value = input.value; 342 | final encoded = _textEncoder.tryConvert(value); 343 | if (encoded != null) { 344 | return EncodedValue.text(castBytes(context.encoding.encode(encoded))); 345 | } else { 346 | return null; 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /lib/src/utils/package_pool_ext.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:pool/pool.dart'; 4 | import 'package:stack_trace/stack_trace.dart'; 5 | 6 | extension PackagePoolExt on Pool { 7 | Future requestWithTimeout(Duration timeout) async { 8 | final stack = StackTrace.current; 9 | final completer = Completer(); 10 | 11 | Timer? timer; 12 | if (timeout > Duration.zero) { 13 | timer = Timer(timeout, () { 14 | if (!completer.isCompleted) { 15 | completer.completeError( 16 | TimeoutException('Failed to acquire pool lock.'), stack); 17 | } 18 | }); 19 | } 20 | 21 | final resourceFuture = request(); 22 | 23 | scheduleMicrotask(() { 24 | resourceFuture.then( 25 | (resource) async { 26 | timer?.cancel(); 27 | if (completer.isCompleted) { 28 | resource.release(); 29 | return; 30 | } 31 | completer.complete(resource); 32 | }, 33 | onError: (e, st) { 34 | timer?.cancel(); 35 | if (!completer.isCompleted) { 36 | completer.completeError( 37 | e, Chain([Trace.from(st), Trace.from(stack)])); 38 | } 39 | }, 40 | ); 41 | }); 42 | 43 | return completer.future; 44 | } 45 | 46 | Future withRequestTimeout( 47 | FutureOr Function(Duration remainingTimeout) callback, { 48 | required Duration timeout, 49 | }) async { 50 | final sw = Stopwatch()..start(); 51 | final resource = await requestWithTimeout(timeout); 52 | final remainingTimeout = timeout - sw.elapsed; 53 | try { 54 | return await callback(remainingTimeout); 55 | } finally { 56 | resource.release(); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/v3/connection_info.dart: -------------------------------------------------------------------------------- 1 | /// Provides runtime information about the current connection. 2 | class ConnectionInfo { 3 | /// The read-only, passive view of the Postgresql's runtime/session parameters. 4 | final ConnectionParametersView parameters; 5 | 6 | ConnectionInfo({ 7 | Map? parameters, 8 | }) : parameters = ConnectionParametersView(values: parameters); 9 | } 10 | 11 | /// The read-only, passive view of the Postgresql's runtime/session parameters. 12 | /// 13 | /// Postgresql server reports certain parameter values at opening a connection 14 | /// or whenever their values change. Such parameters may include: 15 | /// - `application_name` 16 | /// - `server_version` 17 | /// - `server_encoding` 18 | /// - `client_encoding` 19 | /// - `is_superuser` 20 | /// - `session_authorization` 21 | /// - `DateStyle` 22 | /// - `TimeZone` 23 | /// - `integer_datetimes` 24 | /// 25 | /// This class holds the latest parameter values send by the server on a single connection. 26 | /// The values are not queried or updated actively. 27 | /// 28 | /// The available parameters may be discovered following the instructions on these URLs: 29 | /// - https://www.postgresql.org/docs/current/sql-show.html 30 | /// - https://www.postgresql.org/docs/current/runtime-config.html 31 | /// - https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQPARAMETERSTATUS 32 | class ConnectionParametersView { 33 | final _values = {}; 34 | 35 | ConnectionParametersView({ 36 | Map? values, 37 | }) { 38 | if (values != null) { 39 | _values.addAll(values); 40 | } 41 | } 42 | 43 | Iterable get keys => _values.keys; 44 | String? operator [](String key) => _values[key]; 45 | 46 | String? get applicationName => _values['application_name']; 47 | String? get clientEncoding => _values['client_encoding']; 48 | String? get dateStyle => _values['DateStyle']; 49 | String? get integerDatetimes => _values['integer_datetimes']; 50 | String? get serverEncoding => _values['server_encoding']; 51 | String? get serverVersion => _values['server_version']; 52 | String? get timeZone => _values['TimeZone']; 53 | } 54 | 55 | extension ConnectionInfoExt on ConnectionInfo { 56 | void setParameter(String name, String value) { 57 | parameters._values[name] = value; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/v3/database_info.dart: -------------------------------------------------------------------------------- 1 | import 'package:postgres/src/messages/logical_replication_messages.dart'; 2 | 3 | /// Tracks and caches the type and name info of relations (tables, views, 4 | /// indexes...). 5 | /// 6 | /// Currently it only collects and caches [RelationMessage] instances. 7 | /// 8 | /// The instance may be shared between connection pool instances. 9 | /// 10 | /// TODO: Implement active querying using `pg_class` like the below query: 11 | /// "SELECT relname FROM pg_class WHERE relkind='r' AND oid = ?", 12 | /// https://www.postgresql.org/docs/current/catalog-pg-class.html 13 | class DatabaseInfo { 14 | final _relationMessages = {}; 15 | 16 | /// Returns the type OID for [relationId] and [columnIndex]. 17 | /// 18 | /// Returns `null` if the [relationId] is unknown or the [columnIndex] 19 | /// is out of bounds. 20 | Future getColumnTypeOidByRelationIdAndColumnIndex({ 21 | required int relationId, 22 | required int columnIndex, 23 | }) async { 24 | if (columnIndex < 0) { 25 | throw ArgumentError('columnIndex must not be negative'); 26 | } 27 | final m = _relationMessages[relationId]; 28 | if (m == null) { 29 | return null; 30 | } 31 | if (columnIndex > m.columns.length) { 32 | return null; 33 | } 34 | return m.columns[columnIndex].typeOid; 35 | } 36 | } 37 | 38 | extension DatabaseInfoExt on DatabaseInfo { 39 | void addRelationMessage(RelationMessage message) { 40 | _relationMessages[message.relationId] = message; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/v3/protocol.dart: -------------------------------------------------------------------------------- 1 | import 'package:async/async.dart'; 2 | import 'package:postgres/src/types/codec.dart'; 3 | import 'package:stream_channel/stream_channel.dart'; 4 | 5 | import '../buffer.dart'; 6 | import '../message_window.dart'; 7 | import '../messages/client_messages.dart'; 8 | import '../messages/shared_messages.dart'; 9 | 10 | export '../messages/client_messages.dart'; 11 | export '../messages/server_messages.dart'; 12 | export '../messages/shared_messages.dart'; 13 | 14 | class AggregatedClientMessage extends ClientMessage { 15 | final List messages; 16 | 17 | AggregatedClientMessage(this.messages); 18 | 19 | @override 20 | void applyToBuffer(PgByteDataWriter buffer) { 21 | for (final cm in messages) { 22 | cm.applyToBuffer(buffer); 23 | } 24 | } 25 | 26 | @override 27 | String toString() { 28 | return 'Aggregated $messages'; 29 | } 30 | } 31 | 32 | StreamChannelTransformer> messageTransformer( 33 | CodecContext codecContext) { 34 | return StreamChannelTransformer( 35 | BytesToMessageParser(codecContext), 36 | StreamSinkTransformer.fromHandlers( 37 | handleData: (message, out) { 38 | if (message is! ClientMessage) { 39 | out.addError( 40 | ArgumentError.value( 41 | message, 'message', 'Must be a client message'), 42 | StackTrace.current); 43 | return; 44 | } 45 | 46 | out.add(message.asBytes(encoding: codecContext.encoding)); 47 | }, 48 | ), 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /lib/src/v3/query_description.dart: -------------------------------------------------------------------------------- 1 | import '../../postgres.dart'; 2 | import 'variable_tokenizer.dart'; 3 | 4 | class SqlImpl implements Sql { 5 | final String sql; 6 | final TokenizerMode mode; 7 | final String substitution; 8 | final List? types; 9 | 10 | SqlImpl.direct(this.sql, {this.types}) 11 | : mode = TokenizerMode.none, 12 | substitution = ''; 13 | 14 | SqlImpl.indexed(this.sql, {this.substitution = '@'}) 15 | : mode = TokenizerMode.indexed, 16 | types = null; 17 | 18 | SqlImpl.named(this.sql, {this.substitution = '@'}) 19 | : mode = TokenizerMode.named, 20 | types = null; 21 | } 22 | 23 | class InternalQueryDescription { 24 | /// The SQL to send to postgres. 25 | /// 26 | /// This is the [originalSql] statement after local processing ran to 27 | /// substiute parameters. 28 | final String transformedSql; 29 | 30 | /// The SQL query as supplied by the user. 31 | final String originalSql; 32 | 33 | final List? parameterTypes; 34 | final Map? namedVariables; 35 | 36 | InternalQueryDescription._( 37 | this.transformedSql, 38 | this.originalSql, 39 | this.parameterTypes, 40 | this.namedVariables, 41 | ); 42 | 43 | InternalQueryDescription.direct(String sql, {List? types}) 44 | : this._(sql, sql, types, null); 45 | 46 | InternalQueryDescription.transformed( 47 | String original, 48 | String transformed, 49 | List parameterTypes, 50 | Map? namedVariables, 51 | ) : this._( 52 | transformed, 53 | original, 54 | parameterTypes, 55 | namedVariables, 56 | ); 57 | 58 | factory InternalQueryDescription.indexed( 59 | String sql, { 60 | String substitution = '@', 61 | TypeRegistry? typeRegistry, 62 | }) { 63 | return _viaTokenizer(typeRegistry ?? TypeRegistry(), sql, substitution, 64 | TokenizerMode.indexed); 65 | } 66 | 67 | factory InternalQueryDescription.named( 68 | String sql, { 69 | String substitution = '@', 70 | TypeRegistry? typeRegistry, 71 | }) { 72 | return _viaTokenizer( 73 | typeRegistry ?? TypeRegistry(), sql, substitution, TokenizerMode.named); 74 | } 75 | 76 | static InternalQueryDescription _viaTokenizer( 77 | TypeRegistry typeRegistry, 78 | String sql, 79 | String substitution, 80 | TokenizerMode mode, 81 | ) { 82 | final charCodes = substitution.codeUnits; 83 | if (charCodes.length != 1) { 84 | throw ArgumentError.value(substitution, 'substitution', 85 | 'Must be a string with a single code unit'); 86 | } 87 | 88 | final tokenizer = VariableTokenizer( 89 | typeRegistry: typeRegistry, 90 | variableCodeUnit: charCodes[0], 91 | sql: sql, 92 | mode: mode, 93 | )..tokenize(); 94 | 95 | return tokenizer.result; 96 | } 97 | 98 | factory InternalQueryDescription.wrap( 99 | Object query, { 100 | required TypeRegistry typeRegistry, 101 | }) { 102 | if (query is String) { 103 | return InternalQueryDescription.direct(query); 104 | } else if (query is InternalQueryDescription) { 105 | return query; 106 | } else if (query is SqlImpl) { 107 | switch (query.mode) { 108 | case TokenizerMode.none: 109 | return InternalQueryDescription.direct( 110 | query.sql, 111 | types: query.types, 112 | ); 113 | case TokenizerMode.indexed: 114 | return InternalQueryDescription.indexed( 115 | query.sql, 116 | substitution: query.substitution, 117 | typeRegistry: typeRegistry, 118 | ); 119 | case TokenizerMode.named: 120 | return InternalQueryDescription.named( 121 | query.sql, 122 | substitution: query.substitution, 123 | typeRegistry: typeRegistry, 124 | ); 125 | } 126 | } else { 127 | throw ArgumentError.value( 128 | query, 'query', 'Must either be a String or an SqlImpl'); 129 | } 130 | } 131 | 132 | TypedValue _toParameter( 133 | Object? value, 134 | Type? knownType, { 135 | String? name, 136 | }) { 137 | if (value is TypedValue) { 138 | if (value.type != Type.unspecified) { 139 | return value; 140 | } 141 | knownType = value.type; 142 | value = value.value; 143 | } 144 | if (knownType != null && knownType != Type.unspecified) { 145 | return TypedValue(knownType, value); 146 | } else if (value is TsVector) { 147 | return TypedValue(Type.tsvector, value); 148 | } else if (value is TsQuery) { 149 | return TypedValue(Type.tsquery, value); 150 | } else { 151 | return TypedValue(Type.unspecified, value); 152 | } 153 | } 154 | 155 | List bindParameters( 156 | Object? params, { 157 | bool ignoreSuperfluous = false, 158 | }) { 159 | final knownTypes = parameterTypes; 160 | final parameters = []; 161 | 162 | if (params == null) { 163 | if (knownTypes != null && knownTypes.isNotEmpty) { 164 | throw ArgumentError.value(params, 'parameters', 165 | 'This prepared statement has ${knownTypes.length} parameters that must be set.'); 166 | } 167 | 168 | return const []; 169 | } else if (params is List) { 170 | if (knownTypes != null && knownTypes.length != params.length) { 171 | throw ArgumentError.value(params, 'parameters', 172 | 'Expected ${knownTypes.length} parameters, got ${params.length}'); 173 | } 174 | 175 | for (var i = 0; i < params.length; i++) { 176 | final param = params[i]; 177 | final knownType = knownTypes != null ? knownTypes[i] : null; 178 | parameters.add(_toParameter(param, knownType, name: '[$i]')); 179 | } 180 | } else if (params is Map) { 181 | final byName = namedVariables; 182 | final unmatchedVariables = params.keys.toSet(); 183 | if (byName == null) { 184 | throw ArgumentError.value( 185 | params, 'parameters', 'Maps are only supported by `Sql.named`'); 186 | } 187 | 188 | var variableIndex = 1; 189 | for (final entry in byName.entries) { 190 | assert(entry.value == variableIndex); 191 | final type = 192 | knownTypes![variableIndex - 1]; // Known types are 0-indexed 193 | 194 | final name = entry.key; 195 | if (!params.containsKey(name)) { 196 | throw ArgumentError.value( 197 | params, 'parameters', 'Missing variable for `$name`'); 198 | } 199 | 200 | final value = params[name]; 201 | unmatchedVariables.remove(name); 202 | parameters.add(_toParameter(value, type, name: name)); 203 | 204 | variableIndex++; 205 | } 206 | 207 | if (unmatchedVariables.isNotEmpty && !ignoreSuperfluous) { 208 | throw ArgumentError.value(params, 'parameters', 209 | 'Contains superfluous variables: ${unmatchedVariables.join(', ')}'); 210 | } 211 | } else { 212 | throw ArgumentError.value( 213 | params, 'parameters', 'Must either be a list or a map'); 214 | } 215 | 216 | return parameters; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /lib/src/v3/resolved_settings.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:postgres/messages.dart'; 5 | import 'package:stream_channel/stream_channel.dart'; 6 | 7 | import '../../postgres.dart'; 8 | 9 | class ResolvedSessionSettings implements SessionSettings { 10 | @override 11 | final Duration connectTimeout; 12 | @override 13 | final Duration queryTimeout; 14 | @override 15 | final QueryMode queryMode; 16 | @override 17 | final bool ignoreSuperfluousParameters; 18 | 19 | ResolvedSessionSettings(SessionSettings? settings, SessionSettings? fallback) 20 | : connectTimeout = settings?.connectTimeout ?? 21 | fallback?.connectTimeout ?? 22 | Duration(seconds: 15), 23 | queryTimeout = settings?.queryTimeout ?? 24 | fallback?.queryTimeout ?? 25 | Duration(minutes: 5), 26 | queryMode = 27 | settings?.queryMode ?? fallback?.queryMode ?? QueryMode.extended, 28 | ignoreSuperfluousParameters = settings?.ignoreSuperfluousParameters ?? 29 | fallback?.ignoreSuperfluousParameters ?? 30 | false; 31 | 32 | bool isMatchingSession(ResolvedSessionSettings other) { 33 | return connectTimeout == other.connectTimeout && 34 | queryTimeout == other.queryTimeout && 35 | queryMode == other.queryMode && 36 | ignoreSuperfluousParameters == other.ignoreSuperfluousParameters; 37 | } 38 | } 39 | 40 | class ResolvedConnectionSettings extends ResolvedSessionSettings 41 | implements ConnectionSettings { 42 | @override 43 | final String? applicationName; 44 | @override 45 | final String timeZone; 46 | @override 47 | final Encoding encoding; 48 | @override 49 | final SslMode sslMode; 50 | @override 51 | final SecurityContext? securityContext; 52 | @override 53 | final StreamChannelTransformer? transformer; 54 | @override 55 | final ReplicationMode replicationMode; 56 | @override 57 | final TypeRegistry typeRegistry; 58 | @override 59 | final Future Function(Connection connection)? onOpen; 60 | 61 | ResolvedConnectionSettings( 62 | ConnectionSettings? super.settings, ConnectionSettings? super.fallback) 63 | : applicationName = 64 | settings?.applicationName ?? fallback?.applicationName, 65 | timeZone = settings?.timeZone ?? fallback?.timeZone ?? 'UTC', 66 | encoding = settings?.encoding ?? fallback?.encoding ?? utf8, 67 | sslMode = settings?.sslMode ?? fallback?.sslMode ?? SslMode.require, 68 | securityContext = settings?.securityContext, 69 | // TODO: consider merging the transformers 70 | transformer = settings?.transformer ?? fallback?.transformer, 71 | replicationMode = settings?.replicationMode ?? 72 | fallback?.replicationMode ?? 73 | ReplicationMode.none, 74 | // TODO: consider merging the type registries 75 | typeRegistry = 76 | settings?.typeRegistry ?? fallback?.typeRegistry ?? TypeRegistry(), 77 | onOpen = settings?.onOpen ?? fallback?.onOpen; 78 | 79 | bool isMatchingConnection(ResolvedConnectionSettings other) { 80 | return isMatchingSession(other) && 81 | applicationName == other.applicationName && 82 | timeZone == other.timeZone && 83 | encoding == other.encoding && 84 | sslMode == other.sslMode && 85 | transformer == other.transformer && 86 | replicationMode == other.replicationMode && 87 | onOpen == other.onOpen; 88 | } 89 | } 90 | 91 | class ResolvedPoolSettings extends ResolvedConnectionSettings 92 | implements PoolSettings { 93 | @override 94 | final int maxConnectionCount; 95 | @override 96 | final Duration maxConnectionAge; 97 | @override 98 | final Duration maxSessionUse; 99 | @override 100 | final int maxQueryCount; 101 | 102 | ResolvedPoolSettings(PoolSettings? settings) 103 | : maxConnectionCount = settings?.maxConnectionCount ?? 1, 104 | maxConnectionAge = settings?.maxConnectionAge ?? Duration(hours: 12), 105 | maxSessionUse = settings?.maxSessionUse ?? Duration(hours: 4), 106 | maxQueryCount = settings?.maxQueryCount ?? 100000, 107 | super(settings, null); 108 | } 109 | 110 | class ResolvedTransactionSettings extends ResolvedSessionSettings 111 | implements TransactionSettings { 112 | @override 113 | final IsolationLevel? isolationLevel; 114 | @override 115 | final AccessMode? accessMode; 116 | @override 117 | final DeferrableMode? deferrable; 118 | 119 | ResolvedTransactionSettings( 120 | TransactionSettings? super.settings, super.fallback) 121 | : isolationLevel = settings?.isolationLevel, 122 | accessMode = settings?.accessMode, 123 | deferrable = settings?.deferrable; 124 | } 125 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: postgres 2 | description: PostgreSQL database driver. Supports binary protocol, connection pooling and statement reuse. 3 | version: 3.5.6 4 | homepage: https://github.com/isoos/postgresql-dart 5 | topics: 6 | - sql 7 | - db 8 | - database 9 | - postgres 10 | 11 | environment: 12 | sdk: '>=3.4.0 <4.0.0' 13 | 14 | dependencies: 15 | buffer: ^1.2.3 16 | crypto: ^3.0.6 17 | collection: ^1.19.1 18 | sasl_scram: ^0.1.1 19 | stack_trace: ^1.12.1 20 | stream_channel: ^2.1.4 21 | async: ^2.12.0 22 | charcode: ^1.4.0 23 | meta: ^1.16.0 24 | pool: ^1.5.1 25 | 26 | dev_dependencies: 27 | lints: ^5.1.1 28 | test: ^1.25.14 29 | coverage: ^1.11.1 30 | logging: ^1.3.0 31 | docker_process: ^1.3.2 32 | path: ^1.9.1 33 | -------------------------------------------------------------------------------- /test/bytes_example_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:postgres/postgres.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import 'docker.dart'; 7 | 8 | void main() { 9 | withPostgresServer('bytes example', (server) { 10 | test('write and read', () async { 11 | final conn = await server.newConnection(); 12 | await conn.execute(''' 13 | CREATE TABLE IF NOT EXISTS images ( 14 | id SERIAL PRIMARY KEY, 15 | name TEXT, 16 | description TEXT, 17 | image BYTEA NOT NULL 18 | ); 19 | '''); 20 | 21 | final rs1 = await conn.execute(Sql.named(''' 22 | INSERT INTO images (name, description, image) 23 | VALUES (@name, @description, @image:bytea) 24 | RETURNING id 25 | '''), parameters: { 26 | 'name': 'name-1', 27 | 'description': 'descr-1', 28 | 'image': Uint8List.fromList([0, 1, 2]), 29 | }); 30 | final id = rs1.single.single; 31 | 32 | final rs2 = await conn 33 | .execute(r'SELECT image FROM images WHERE id=$1', parameters: [id]); 34 | final bytes = rs2.single.single; 35 | expect(bytes, [0, 1, 2]); 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /test/docker.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'dart:typed_data'; 4 | 5 | import 'package:async/async.dart'; 6 | import 'package:docker_process/containers/postgres.dart'; 7 | import 'package:logging/logging.dart'; 8 | import 'package:meta/meta.dart'; 9 | import 'package:path/path.dart' as p; 10 | import 'package:postgres/messages.dart'; 11 | import 'package:postgres/postgres.dart'; 12 | import 'package:postgres/src/v3/connection.dart'; 13 | import 'package:stream_channel/stream_channel.dart'; 14 | import 'package:test/test.dart'; 15 | 16 | final _splitAndDelayBytes = false; 17 | 18 | // We log all packets sent to and received from the postgres server. This can be 19 | // used to debug failing tests. To view logs, something like this can be put 20 | // at the beginning of `main()`: 21 | // 22 | // Logger.root.level = Level.ALL; 23 | // Logger.root.onRecord.listen((r) => print('${r.loggerName}: ${r.message}')); 24 | StreamChannelTransformer loggingTransformer(String prefix) { 25 | final inLogger = Logger('postgres.connection.$prefix.in'); 26 | final outLogger = Logger('postgres.connection.$prefix.out'); 27 | 28 | return StreamChannelTransformer( 29 | StreamTransformer.fromHandlers( 30 | handleData: (data, sink) { 31 | inLogger.fine(data); 32 | sink.add(data); 33 | }, 34 | ), 35 | StreamSinkTransformer.fromHandlers( 36 | handleData: (data, sink) { 37 | outLogger.fine(data); 38 | sink.add(data); 39 | }, 40 | ), 41 | ); 42 | } 43 | 44 | class PostgresServer { 45 | final _port = Completer(); 46 | final _containerName = Completer(); 47 | 48 | Future get port => _port.future; 49 | final String? _pgUser; 50 | final String? _pgPassword; 51 | 52 | PostgresServer({ 53 | String? pgUser, 54 | String? pgPassword, 55 | }) : _pgUser = pgUser, 56 | _pgPassword = pgPassword; 57 | 58 | Future endpoint() async => Endpoint( 59 | host: 'localhost', 60 | database: 'postgres', 61 | username: _pgUser ?? 'postgres', 62 | password: _pgPassword ?? 'postgres', 63 | port: await port, 64 | ); 65 | 66 | Future newConnection({ 67 | ReplicationMode replicationMode = ReplicationMode.none, 68 | SslMode? sslMode, 69 | QueryMode? queryMode, 70 | }) async { 71 | return await PgConnectionImplementation.connect( 72 | await endpoint(), 73 | connectionSettings: ConnectionSettings( 74 | connectTimeout: Duration(seconds: 3), 75 | queryTimeout: Duration(seconds: 3), 76 | replicationMode: replicationMode, 77 | transformer: loggingTransformer('conn'), 78 | sslMode: sslMode, 79 | queryMode: queryMode, 80 | ), 81 | incomingBytesTransformer: 82 | _splitAndDelayBytes ? _transformIncomingBytes() : null, 83 | ); 84 | } 85 | 86 | Future kill() async { 87 | await Process.run('docker', ['kill', await _containerName.future]); 88 | } 89 | } 90 | 91 | StreamTransformer _transformIncomingBytes() { 92 | return StreamTransformer.fromBind((s) => s.asyncExpand((u) { 93 | if (u.length <= 2) { 94 | return Stream.value(u); 95 | } 96 | final hash = u.hashCode.abs(); 97 | final split = hash % u.length; 98 | if (split == 0 || split >= u.length - 1) { 99 | return Stream.value(u); 100 | } 101 | 102 | final p1 = u.sublist(0, split); 103 | final p2 = u.sublist(split); 104 | 105 | return Stream.fromFutures([ 106 | Future.value(p1), 107 | Future.delayed( 108 | Duration(milliseconds: 50), 109 | () => p2, 110 | ) 111 | ]); 112 | })); 113 | } 114 | 115 | @isTestGroup 116 | void withPostgresServer( 117 | String name, 118 | void Function(PostgresServer server) fn, { 119 | Iterable? initSqls, 120 | String? pgUser, 121 | String? pgPassword, 122 | String? pgHbaConfContent, 123 | String? timeZone, 124 | }) { 125 | group(name, () { 126 | final server = PostgresServer( 127 | pgUser: pgUser, 128 | pgPassword: pgPassword, 129 | ); 130 | Directory? tempDir; 131 | 132 | setUpAll(() async { 133 | try { 134 | final port = await selectFreePort(); 135 | String? pgHbaConfPath; 136 | if (pgHbaConfContent != null) { 137 | tempDir = 138 | await Directory.systemTemp.createTemp('postgres-dart-test-$port'); 139 | pgHbaConfPath = p.join(tempDir!.path, 'pg_hba.conf'); 140 | await File(pgHbaConfPath).writeAsString(pgHbaConfContent); 141 | } 142 | 143 | final containerName = 'postgres-dart-test-$port'; 144 | await _startPostgresContainer( 145 | port: port, 146 | containerName: containerName, 147 | initSqls: initSqls ?? const [], 148 | pgUser: pgUser, 149 | pgPassword: pgPassword, 150 | pgHbaConfPath: pgHbaConfPath, 151 | timeZone: timeZone, 152 | ); 153 | 154 | server._containerName.complete(containerName); 155 | server._port.complete(port); 156 | } catch (e, st) { 157 | server._containerName.completeError(e, st); 158 | server._port.completeError(e, st); 159 | rethrow; 160 | } 161 | }); 162 | 163 | tearDownAll(() async { 164 | final containerName = await server._containerName.future; 165 | await Process.run('docker', ['stop', containerName]); 166 | await Process.run('docker', ['kill', containerName]); 167 | await tempDir?.delete(recursive: true); 168 | }); 169 | 170 | fn(server); 171 | }); 172 | } 173 | 174 | Future selectFreePort() async { 175 | final socket = await ServerSocket.bind(InternetAddress.anyIPv4, 0); 176 | final port = socket.port; 177 | await socket.close(); 178 | return port; 179 | } 180 | 181 | Future _startPostgresContainer({ 182 | required int port, 183 | required String containerName, 184 | required Iterable initSqls, 185 | String? pgUser, 186 | String? pgPassword, 187 | String? pgHbaConfPath, 188 | String? timeZone, 189 | }) async { 190 | final isRunning = await _isPostgresContainerRunning(containerName); 191 | if (isRunning) { 192 | return; 193 | } 194 | 195 | final configPath = p.join(Directory.current.path, 'test', 'pg_configs'); 196 | 197 | final dp = await startPostgres( 198 | name: containerName, 199 | version: 'latest', 200 | pgPort: port, 201 | pgDatabase: 'postgres', 202 | pgUser: pgUser ?? 'postgres', 203 | pgPassword: pgPassword ?? 'postgres', 204 | cleanup: true, 205 | configurations: [ 206 | // SSL settings 207 | 'ssl=on', 208 | // The debian image includes a self-signed SSL cert that can be used: 209 | 'ssl_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem', 210 | 'ssl_key_file=/etc/ssl/private/ssl-cert-snakeoil.key', 211 | ], 212 | pgHbaConfPath: pgHbaConfPath ?? p.join(configPath, 'pg_hba.conf'), 213 | postgresqlConfPath: p.join(configPath, 'postgresql.conf'), 214 | timeZone: timeZone, 215 | ); 216 | 217 | // Setup the database to support all kind of tests 218 | for (final stmt in initSqls) { 219 | final args = [ 220 | 'psql', 221 | '-c', 222 | stmt, 223 | '-U', 224 | 'postgres', 225 | ]; 226 | final res = await dp.exec(args); 227 | if (res.exitCode != 0) { 228 | final message = 229 | 'Failed to setup PostgreSQL database due to the following error:\n' 230 | '${res.stderr}'; 231 | throw ProcessException( 232 | 'docker exec $containerName', 233 | args, 234 | message, 235 | res.exitCode, 236 | ); 237 | } 238 | } 239 | } 240 | 241 | Future _isPostgresContainerRunning(String containerName) async { 242 | final pr = await Process.run( 243 | 'docker', 244 | ['ps', '--format', '{{.Names}}'], 245 | ); 246 | return pr.stdout 247 | .toString() 248 | .split('\n') 249 | .map((s) => s.trim()) 250 | .contains(containerName); 251 | } 252 | 253 | /// This is setup is the same as the one from the old travis ci. 254 | const oldSchemaInit = [ 255 | // create testing database 256 | 'create database dart_test;', 257 | // create dart user 258 | 'create user dart with createdb;', 259 | "alter user dart with password 'dart';", 260 | 'grant all on database dart_test to dart;', 261 | // create darttrust user 262 | 'create user darttrust with createdb;', 263 | 'grant all on database dart_test to darttrust;', 264 | ]; 265 | 266 | const replicationSchemaInit = [ 267 | // create replication user 268 | "create role replication with replication password 'replication' login;", 269 | ]; 270 | -------------------------------------------------------------------------------- /test/error_handling_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:postgres/postgres.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import 'docker.dart'; 7 | 8 | void main() { 9 | withPostgresServer('error handling', (server) { 10 | test('Reports stacktrace correctly', () async { 11 | final conn = await server.newConnection(); 12 | addTearDown(() async => conn.close()); 13 | 14 | // Root connection query 15 | try { 16 | await conn.execute('SELECT hello'); 17 | fail('Should not reach'); 18 | } catch (e, st) { 19 | expect(e.toString(), contains('column "hello" does not exist')); 20 | expect( 21 | st.toString(), 22 | contains('test/error_handling_test.dart'), 23 | ); 24 | } 25 | 26 | // Root connection execute 27 | try { 28 | await conn.execute('DELETE FROM hello'); 29 | fail('Should not reach'); 30 | } catch (e, st) { 31 | expect(e.toString(), contains('relation "hello" does not exist')); 32 | expect( 33 | st.toString(), 34 | contains('test/error_handling_test.dart'), 35 | ); 36 | } 37 | 38 | // Inside transaction 39 | try { 40 | await conn.runTx((s) async { 41 | await s.execute('SELECT hello'); 42 | fail('Should not reach'); 43 | }); 44 | } catch (e, st) { 45 | expect(e.toString(), contains('column "hello" does not exist')); 46 | expect( 47 | st.toString(), 48 | contains('test/error_handling_test.dart'), 49 | ); 50 | } 51 | }); 52 | 53 | test('TimeoutException', () async { 54 | final c = await server.newConnection(queryMode: QueryMode.simple); 55 | await c.execute('SET statement_timeout = 1000;'); 56 | await expectLater( 57 | () => c.execute('SELECT pg_sleep(2);'), 58 | throwsA( 59 | allOf( 60 | isA(), 61 | isA().having( 62 | (e) => e.toString(), 63 | 'toString()', 64 | 'Severity.error 57014: canceling statement due to statement timeout', 65 | ), 66 | ), 67 | ), 68 | ); 69 | }); 70 | 71 | test('DuplicateKeyException', () async { 72 | final c = await server.newConnection(); 73 | await c.execute('CREATE TABLE test (id INT PRIMARY KEY);'); 74 | await c.execute('INSERT INTO test (id) VALUES (1);'); 75 | addTearDown(() async => c.execute('DROP TABLE test;')); 76 | 77 | try { 78 | await c.execute('INSERT INTO test (id) VALUES (1);'); 79 | } catch (e) { 80 | expect(e, isA()); 81 | expect( 82 | e.toString(), 83 | contains( 84 | 'duplicate key value violates unique constraint "test_pkey"')); 85 | } 86 | }); 87 | 88 | test('ForeignKeyViolationException', () async { 89 | final c = await server.newConnection(); 90 | await c.execute('CREATE TABLE test (id INT PRIMARY KEY);'); 91 | await c.execute( 92 | 'CREATE TABLE test2 (id INT PRIMARY KEY, test_id INT REFERENCES test(id));'); 93 | await c.execute('INSERT INTO test (id) VALUES (1);'); 94 | addTearDown(() async { 95 | await c.execute('DROP TABLE test2;'); 96 | await c.execute('DROP TABLE test;'); 97 | }); 98 | 99 | try { 100 | await c.execute('INSERT INTO test2 (id, test_id) VALUES (1, 2);'); 101 | } catch (e) { 102 | expect(e, isA()); 103 | expect( 104 | e.toString(), 105 | contains( 106 | 'insert or update on table "test2" violates foreign key constraint "test2_test_id_fkey"', 107 | ), 108 | ); 109 | } 110 | }); 111 | }); 112 | } 113 | -------------------------------------------------------------------------------- /test/event_after_closing_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:postgres/postgres.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import 'docker.dart'; 7 | 8 | void _print(x) { 9 | // uncomment to debug locally 10 | // print(x); 11 | } 12 | 13 | void main() { 14 | withPostgresServer('event after closing', (server) { 15 | Future createTableAndPopulate(Connection conn) async { 16 | final sw = Stopwatch()..start(); 17 | 18 | await conn.execute(''' 19 | CREATE TABLE IF NOT EXISTS large_table ( 20 | id SERIAL PRIMARY KEY, 21 | c1 INTEGER NOT NULL, 22 | c2 INTEGER NOT NULL, 23 | c3 TEXT NOT NULL, 24 | c4 TEXT NOT NULL, 25 | c5 TEXT NOT NULL, 26 | c6 TEXT NOT NULL, 27 | c7 TEXT NOT NULL, 28 | c8 TEXT NOT NULL, 29 | c9 TEXT NOT NULL, 30 | c10 TEXT NOT NULL 31 | ) 32 | '''); 33 | 34 | final numBatches = 20; 35 | final batchSize = 5000; 36 | 37 | for (var i = 0; i < numBatches; i++) { 38 | _print('Batch $i of $numBatches'); 39 | final values = List.generate( 40 | batchSize, 41 | (i) => [ 42 | i, 43 | i * 2, 44 | 'value $i', 45 | 'value $i', 46 | 'value $i', 47 | 'value $i', 48 | 'value $i', 49 | 'value $i', 50 | 'value $i', 51 | 'value $i', 52 | ]); 53 | 54 | final allArgs = values.expand((e) => e).toList(); 55 | final valuesPart = List.generate( 56 | batchSize, 57 | (i) => 58 | '(${List.generate(10, (j) => '\$${i * 10 + j + 1}').join(', ')})') 59 | .join(', '); 60 | 61 | final stmt = 62 | 'INSERT INTO large_table (c1, c2, c3, c4, c5, c6, c7, c8, c9, c10) VALUES $valuesPart'; 63 | await conn.execute( 64 | stmt, 65 | parameters: allArgs, 66 | ); 67 | } 68 | 69 | _print('Inserted ${numBatches * batchSize} rows in ${sw.elapsed}'); 70 | } 71 | 72 | test('issue#398 ssl:disabled', () async { 73 | final conn = await server.newConnection(sslMode: SslMode.disable); 74 | await createTableAndPopulate(conn); 75 | 76 | final rows = await conn.execute('SELECT * FROM large_table'); 77 | _print('SELECTED ROWS ${rows.length}'); 78 | 79 | await conn.close(); 80 | }); 81 | 82 | test('issue#398 ssl:require', () async { 83 | final conn = await server.newConnection(sslMode: SslMode.require); 84 | await createTableAndPopulate(conn); 85 | 86 | final rows = await conn.execute('SELECT * FROM large_table'); 87 | _print('SELECTED ROWS ${rows.length}'); 88 | 89 | await conn.close(); 90 | }); 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /test/framer_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:buffer/buffer.dart'; 5 | import 'package:postgres/src/message_window.dart'; 6 | import 'package:postgres/src/messages/logical_replication_messages.dart'; 7 | import 'package:postgres/src/messages/server_messages.dart'; 8 | import 'package:postgres/src/messages/shared_messages.dart'; 9 | import 'package:postgres/src/types/codec.dart'; 10 | import 'package:test/test.dart'; 11 | 12 | void main() { 13 | Future parse(Uint8List buffer, messages) async { 14 | expect( 15 | await Stream.fromIterable([buffer]) 16 | .transform(BytesToMessageParser(CodecContext.withDefaults())) 17 | .toList(), 18 | messages, 19 | ); 20 | 21 | expect( 22 | await Stream.fromIterable(buffer.expand((b) => [ 23 | Uint8List.fromList([b]) 24 | ])) 25 | .transform(BytesToMessageParser(CodecContext.withDefaults())) 26 | .toList(), 27 | messages, 28 | ); 29 | 30 | for (var i = 1; i < buffer.length - 1; i++) { 31 | final splitBuffers = fragmentedMessageBuffer(buffer, i); 32 | expect( 33 | await Stream.fromIterable(splitBuffers) 34 | .transform(BytesToMessageParser(CodecContext.withDefaults())) 35 | .toList(), 36 | messages, 37 | ); 38 | } 39 | } 40 | 41 | test('Perfectly sized message in one buffer', () async { 42 | await parse( 43 | bufferWithMessages([ 44 | messageWithBytes([1, 2, 3], 1), 45 | ]), 46 | [ 47 | UnknownMessage(1, Uint8List.fromList([1, 2, 3])), 48 | ]); 49 | }); 50 | 51 | test('Two perfectly sized messages in one buffer', () async { 52 | await parse( 53 | bufferWithMessages([ 54 | messageWithBytes([1, 2, 3], 1), 55 | messageWithBytes([1, 2, 3, 4], 2), 56 | ]), 57 | [ 58 | UnknownMessage(1, Uint8List.fromList([1, 2, 3])), 59 | UnknownMessage(2, Uint8List.fromList([1, 2, 3, 4])), 60 | ]); 61 | }); 62 | 63 | test('Header fragment', () async { 64 | await parse( 65 | bufferWithMessages([ 66 | messageWithBytes([], 1), // frame with no data 67 | [1], // only a header fragment 68 | ]), 69 | [UnknownMessage(1, Uint8List.fromList([]))]); 70 | }); 71 | 72 | test('Identify CopyDoneMessage with length equals size length (min)', 73 | () async { 74 | // min length 75 | final length = [0, 0, 0, 4]; // min length (4 bytes) as 32-bit 76 | final bytes = Uint8List.fromList([ 77 | SharedMessageId.copyDone, 78 | ...length, 79 | ]); 80 | await parse( 81 | bytes, [isA().having((m) => m.length, 'length', 4)]); 82 | }); 83 | 84 | test('Identify CopyDoneMessage when length larger than size length', 85 | () async { 86 | final length = (ByteData(4)..setUint32(0, 42)).buffer.asUint8List(); 87 | final bytes = Uint8List.fromList([ 88 | SharedMessageId.copyDone, 89 | ...length, 90 | ]); 91 | 92 | await parse( 93 | bytes, [isA().having((m) => m.length, 'length', 42)]); 94 | }); 95 | 96 | test('Adds XLogDataMessage to queue', () async { 97 | final bits64 = (ByteData(8)..setUint64(0, 42)).buffer.asUint8List(); 98 | // random data bytes 99 | final dataBytes = [1, 2, 3, 4, 5, 6, 7, 8]; 100 | 101 | /// This represent a raw [XLogDataMessage] 102 | final xlogDataMessage = [ 103 | ReplicationMessageId.xLogData, 104 | ...bits64, // walStart (64bit) 105 | ...bits64, // walEnd (64bit) 106 | ...bits64, // time (64bit) 107 | ...dataBytes // bytes (any) 108 | ]; 109 | final length = ByteData(4)..setUint32(0, xlogDataMessage.length + 4); 110 | 111 | // this represents the [CopyDataMessage] which is a wrapper for [XLogDataMessage] 112 | // and such 113 | final copyDataBytes = [ 114 | SharedMessageId.copyData, 115 | ...length.buffer.asUint8List(), 116 | ...xlogDataMessage, 117 | ]; 118 | 119 | await parse(Uint8List.fromList(copyDataBytes), [ 120 | allOf( 121 | isA(), 122 | isNot(isA()), 123 | ), 124 | ]); 125 | }); 126 | 127 | test('Adds XLogDataLogicalMessage with JsonMessage to queue', () async { 128 | final bits64 = (ByteData(8)..setUint64(0, 42)).buffer.asUint8List(); 129 | 130 | /// represent an empty json object so we should get a XLogDataLogicalMessage 131 | /// with JsonMessage as its message. 132 | final dataBytes = '{}'.codeUnits; 133 | 134 | /// This represent a raw [XLogDataMessage] 135 | final xlogDataMessage = [ 136 | ReplicationMessageId.xLogData, 137 | ...bits64, // walStart (64bit) 138 | ...bits64, // walEnd (64bit) 139 | ...bits64, // time (64bit) 140 | ...dataBytes, // bytes (any) 141 | ]; 142 | 143 | final length = ByteData(4)..setUint32(0, xlogDataMessage.length + 4); 144 | 145 | /// this represents the [CopyDataMessage] in which [XLogDataMessage] 146 | /// is delivered per protocol 147 | final copyDataMessage = [ 148 | SharedMessageId.copyData, 149 | ...length.buffer.asUint8List(), 150 | ...xlogDataMessage, 151 | ]; 152 | 153 | await parse(Uint8List.fromList(copyDataMessage), [ 154 | isA() 155 | .having((x) => x.message, 'message', isA()), 156 | ]); 157 | }); 158 | 159 | test('Adds PrimaryKeepAliveMessage to queue', () async { 160 | final bits64 = (ByteData(8)..setUint64(0, 42)).buffer.asUint8List(); 161 | 162 | /// This represent a raw [PrimaryKeepAliveMessage] 163 | final xlogDataMessage = [ 164 | ReplicationMessageId.primaryKeepAlive, 165 | ...bits64, // walEnd (64bits) 166 | ...bits64, // time (64bits) 167 | 0, // mustReply (1bit) 168 | ]; 169 | final length = ByteData(4)..setUint32(0, xlogDataMessage.length + 4); 170 | 171 | /// This represents the [CopyDataMessage] in which [PrimaryKeepAliveMessage] 172 | /// is delivered per protocol 173 | final copyDataMessage = [ 174 | SharedMessageId.copyData, 175 | ...length.buffer.asUint8List(), 176 | ...xlogDataMessage, 177 | ]; 178 | 179 | await parse( 180 | Uint8List.fromList(copyDataMessage), [isA()]); 181 | }); 182 | 183 | test('Adds raw CopyDataMessage for unknown stream message', () async { 184 | final xlogDataBytes = [ 185 | -1, // unknown id 186 | ]; 187 | 188 | final length = ByteData(4)..setUint32(0, xlogDataBytes.length + 4); 189 | 190 | /// This represents the [CopyDataMessage] in which data is delivered per protocol 191 | /// typically contains [XLogData] and such but this tests unknown content 192 | final copyDataMessage = [ 193 | SharedMessageId.copyData, 194 | ...length.buffer.asUint8List(), 195 | ...xlogDataBytes, 196 | ]; 197 | 198 | await parse(Uint8List.fromList(copyDataMessage), [isA()]); 199 | }); 200 | } 201 | 202 | List messageWithBytes(List bytes, int messageID) { 203 | final buffer = BytesBuilder(); 204 | buffer.addByte(messageID); 205 | final lengthBuffer = ByteData(4); 206 | lengthBuffer.setUint32(0, bytes.length + 4); 207 | buffer.add(lengthBuffer.buffer.asUint8List()); 208 | buffer.add(bytes); 209 | return buffer.toBytes(); 210 | } 211 | 212 | List fragmentedMessageBuffer(List message, int pivotPoint) { 213 | final l1 = message.sublist(0, pivotPoint); 214 | final l2 = message.sublist(pivotPoint, message.length); 215 | return [castBytes(l1), castBytes(l2)]; 216 | } 217 | 218 | Uint8List bufferWithMessages(List> messages) { 219 | return Uint8List.fromList(messages.expand((l) => l).toList()); 220 | } 221 | -------------------------------------------------------------------------------- /test/json_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:postgres/postgres.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import 'docker.dart'; 5 | 6 | void main() { 7 | withPostgresServer('JSON storage', (server) { 8 | late Connection connection; 9 | 10 | setUp(() async { 11 | connection = await server.newConnection(); 12 | 13 | await connection.execute(''' 14 | CREATE TEMPORARY TABLE t (j jsonb) 15 | '''); 16 | }); 17 | 18 | tearDown(() async { 19 | await connection.close(); 20 | }); 21 | 22 | test('Can store JSON String', () async { 23 | var result = await connection 24 | .execute("INSERT INTO t (j) VALUES ('\"xyz\"'::jsonb) RETURNING j"); 25 | expect(result, [ 26 | ['xyz'] 27 | ]); 28 | result = await connection.execute('SELECT j FROM t'); 29 | expect(result, [ 30 | ['xyz'] 31 | ]); 32 | }); 33 | 34 | test('Can store JSON String with driver type annotation', () async { 35 | var result = await connection.execute( 36 | Sql.named('INSERT INTO t (j) VALUES (@a:jsonb) RETURNING j'), 37 | parameters: {'a': 'xyz'}); 38 | expect(result, [ 39 | ['xyz'] 40 | ]); 41 | result = await connection.execute('SELECT j FROM t'); 42 | expect(result, [ 43 | ['xyz'] 44 | ]); 45 | }); 46 | 47 | test('Can store JSON Number', () async { 48 | var result = await connection 49 | .execute("INSERT INTO t (j) VALUES ('4'::jsonb) RETURNING j"); 50 | expect(result, [ 51 | [4] 52 | ]); 53 | result = await connection.execute('SELECT j FROM t'); 54 | expect(result, [ 55 | [4] 56 | ]); 57 | }); 58 | 59 | test('Can store JSON Number with driver type annotation', () async { 60 | var result = await connection.execute( 61 | Sql.named('INSERT INTO t (j) VALUES (@a:jsonb) RETURNING j'), 62 | parameters: {'a': 4}); 63 | expect(result, [ 64 | [4] 65 | ]); 66 | result = await connection.execute('SELECT j FROM t'); 67 | expect(result, [ 68 | [4] 69 | ]); 70 | }); 71 | 72 | test('Can store JSON map', () async { 73 | var result = await connection 74 | .execute("INSERT INTO t (j) VALUES ('{\"a\":4}') RETURNING j"); 75 | expect(result, [ 76 | [ 77 | {'a': 4} 78 | ] 79 | ]); 80 | result = await connection.execute('SELECT j FROM t'); 81 | expect(result, [ 82 | [ 83 | {'a': 4} 84 | ] 85 | ]); 86 | }); 87 | 88 | test('Can store JSON map with driver type annotation', () async { 89 | var result = await connection.execute( 90 | Sql.named('INSERT INTO t (j) VALUES (@a:jsonb) RETURNING j'), 91 | parameters: { 92 | 'a': {'a': 4} 93 | }); 94 | expect(result, [ 95 | [ 96 | {'a': 4} 97 | ] 98 | ]); 99 | result = await connection.execute('SELECT j FROM t'); 100 | expect(result, [ 101 | [ 102 | {'a': 4} 103 | ] 104 | ]); 105 | }); 106 | test('Can store JSON map with execute', () async { 107 | final result = await connection.execute( 108 | Sql.named('INSERT INTO t (j) VALUES (@a:jsonb) RETURNING j'), 109 | parameters: { 110 | 'a': {'a': 4} 111 | }); 112 | expect(result, hasLength(1)); 113 | final resultQuery = await connection.execute('SELECT j FROM t'); 114 | expect(resultQuery, [ 115 | [ 116 | {'a': 4} 117 | ] 118 | ]); 119 | }); 120 | 121 | test('Can store JSON list', () async { 122 | var result = await connection 123 | .execute("INSERT INTO t (j) VALUES ('[{\"a\":4}]') RETURNING j"); 124 | expect(result, [ 125 | [ 126 | [ 127 | {'a': 4} 128 | ] 129 | ] 130 | ]); 131 | result = await connection.execute('SELECT j FROM t'); 132 | expect(result, [ 133 | [ 134 | [ 135 | {'a': 4} 136 | ] 137 | ] 138 | ]); 139 | }); 140 | 141 | test('Can store JSON list with driver type annotation', () async { 142 | var result = await connection.execute( 143 | Sql.named('INSERT INTO t (j) VALUES (@a:jsonb) RETURNING j'), 144 | parameters: { 145 | 'a': [ 146 | {'a': 4} 147 | ] 148 | }); 149 | expect(result, [ 150 | [ 151 | [ 152 | {'a': 4} 153 | ] 154 | ] 155 | ]); 156 | result = await connection.execute('SELECT j FROM t'); 157 | expect(result, [ 158 | [ 159 | [ 160 | {'a': 4} 161 | ] 162 | ] 163 | ]); 164 | }); 165 | }); 166 | } 167 | -------------------------------------------------------------------------------- /test/non_ascii_connection_strings_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:postgres/postgres.dart'; 2 | import 'package:test/expect.dart'; 3 | import 'package:test/scaffolding.dart'; 4 | 5 | import 'docker.dart'; 6 | 7 | void main() { 8 | final username = 'abc@def'; 9 | final password = 'pöstgrēs_üšęr_pæsswœêrd'; 10 | 11 | withPostgresServer( 12 | 'non-ascii tests (clear password auth)', 13 | pgUser: username, 14 | pgPassword: password, 15 | pgHbaConfContent: _sampleHbaConfigPassword, 16 | (server) { 17 | Connection? conn; 18 | tearDown(() async { 19 | await conn?.close(); 20 | }); 21 | 22 | test('- Connect with non-ascii connection string', () async { 23 | conn = await server.newConnection(sslMode: SslMode.disable); 24 | final res = await conn!.execute('select 1;'); 25 | expect(res.length, 1); 26 | }); 27 | }, 28 | ); 29 | 30 | withPostgresServer( 31 | 'non-ascii tests (md5 auth)', 32 | pgUser: username, 33 | pgPassword: password, 34 | pgHbaConfContent: _sampleHbaConfigMd5, 35 | (server) { 36 | Connection? conn; 37 | tearDown(() async { 38 | await conn?.close(); 39 | }); 40 | 41 | test('- Connect with non-ascii connection string', () async { 42 | conn = await server.newConnection(); 43 | final res = await conn!.execute('select 1;'); 44 | expect(res.length, 1); 45 | }); 46 | }, 47 | ); 48 | 49 | withPostgresServer( 50 | 'non-ascii tests (scram-sha-256 auth)', 51 | pgUser: username, 52 | pgPassword: password, 53 | pgHbaConfContent: _sampleHbaConfigScramSha256, 54 | (server) { 55 | Connection? conn; 56 | 57 | tearDown(() async { 58 | await conn?.close(); 59 | }); 60 | 61 | test('- Connect with non-ascii connection string', () async { 62 | conn = await server.newConnection(); 63 | final res = await conn!.execute('select 1;'); 64 | expect(res.length, 1); 65 | }); 66 | }, 67 | ); 68 | } 69 | 70 | /* -------------------------------------------------------------------------- */ 71 | /* helper methods and getters */ 72 | /* -------------------------------------------------------------------------- */ 73 | 74 | String get _sampleHbaConfigPassword => 75 | _sampleHbaContentTrust.replaceAll('trust', 'password'); 76 | 77 | String get _sampleHbaConfigMd5 => 78 | _sampleHbaContentTrust.replaceAll('trust', 'md5'); 79 | 80 | String get _sampleHbaConfigScramSha256 => 81 | _sampleHbaContentTrust.replaceAll('trust', 'scram-sha-256'); 82 | 83 | /// METHOD can be "trust", "reject", "md5", "password", "scram-sha-256", 84 | /// "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert". 85 | /// 86 | /// Currently, the package only supports: 'md5', 'password', 'scram-sha-256'. 87 | /// See [AuthenticationScheme] within `src/auth/auth.dart` 88 | const _sampleHbaContentTrust = ''' 89 | # TYPE DATABASE USER ADDRESS METHOD 90 | 91 | # "local" is for Unix domain socket connections only 92 | local all all trust 93 | # IPv4 local connections: 94 | host all all 127.0.0.1/32 trust 95 | # IPv6 local connections: 96 | host all all ::1/128 trust 97 | 98 | # when using containers 99 | host all all 0.0.0.0/0 trust 100 | '''; 101 | -------------------------------------------------------------------------------- /test/not_enough_bytes_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:postgres/postgres.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import 'docker.dart'; 5 | 6 | void main() { 7 | withPostgresServer('not enough bytes to read', (server) { 8 | test('case #1', () async { 9 | final conn = await server.newConnection(); 10 | await conn.execute(_testDdl, queryMode: QueryMode.simple); 11 | final rs1 = await conn.execute('SELECT l.id, bn.novel_id as novels ' 12 | 'FROM books l LEFT JOIN book_novel bn on l.id=bn.book_id;'); 13 | expect(rs1.single, [359, null]); 14 | 15 | final rs2 = 16 | await conn.execute('SELECT l.id, ARRAY_AGG(bn.novel_id) as novels ' 17 | 'FROM books l LEFT JOIN book_novel bn on l.id=bn.book_id ' 18 | 'GROUP BY l.id;'); 19 | expect(rs2.single, [ 20 | 359, 21 | [null] 22 | ]); 23 | }); 24 | }); 25 | } 26 | 27 | final _testDdl = ''' 28 | CREATE TABLE IF NOT EXISTS books ( 29 | id INTEGER NOT NULL PRIMARY KEY, 30 | title TEXT NOT NULL, 31 | first_publication INTEGER, 32 | notes TEXT, 33 | opinion_id INTEGER NOT NULL 34 | ); 35 | 36 | CREATE TABLE IF NOT EXISTS book_novel ( 37 | book_id INTEGER NOT NULL, 38 | novel_id INTEGER NOT NULL, 39 | PRIMARY KEY (book_id,novel_id) 40 | ); 41 | 42 | INSERT INTO books (id,title,first_publication,notes,opinion_id) VALUES (359,'The legacy of heorot',1987,NULL,0); 43 | INSERT INTO book_novel (book_id,novel_id) VALUES (1268,215); 44 | '''; 45 | -------------------------------------------------------------------------------- /test/notification_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:postgres/postgres.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import 'docker.dart'; 7 | 8 | void main() { 9 | withPostgresServer('Successful notifications', (server) { 10 | late Connection connection; 11 | setUp(() async { 12 | connection = await server.newConnection(); 13 | }); 14 | 15 | tearDown(() async { 16 | await connection.close(); 17 | }); 18 | 19 | test('Notification Response', () async { 20 | final channel = 'virtual'; 21 | final payload = 'This is the payload'; 22 | final futureMsg = connection.channels.all.first; 23 | await connection.execute( 24 | 'LISTEN $channel;' 25 | "NOTIFY $channel, '$payload';", 26 | queryMode: QueryMode.simple, 27 | ); 28 | 29 | final msg = await futureMsg.timeout(Duration(milliseconds: 200)); 30 | expect(msg.channel, channel); 31 | expect(msg.payload, payload); 32 | }); 33 | 34 | test('Notification Response empty payload', () async { 35 | final channel = 'virtual'; 36 | final futureMsg = connection.channels.all.first; 37 | await connection.execute( 38 | 'LISTEN $channel;' 39 | 'NOTIFY $channel;', 40 | queryMode: QueryMode.simple, 41 | ); 42 | 43 | final msg = await futureMsg.timeout(Duration(milliseconds: 200)); 44 | expect(msg.channel, channel); 45 | expect(msg.payload, ''); 46 | }); 47 | 48 | test('Notification UNLISTEN', () async { 49 | final channel = 'virtual'; 50 | final payload = 'This is the payload'; 51 | var futureMsg = connection.channels.all.first; 52 | await connection.execute( 53 | 'LISTEN $channel;' 54 | "NOTIFY $channel, '$payload';", 55 | queryMode: QueryMode.simple, 56 | ); 57 | 58 | final msg = await futureMsg.timeout(Duration(milliseconds: 200)); 59 | 60 | expect(msg.channel, channel); 61 | expect(msg.payload, payload); 62 | 63 | await connection.execute('UNLISTEN $channel;'); 64 | 65 | futureMsg = connection.channels.all.first; 66 | 67 | try { 68 | await connection.execute( 69 | "NOTIFY $channel, '$payload';", 70 | queryMode: QueryMode.simple, 71 | ); 72 | 73 | await futureMsg.timeout(Duration(milliseconds: 200)); 74 | 75 | fail('There should be no notification'); 76 | } on TimeoutException catch (_) {} 77 | }); 78 | 79 | test('Notification many channel', () async { 80 | final countResponse = {}; 81 | var totalCountResponse = 0; 82 | final finishExecute = Completer(); 83 | connection.channels.all.listen((msg) { 84 | final count = countResponse[msg.channel]; 85 | countResponse[msg.channel] = (count ?? 0) + 1; 86 | totalCountResponse++; 87 | if (totalCountResponse == 20) finishExecute.complete(); 88 | }); 89 | 90 | final channel1 = 'virtual1'; 91 | final channel2 = 'virtual2'; 92 | 93 | Future notifier() async { 94 | for (var i = 0; i < 5; i++) { 95 | await connection.execute( 96 | 'NOTIFY $channel1;' 97 | 'NOTIFY $channel2;', 98 | queryMode: QueryMode.simple, 99 | ); 100 | } 101 | } 102 | 103 | await connection.execute('LISTEN $channel1;'); 104 | await notifier(); 105 | 106 | await connection.execute('LISTEN $channel2;'); 107 | await notifier(); 108 | 109 | await connection.execute('UNLISTEN $channel1;'); 110 | await notifier(); 111 | 112 | await connection.execute('UNLISTEN $channel2;'); 113 | await notifier(); 114 | 115 | await finishExecute.future.timeout(Duration(milliseconds: 200)); 116 | 117 | expect(countResponse[channel1], 10); 118 | expect(countResponse[channel2], 10); 119 | }, timeout: Timeout(Duration(seconds: 5))); 120 | }); 121 | } 122 | -------------------------------------------------------------------------------- /test/pg_configs/pg_hba.conf: -------------------------------------------------------------------------------- 1 | # "local" is for Unix domain socket connections only 2 | local all postgres trust 3 | local all darttrust trust 4 | local all all md5 5 | 6 | # IPv4 local connections: 7 | host all postgres 127.0.0.1/32 trust 8 | host all darttrust 127.0.0.1/32 trust 9 | host all all 127.0.0.1/32 md5 10 | 11 | # IPv6 local connections: 12 | host all postgres ::1/128 trust 13 | host all darttrust ::1/128 trust 14 | host all all ::1/128 md5 15 | 16 | # All other IPv4 (to allow testing from outside the container): 17 | host all darttrust 0.0.0.0/0 trust 18 | host all postgres 0.0.0.0/0 trust 19 | host all all 0.0.0.0/0 md5 -------------------------------------------------------------------------------- /test/pool_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:postgres/postgres.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import 'docker.dart'; 7 | 8 | void main() { 9 | withPostgresServer('generic', (server) { 10 | late Pool pool; 11 | 12 | setUp(() async { 13 | pool = Pool.withEndpoints( 14 | [await server.endpoint()], 15 | settings: PoolSettings(maxConnectionCount: 8), 16 | ); 17 | 18 | // We can't write to the public schema by default in postgres 15, so 19 | // create one for this test. 20 | await pool.execute('CREATE SCHEMA IF NOT EXISTS test'); 21 | }); 22 | tearDown(() => pool.close()); 23 | 24 | test('does not support channels', () { 25 | expect(pool.withConnection((c) async => c.channels.notify('foo')), 26 | throwsUnsupportedError); 27 | }); 28 | 29 | test('execute re-uses free connection', () async { 30 | // The temporary table is only visible to the connection creating it, so 31 | // this asserts that all statements are running on the same underlying 32 | // connection. 33 | await pool.execute('CREATE TEMPORARY TABLE foo (bar INTEGER);'); 34 | 35 | await pool.execute('INSERT INTO foo VALUES (1), (2), (3);'); 36 | final results = await pool.execute('SELECT * FROM foo'); 37 | expect(results, hasLength(3)); 38 | }); 39 | 40 | test('can use transactions', () async { 41 | // The table can't be temporary because it needs to be visible across 42 | // connections. 43 | await pool.execute( 44 | 'CREATE TABLE IF NOT EXISTS test.transactions (bar INTEGER);'); 45 | addTearDown(() => pool.execute('DROP TABLE test.transactions;')); 46 | 47 | final completeTransaction = Completer(); 48 | final transaction = pool.runTx((session) async { 49 | await session 50 | .execute('INSERT INTO test.transactions VALUES (1), (2), (3);'); 51 | await completeTransaction.future; 52 | }); 53 | 54 | var rows = await pool.execute('SELECT * FROM test.transactions'); 55 | expect(rows, isEmpty); 56 | 57 | completeTransaction.complete(); 58 | await transaction; 59 | 60 | rows = await pool.execute('SELECT * FROM test.transactions'); 61 | expect(rows, hasLength(3)); 62 | }); 63 | 64 | test('can use prepared statements', () async { 65 | await pool 66 | .execute('CREATE TABLE IF NOT EXISTS test.statements (bar INTEGER);'); 67 | addTearDown(() => pool.execute('DROP TABLE test.statements;')); 68 | 69 | final stmt = await pool.prepare('SELECT * FROM test.statements'); 70 | expect(await stmt.run([]), isEmpty); 71 | 72 | await pool.execute('INSERT INTO test.statements VALUES (1), (2), (3);'); 73 | 74 | expect(await stmt.run([]), hasLength(3)); 75 | await stmt.dispose(); 76 | }); 77 | 78 | test('disables close()', () async { 79 | late Connection leakedConnection; 80 | 81 | await pool.withConnection((connection) async { 82 | expect(connection.isOpen, isTrue); 83 | await connection.close(); 84 | expect(connection.isOpen, isTrue); 85 | 86 | leakedConnection = connection; 87 | }); 88 | 89 | await pool.close(); 90 | expect(pool.isOpen, isFalse); 91 | expect(leakedConnection.isOpen, isFalse); 92 | }); 93 | }); 94 | 95 | withPostgresServer('handles session errors', (server) { 96 | test('timeout unlocks pool', () async { 97 | final db = Pool.withEndpoints( 98 | [await server.endpoint()], 99 | settings: PoolSettings( 100 | maxConnectionCount: 1, 101 | connectTimeout: Duration(seconds: 3), 102 | ), 103 | ); 104 | 105 | await expectLater( 106 | () => db.run((_) async { 107 | // NOTE: session is not used here 108 | await db.execute('SELECT 1'); 109 | }), 110 | throwsA(isA()), 111 | ); 112 | }); 113 | 114 | test('bad query does not lock up pool instance', () async { 115 | final db = Pool.withEndpoints( 116 | [await server.endpoint()], 117 | settings: PoolSettings( 118 | maxConnectionCount: 1, 119 | ), 120 | ); 121 | 122 | for (var i = 0; i < 10; i++) { 123 | await expectLater( 124 | () => db.run((c) => c.execute('select x;')), throwsException); 125 | } 126 | 127 | await db.execute('SELECT 1'); 128 | }); 129 | 130 | test('empty query does not lock up pool instance', () async { 131 | final db = Pool.withEndpoints( 132 | [await server.endpoint()], 133 | settings: PoolSettings( 134 | maxConnectionCount: 1, 135 | ), 136 | ); 137 | 138 | await db.execute('-- test'); 139 | expect(await db.execute('SELECT 1'), [ 140 | [1] 141 | ]); 142 | }); 143 | }); 144 | 145 | withPostgresServer('limit pool connections', (server) { 146 | test('can limit concurrent connections', () async { 147 | final pool = Pool.withEndpoints( 148 | [await server.endpoint()], 149 | settings: PoolSettings(maxConnectionCount: 2), 150 | ); 151 | addTearDown(pool.close); 152 | 153 | final completeFirstTwo = Completer(); 154 | final didInvokeThird = Completer(); 155 | 156 | // Take two connections 157 | unawaited(pool.withConnection((connection) => completeFirstTwo.future)); 158 | unawaited(pool.withConnection((connection) => completeFirstTwo.future)); 159 | 160 | // Creating a third one should block. 161 | 162 | unawaited(pool.withConnection((connection) async { 163 | didInvokeThird.complete(); 164 | })); 165 | 166 | await pumpEventQueue(); 167 | expect(didInvokeThird.isCompleted, isFalse); 168 | 169 | completeFirstTwo.complete(); 170 | await didInvokeThird.future; 171 | }); 172 | }); 173 | 174 | withPostgresServer('closes old connections', (server) { 175 | test('when new connection required it', () async { 176 | final pool = Pool.withEndpoints( 177 | [await server.endpoint()], 178 | settings: PoolSettings(maxConnectionCount: 1), 179 | ); 180 | addTearDown(pool.close); 181 | 182 | final results = {}; 183 | final futures = {}; 184 | for (var i = 0; i < 10; i++) { 185 | final f = pool.withConnection((c) async { 186 | await c.execute('SELECT $i'); 187 | results.add(i); 188 | }, settings: PoolSettings(applicationName: 'x$i')); 189 | futures.add(f); 190 | } 191 | await Future.wait(futures); 192 | expect(results, hasLength(10)); 193 | }); 194 | }); 195 | 196 | withPostgresServer('Connection settings', (server) { 197 | test('runs connection.onOpen callback', () async { 198 | final pool = Pool.withEndpoints( 199 | [await server.endpoint()], 200 | settings: PoolSettings( 201 | maxConnectionCount: 1, 202 | onOpen: (c) async { 203 | await c.execute('SET application_name TO myapp;'); 204 | }, 205 | ), 206 | ); 207 | addTearDown(pool.close); 208 | 209 | final name = (await pool.execute('SHOW application_name;')).single.single; 210 | expect(name, 'myapp'); 211 | }); 212 | }); 213 | 214 | group('force close', () { 215 | Future openPool(PostgresServer server) async { 216 | final pool = Pool.withEndpoints( 217 | [await server.endpoint()], 218 | settings: PoolSettings(maxConnectionCount: 1), 219 | ); 220 | addTearDown(pool.close); 221 | return pool; 222 | } 223 | 224 | Future expectPoolClosesForcefully(Pool pool) async { 225 | await pool 226 | .close(force: true) // 227 | // If close takes too long, the test will fail (force=true would not be working correctly) 228 | // as it would be waiting for the query to finish 229 | .timeout(Duration(seconds: 1)); 230 | expect(pool.isOpen, isFalse); 231 | } 232 | 233 | Future runLongQuery(Session session) { 234 | return session.execute('select pg_sleep(10) from pg_stat_activity;'); 235 | } 236 | 237 | withPostgresServer('pool session', (server) { 238 | test('pool session', () async { 239 | final pool = await openPool(server); 240 | final started = Completer(); 241 | final rs = pool.run((s) async { 242 | started.complete(); 243 | await runLongQuery(s); 244 | }); 245 | // let it start 246 | await started.future; 247 | await Future.delayed(const Duration(milliseconds: 100)); 248 | await expectPoolClosesForcefully(pool); 249 | 250 | await expectLater(() => rs, throwsA(isA())); 251 | }); 252 | }); 253 | 254 | withPostgresServer('tx session', (server) { 255 | test('tx', () async { 256 | final pool = await openPool(server); 257 | final started = Completer(); 258 | final rs = pool.runTx((s) async { 259 | started.complete(); 260 | await runLongQuery(s); 261 | }); 262 | // let it start 263 | await started.future; 264 | await Future.delayed(const Duration(milliseconds: 100)); 265 | await expectPoolClosesForcefully(pool); 266 | 267 | await expectLater(() => rs, throwsA(isA())); 268 | }); 269 | }); 270 | 271 | withPostgresServer('inner connection close', (server) { 272 | test('connection from inside withConnection', () async { 273 | final pool = await openPool(server); 274 | final rs = pool.withConnection((c) async { 275 | await Future.wait([ 276 | Future.delayed( 277 | Duration(milliseconds: 200), 278 | () => c.close(force: true), 279 | ), 280 | runLongQuery(c), 281 | ]); 282 | }); 283 | await expectLater(() => rs, throwsA(isA())); 284 | }); 285 | }); 286 | }); 287 | } 288 | -------------------------------------------------------------------------------- /test/server_messages_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:postgres/src/messages/server_messages.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | test('UnknownMessage equality', () { 8 | final a = UnknownMessage(1, Uint8List.fromList([0])); 9 | final b = UnknownMessage(1, Uint8List.fromList([])); 10 | final c = UnknownMessage(0, Uint8List.fromList([0])); 11 | final d = UnknownMessage(1, Uint8List.fromList([0])); 12 | // == needs to null check the data field 13 | expect(a != b, isTrue); 14 | 15 | /// == needs to null check the code field 16 | expect(a != c, isTrue); 17 | 18 | // == needs needs to check the type before comparing. 19 | // ignore: unrelated_type_equality_checks 20 | expect(a != 2, isTrue); 21 | 22 | // equal objects should be equal 23 | expect(a == d, isTrue); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /test/text_search_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:postgres/src/types/text_search.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import 'docker.dart'; 5 | 6 | void main() { 7 | withPostgresServer('tsvector', (server) { 8 | test('decode output', () async { 9 | final c = await server.newConnection(); 10 | final rs = await c.execute('SELECT \'x:11,12 yy:2A,4C\'::tsvector'); 11 | final vector = rs.first.first as TsVector; 12 | expect(vector.words, hasLength(2)); 13 | expect(vector.words.first.text, 'x'); 14 | expect(vector.words.first.toString(), 'x:11,12'); 15 | expect(vector.words.last.text, 'yy'); 16 | expect( 17 | vector.words.last.positions?.map((e) => e.toString()).toList(), 18 | ['2A', '4C'], 19 | ); 20 | }); 21 | 22 | test('encode and decode', () async { 23 | final c = await server.newConnection(); 24 | final rs = await c.execute(r'SELECT $1::tsvector', parameters: [ 25 | TsVector(words: [ 26 | TsWord('ab'), 27 | TsWord('cd', positions: [TsWordPos(4, weight: TsWeight.c)]), 28 | ]) 29 | ]); 30 | final first = rs.first.first as TsVector; 31 | expect(first.words, hasLength(2)); 32 | expect(first.words.first.text, 'ab'); 33 | expect(first.words.first.positions, isNull); 34 | expect(first.words.last.text, 'cd'); 35 | expect(first.words.last.positions?.single.toString(), '4C'); 36 | }); 37 | 38 | test('store and read', () async { 39 | final c = await server.newConnection(); 40 | await c 41 | .execute('CREATE TABLE t (id TEXT, tsv TSVECTOR, PRIMARY KEY (id))'); 42 | await c.execute( 43 | r'INSERT INTO t VALUES ($1, $2)', 44 | parameters: [ 45 | 'a', 46 | TsVector(words: [ 47 | TsWord('abc', positions: [TsWordPos(1)]), 48 | TsWord('def'), 49 | ]), 50 | ], 51 | ); 52 | final rs = 53 | await c.execute(r'SELECT * FROM t WHERE id = $1', parameters: ['a']); 54 | final row = rs.single; 55 | final tsv = row[1] as TsVector; 56 | expect(tsv.toString(), 'abc:1 def'); 57 | }); 58 | }); 59 | 60 | withPostgresServer('tsquery', (server) { 61 | test('read and re-read queries', () async { 62 | final queries = { 63 | 'x': "'x'", 64 | '!x': "!'x'", 65 | 'x & y': "'x' & 'y'", 66 | 'x | y': "'x' | 'y'", 67 | 'x <-> y': "'x' <1> 'y'", 68 | 'x <4> y': "'x' <4> 'y'", 69 | 'x & !(y <2> z)': "'x' & !('y' <2> 'z')", 70 | 'x & y & z & zz': "'x' & 'y' & 'z' & 'zz'", 71 | 'x:A': "'x':A", 72 | 'x:*': "'x':*", 73 | 'x:A*B': "'x':*AB", 74 | 'x:B & y:AC': "'x':B & 'y':AC", 75 | }; 76 | final c = await server.newConnection(); 77 | for (final e in queries.entries) { 78 | final rs = await c.execute("SELECT '${e.key}'::tsquery"); 79 | final first = rs.first.first; 80 | final s1 = first.toString(); 81 | expect(s1, e.value ?? e.key, reason: e.key); 82 | final rs2 = await c.execute(r'SELECT $1::tsquery', parameters: [first]); 83 | final s2 = rs2.first.first.toString(); 84 | expect(s2, s1, reason: e.key); 85 | } 86 | }); 87 | 88 | test('match queries to vectors', () async { 89 | final c = await server.newConnection(); 90 | 91 | Future expectMatch( 92 | TsVector vector, TsQuery query, bool expectedMatch) async { 93 | final rs = await c.execute( 94 | r'SELECT $1::tsvector @@ $2::tsquery', 95 | parameters: [vector, query], 96 | ); 97 | expect(rs.first.first, expectedMatch); 98 | } 99 | 100 | final vector = TsVector(words: [ 101 | TsWord('abc', positions: [TsWordPos(1)]), 102 | TsWord('cde', positions: [TsWordPos(2)]), 103 | TsWord('xyz', positions: [TsWordPos(3)]), 104 | ]); 105 | 106 | await expectMatch( 107 | vector, 108 | TsQuery.word('cd', prefix: true), 109 | true, 110 | ); 111 | await expectMatch( 112 | vector, 113 | TsQuery.word('cde').followedBy(TsQuery.word('xyz')), 114 | true, 115 | ); 116 | await expectMatch( 117 | vector, 118 | TsQuery.word('cde').followedBy(TsQuery.word('efg')), 119 | false, 120 | ); 121 | }); 122 | }); 123 | } 124 | -------------------------------------------------------------------------------- /test/time_converter_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:postgres/src/time_converters.dart'; 2 | import 'package:test/expect.dart'; 3 | import 'package:test/scaffolding.dart'; 4 | 5 | void main() { 6 | group('test time conversion from pg to dart and vice versa', () { 7 | test('pgTimeToDateTime produces correct DateTime', () { 8 | final timeFromPg = dateTimeFromMicrosecondsSinceY2k(0); 9 | 10 | expect(timeFromPg.year, 2000); 11 | expect(timeFromPg.month, 1); 12 | expect(timeFromPg.day, 1); 13 | }); 14 | 15 | test('dateTimeToPgTime produces correct microseconds since 2000-01-01', () { 16 | // final timeFromPg = pgTimeToDateTime(0); 17 | final dateTime = DateTime.utc(2000, 1, 1); 18 | final pgTime = dateTimeToMicrosecondsSinceY2k(dateTime); 19 | expect(pgTime, 0); 20 | }); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /test/timeout_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:postgres/postgres.dart'; 4 | import 'package:postgres/src/v3/connection.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import 'docker.dart'; 8 | 9 | void main() { 10 | withPostgresServer('timeout', (server) { 11 | late Connection conn; 12 | 13 | setUp(() async { 14 | conn = await server.newConnection(); 15 | await conn.execute('CREATE TEMPORARY TABLE t (id INT UNIQUE)'); 16 | }); 17 | 18 | tearDown(() async { 19 | await conn.close(); 20 | }); 21 | 22 | test('Cancel current statement through a new connection', () async { 23 | final f = conn.execute('SELECT pg_sleep(2);'); 24 | await (conn as PgConnectionImplementation).cancelPendingStatement(); 25 | await expectLater( 26 | f, 27 | throwsA( 28 | allOf( 29 | isA(), 30 | isA(), 31 | ), 32 | ), 33 | ); 34 | // connection is still usable 35 | final rs = await conn.execute('SELECT 1;'); 36 | expect(rs[0][0], 1); 37 | }); 38 | 39 | test('Timeout fires during transaction rolls ack transaction', () async { 40 | try { 41 | await conn.runTx((ctx) async { 42 | await ctx.execute('INSERT INTO t (id) VALUES (1)'); 43 | await ctx.execute('SELECT pg_sleep(2)', 44 | timeout: Duration(seconds: 1)); 45 | }); 46 | fail('unreachable'); 47 | } on TimeoutException { 48 | // ignore 49 | } 50 | 51 | expect(await conn.execute('SELECT * from t'), hasLength(0)); 52 | }); 53 | 54 | test('Timeout is ignored when new statement is run on parent context', 55 | () async { 56 | try { 57 | await conn.runTx((ctx) async { 58 | await conn.execute('SELECT 1', timeout: Duration(seconds: 1)); 59 | await ctx.execute('INSERT INTO t (id) VALUES (1)'); 60 | }); 61 | fail('unreachable'); 62 | } on PgException catch (e) { 63 | expect(e.message, contains('runTx')); 64 | // ignore 65 | } 66 | 67 | expect(await conn.execute('SELECT * from t'), hasLength(0)); 68 | }); 69 | 70 | test( 71 | 'If query is already on the wire and times out, safely throws timeoutexception and nothing else', 72 | () async { 73 | try { 74 | await conn.execute('SELECT pg_sleep(2)', timeout: Duration(seconds: 1)); 75 | fail('unreachable'); 76 | } on TimeoutException { 77 | // ignore 78 | } 79 | }); 80 | 81 | test('Query times out, next query in the queue runs', () async { 82 | final rs = await conn.execute('SELECT 1'); 83 | //ignore: unawaited_futures 84 | conn 85 | .execute('SELECT pg_sleep(2)', timeout: Duration(seconds: 1)) 86 | .catchError((_) => rs); 87 | 88 | expect(await conn.execute('SELECT 1'), [ 89 | [1] 90 | ]); 91 | }); 92 | 93 | test('Query that succeeds does not timeout', () async { 94 | await conn.execute('SELECT 1', timeout: Duration(seconds: 1)); 95 | }); 96 | 97 | test('Query that fails does not timeout', () async { 98 | final rs = await conn.execute('SELECT 1'); 99 | Exception? caught; 100 | await conn 101 | .execute("INSERT INTO t (id) VALUES ('foo')", 102 | timeout: Duration(seconds: 1)) 103 | .catchError((e) { 104 | caught = e; 105 | // needs this to match return type 106 | return rs; 107 | }); 108 | expect(caught, isA()); 109 | }); 110 | }); 111 | 112 | // Note: to fix this, we may consider cancelling the currently running statements: 113 | // https://www.postgresql.org/docs/current/protocol-flow.html#PROTOCOL-FLOW-CANCELING-REQUESTS 114 | withPostgresServer('timeout race conditions', (server) { 115 | setUp(() async { 116 | final c1 = await server.newConnection(); 117 | await c1.execute('CREATE TABLE t (id INT PRIMARY KEY);'); 118 | await c1.execute('INSERT INTO t (id) values (1);'); 119 | }); 120 | 121 | test('two transactions for update', () async { 122 | for (final qm in QueryMode.values) { 123 | final c1 = await server.newConnection(); 124 | final c2 = await server.newConnection(queryMode: qm); 125 | await c1.execute('BEGIN'); 126 | await c1.execute('SELECT * FROM t WHERE id=1 FOR UPDATE'); 127 | await c2.execute('BEGIN'); 128 | try { 129 | await c2.execute('SELECT * FROM t WHERE id=1 FOR UPDATE', 130 | timeout: Duration(seconds: 1)); 131 | fail('unreachable'); 132 | } on TimeoutException catch (_) { 133 | // ignore 134 | } 135 | await c1.execute('ROLLBACK'); 136 | await c2.execute('ROLLBACK'); 137 | 138 | await c1.execute('SELECT 1'); 139 | await c2.execute('SELECT 1'); 140 | } 141 | }); 142 | }); 143 | } 144 | -------------------------------------------------------------------------------- /test/transaction_isolation_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:postgres/postgres.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import 'docker.dart'; 7 | 8 | void main() { 9 | withPostgresServer('transaction isolations', (server) { 10 | late Connection conn1; 11 | late Connection conn2; 12 | 13 | setUp(() async { 14 | conn1 = await server.newConnection(); 15 | conn2 = await server.newConnection(); 16 | await conn1.execute('CREATE TABLE t (id INT PRIMARY KEY, counter INT)'); 17 | await conn1.execute('INSERT INTO t VALUES (1, 0)'); 18 | }); 19 | 20 | tearDown(() async { 21 | await conn1.execute('DROP TABLE t;'); 22 | await conn1.close(); 23 | await conn2.close(); 24 | }); 25 | 26 | test('read committed works as expected', () async { 27 | final c1 = Completer(); 28 | final c2 = Completer(); 29 | final c3 = Completer(); 30 | final f1 = Future.microtask( 31 | () => conn1.runTx( 32 | settings: TransactionSettings( 33 | isolationLevel: IsolationLevel.readCommitted, 34 | ), 35 | (session) async { 36 | await c2.future; 37 | await session 38 | .execute('UPDATE t SET counter = counter + 1 WHERE id=1'); 39 | c1.complete(); 40 | // await c3.future; 41 | }, 42 | ), 43 | ); 44 | final f2 = Future.microtask( 45 | () => conn2.runTx( 46 | settings: TransactionSettings( 47 | isolationLevel: IsolationLevel.readCommitted, 48 | ), 49 | (session) async { 50 | c2.complete(); 51 | await c1.future; 52 | await session 53 | .execute('UPDATE t SET counter = counter + 1 WHERE id=1'); 54 | c3.complete(); 55 | }, 56 | ), 57 | ); 58 | await Future.wait([f1, f2]); 59 | final rs = await conn1.execute('SELECT * from t WHERE id=1'); 60 | expect(rs.single, [1, 2]); 61 | }); 62 | 63 | test('forced serialization failure', () async { 64 | final c1 = Completer(); 65 | final c2 = Completer(); 66 | final c3 = Completer(); 67 | final f1 = Future.microtask( 68 | () => conn1.runTx( 69 | settings: TransactionSettings( 70 | isolationLevel: IsolationLevel.serializable, 71 | ), 72 | (session) async { 73 | await c2.future; 74 | await session 75 | .execute('UPDATE t SET counter = counter + 1 WHERE id=1'); 76 | c1.complete(); 77 | // await c3.future; 78 | }, 79 | ), 80 | ); 81 | final f2 = Future.microtask( 82 | () => conn2.runTx( 83 | settings: TransactionSettings( 84 | isolationLevel: IsolationLevel.serializable, 85 | ), 86 | (session) async { 87 | c2.complete(); 88 | await c1.future; 89 | await session 90 | .execute('UPDATE t SET counter = counter + 1 WHERE id=1'); 91 | c3.complete(); 92 | }, 93 | ), 94 | ); 95 | await expectLater( 96 | () => Future.wait([f1, f2]), throwsA(isA())); 97 | final rs = await conn1.execute('SELECT * from t WHERE id=1'); 98 | expect(rs.single, [1, 1]); 99 | }); 100 | }); 101 | 102 | withPostgresServer('Transaction isolation level', (server) { 103 | group('Given two rows in the database and two database connections', () { 104 | late Connection conn1; 105 | late Connection conn2; 106 | setUp(() async { 107 | conn1 = await server.newConnection(); 108 | conn2 = await server.newConnection(); 109 | await conn1.execute('CREATE TABLE t (id INT PRIMARY KEY, counter INT)'); 110 | await conn1.execute('INSERT INTO t VALUES (1, 0)'); 111 | await conn1.execute('INSERT INTO t VALUES (2, 1)'); 112 | }); 113 | 114 | tearDown(() async { 115 | await conn1.execute('DROP TABLE t;'); 116 | await conn1.close(); 117 | await conn2.close(); 118 | }); 119 | 120 | test( 121 | 'when two transactions using repeatable read isolation level' 122 | 'reads the row updated by the other transaction' 123 | 'then one transaction throws exception ', () async { 124 | final c1 = Completer(); 125 | final c2 = Completer(); 126 | final f1 = Future.microtask( 127 | () => conn1.runTx( 128 | settings: TransactionSettings( 129 | isolationLevel: IsolationLevel.serializable, 130 | ), 131 | (session) async { 132 | await session.execute('SELECT * from t WHERE id=1'); 133 | 134 | c1.complete(); 135 | await c2.future; 136 | 137 | await session 138 | .execute('UPDATE t SET counter = counter + 10 WHERE id=2'); 139 | }, 140 | ), 141 | ); 142 | final f2 = Future.microtask( 143 | () => conn2.runTx( 144 | settings: TransactionSettings( 145 | isolationLevel: IsolationLevel.serializable, 146 | ), 147 | (session) async { 148 | await session.execute('SELECT * from t WHERE id=2'); 149 | 150 | await c1.future; 151 | // If we complete both transactions in parallel, we get an unexpected 152 | // exception 153 | c2.complete(); 154 | 155 | await session 156 | .execute('UPDATE t SET counter = counter + 20 WHERE id=1'); 157 | // If we complete the first transaction after the second transaction 158 | // the correct exception is thrown 159 | // c2.complete(); 160 | }, 161 | ), 162 | ); 163 | 164 | // This test throws Severity.error Session or transaction has already 165 | // finished, did you forget to await a statement? 166 | await expectLater( 167 | () => Future.wait([f1, f2]), 168 | throwsA( 169 | isA() 170 | .having((e) => e.severity, 'Exception severity', Severity.error) 171 | .having((e) => e.code, 'Exception code', '40001'), 172 | ), 173 | ); 174 | }); 175 | }); 176 | }); 177 | } 178 | -------------------------------------------------------------------------------- /test/types_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:postgres/postgres.dart'; 2 | import 'package:test/expect.dart'; 3 | import 'package:test/scaffolding.dart'; 4 | 5 | void main() { 6 | group('LSN type', () { 7 | test('- Can parse LSN String', () { 8 | // These two numbers are equal but in different formats 9 | // see: https://www.postgresql.org/docs/current/datatype-pg-lsn.html 10 | final lsn = LSN.fromString('16/B374D848'); 11 | expect(lsn.value, 97500059720); 12 | }); 13 | 14 | test('- Can convert LSN to String', () { 15 | final lsn = LSN(97500059720); 16 | expect(lsn.toString(), '16/B374D848'); 17 | }); 18 | }); 19 | 20 | group('PgPoint type', () { 21 | test('- PgPoint hashcode', () { 22 | final point = Point(1.0, 2.0); 23 | final point2 = Point(2.0, 1.0); 24 | expect(point.hashCode, isNot(point2.hashCode)); 25 | }); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /test/unexpected_protocol_bytes_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:postgres/postgres.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import 'docker.dart'; 8 | 9 | void main() { 10 | withPostgresServer('unexpected protocol bytes', (server) { 11 | late Connection conn; 12 | late ServerSocket serverSocket; 13 | bool sendGarbageResponse = false; 14 | 15 | setUp(() async { 16 | serverSocket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); 17 | serverSocket.listen((socket) async { 18 | final clientSocket = await Socket.connect( 19 | InternetAddress.loopbackIPv4, await server.port); 20 | late StreamSubscription socketSubs; 21 | late StreamSubscription clientSubs; 22 | socketSubs = socket.listen(clientSocket.add, onDone: () { 23 | socketSubs.cancel(); 24 | clientSubs.cancel(); 25 | clientSocket.close(); 26 | }, onError: (e) { 27 | socketSubs.cancel(); 28 | clientSubs.cancel(); 29 | clientSocket.close(); 30 | }); 31 | final pattern = [68, 0, 0, 0, 11, 0, 1, 0, 0, 0, 1]; 32 | clientSubs = clientSocket.listen((data) { 33 | if (sendGarbageResponse) { 34 | final i = _bytesIndexOf(data, pattern); 35 | if (i >= 0) { 36 | data[i + pattern.length - 4] = 255; 37 | data[i + pattern.length - 3] = 255; 38 | data[i + pattern.length - 2] = 255; 39 | data[i + pattern.length - 1] = 250; 40 | } 41 | socket.add(data); 42 | } else { 43 | socket.add(data); 44 | } 45 | }, onDone: () { 46 | socketSubs.cancel(); 47 | clientSubs.cancel(); 48 | clientSocket.close(); 49 | }, onError: (e) { 50 | socketSubs.cancel(); 51 | clientSubs.cancel(); 52 | clientSocket.close(); 53 | }); 54 | }); 55 | 56 | final endpoint = await server.endpoint(); 57 | conn = await Connection.open( 58 | Endpoint( 59 | host: endpoint.host, 60 | port: serverSocket.port, 61 | database: endpoint.database, 62 | password: endpoint.password, 63 | username: endpoint.username, 64 | ), 65 | settings: ConnectionSettings( 66 | sslMode: SslMode.disable, 67 | ), 68 | ); 69 | }); 70 | 71 | tearDown(() async { 72 | await conn.close(); 73 | await serverSocket.close(); 74 | }); 75 | 76 | test('inject bad bytes', () async { 77 | await conn.execute('SELECT 1;'); 78 | sendGarbageResponse = true; 79 | await expectLater( 80 | () => conn.execute('SELECT 2;', queryMode: QueryMode.simple), 81 | throwsA(isA())); 82 | expect(conn.isOpen, false); 83 | }); 84 | }); 85 | } 86 | 87 | int _bytesIndexOf(List data, List pattern) { 88 | for (var i = 0; i < data.length - pattern.length; i++) { 89 | var matches = true; 90 | for (var j = 0; j < pattern.length; j++) { 91 | if (data[i + j] != pattern[j]) { 92 | matches = false; 93 | break; 94 | } 95 | } 96 | if (matches) { 97 | return i; 98 | } 99 | } 100 | return -1; 101 | } 102 | -------------------------------------------------------------------------------- /test/utils/package_pool_ext_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:pool/pool.dart'; 4 | import 'package:postgres/src/utils/package_pool_ext.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | void main() { 8 | group('package:pool extensions', () { 9 | test('acquire with timeout succeeds - no parallel use', () async { 10 | final pool = Pool(1); 11 | final x = await pool.withRequestTimeout( 12 | timeout: Duration(seconds: 1), 13 | (_) async { 14 | return 1; 15 | }, 16 | ); 17 | expect(x, 1); 18 | final r = await pool.request(); 19 | r.release(); 20 | await pool.close(); 21 | }); 22 | 23 | test('acquire with timeout succeeds - quick parallel use', () async { 24 | final pool = Pool(1); 25 | final other = await pool.request(); 26 | Timer(Duration(seconds: 1), other.release); 27 | var remainingMillis = 0; 28 | final x = await pool.withRequestTimeout( 29 | timeout: Duration(seconds: 2), 30 | (remaining) async { 31 | remainingMillis = remaining.inMilliseconds; 32 | return 1; 33 | }, 34 | ); 35 | expect(x, 1); 36 | final r = await pool.request(); 37 | r.release(); 38 | await pool.close(); 39 | expect(remainingMillis, greaterThan(500)); 40 | expect(remainingMillis, lessThan(1500)); 41 | }); 42 | 43 | test('acquire with timeout fails - long parallel use', () async { 44 | final pool = Pool(1); 45 | final other = await pool.request(); 46 | Timer(Duration(seconds: 2), other.release); 47 | await expectLater( 48 | pool.withRequestTimeout( 49 | timeout: Duration(seconds: 1), 50 | (_) async { 51 | return 1; 52 | }, 53 | ), 54 | throwsA(isA()), 55 | ); 56 | final sw = Stopwatch()..start(); 57 | final r = await pool.request(); 58 | sw.stop(); 59 | r.release(); 60 | await pool.close(); 61 | expect(sw.elapsedMilliseconds, greaterThan(500)); 62 | expect(sw.elapsedMilliseconds, lessThan(1500)); 63 | }); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /test/v3_close_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:postgres/postgres.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import 'docker.dart'; 7 | 8 | void main() { 9 | withPostgresServer('v3 close', (server) { 10 | late Connection conn1; 11 | late Connection conn2; 12 | 13 | const conn1Name = 'conn1'; 14 | const conn2Name = 'conn2'; 15 | 16 | setUp(() async { 17 | conn1 = await Connection.open( 18 | await server.endpoint(), 19 | settings: ConnectionSettings( 20 | applicationName: conn1Name, 21 | //transformer: _loggingTransformer('c1'), 22 | ), 23 | ); 24 | 25 | conn2 = await Connection.open( 26 | await server.endpoint(), 27 | settings: ConnectionSettings( 28 | applicationName: conn2Name, 29 | ), 30 | ); 31 | }); 32 | 33 | tearDown(() async { 34 | await conn1.close(); 35 | await conn2.close(); 36 | }); 37 | 38 | for (final concurrentQuery in [false, true]) { 39 | test( 40 | 'with concurrent query: $concurrentQuery', 41 | () async { 42 | final res = await conn2.execute( 43 | "SELECT pid FROM pg_stat_activity where application_name = '$conn1Name';"); 44 | final conn1PID = res.first.first as int; 45 | 46 | // Simulate issue by terminating a connection during a query 47 | if (concurrentQuery) { 48 | // We expect that terminating the connection will throw. 49 | expect(conn1.execute('select pg_sleep(1) from pg_stat_activity;'), 50 | _throwsPostgresException); 51 | } 52 | 53 | // Terminate the conn1 while the query is running 54 | await conn2.execute('select pg_terminate_backend($conn1PID);'); 55 | }, 56 | ); 57 | } 58 | 59 | test('with simple query protocol', () async { 60 | // Get the PID for conn1 61 | final res = await conn2.execute( 62 | "SELECT pid FROM pg_stat_activity where application_name = '$conn1Name';"); 63 | final conn1PID = res.first.first as int; 64 | 65 | // ignore: unawaited_futures 66 | expect( 67 | conn1.execute('select pg_sleep(1) from pg_stat_activity;', 68 | ignoreRows: true), 69 | _throwsPostgresException); 70 | 71 | await conn2.execute( 72 | 'select pg_terminate_backend($conn1PID) from pg_stat_activity;'); 73 | }); 74 | 75 | test('empty query does not close connection', () async { 76 | await conn1.execute('-- test'); 77 | expect(await conn1.execute('SELECT 1'), [ 78 | [1] 79 | ]); 80 | }); 81 | }); 82 | 83 | group('force close', () { 84 | Future openConnection(PostgresServer server) async { 85 | final conn = await Connection.open(await server.endpoint()); 86 | addTearDown(conn.close); 87 | return conn; 88 | } 89 | 90 | Future expectConn1ClosesForcefully(Connection conn) async { 91 | await conn 92 | .close(force: true) // 93 | // If close takes too long, the test will fail (force=true would not be working correctly) 94 | // as it would be waiting for the query to finish 95 | .timeout(Duration(seconds: 1)); 96 | expect(conn.isOpen, isFalse); 97 | } 98 | 99 | Future runLongQuery(Session session) { 100 | return session.execute('select pg_sleep(10) from pg_stat_activity;'); 101 | } 102 | 103 | withPostgresServer('connection session', (server) { 104 | test('connection session', () async { 105 | final conn = await openConnection(server); 106 | final rs = runLongQuery(conn); 107 | // let it start 108 | await Future.delayed(const Duration(milliseconds: 100)); 109 | await expectConn1ClosesForcefully(conn); 110 | 111 | await expectLater(() => rs, throwsA(isA())); 112 | }); 113 | }); 114 | 115 | withPostgresServer('tx session', (server) { 116 | test('tx', () async { 117 | final conn = await openConnection(server); 118 | final started = Completer(); 119 | final rs = conn.runTx((tx) async { 120 | started.complete(); 121 | await runLongQuery(tx); 122 | }); 123 | // let it start 124 | await started.future; 125 | await Future.delayed(const Duration(milliseconds: 100)); 126 | await expectConn1ClosesForcefully(conn); 127 | 128 | await expectLater(() => rs, throwsA(isA())); 129 | }); 130 | }); 131 | 132 | withPostgresServer('run session', (server) { 133 | test('run', () async { 134 | final conn = await openConnection(server); 135 | final started = Completer(); 136 | final rs = conn.run((s) async { 137 | started.complete(); 138 | await runLongQuery(s); 139 | }); 140 | // let it start 141 | await started.future; 142 | await Future.delayed(const Duration(milliseconds: 100)); 143 | await expectConn1ClosesForcefully(conn); 144 | 145 | await expectLater(() => rs, throwsA(isA())); 146 | }); 147 | }); 148 | }); 149 | } 150 | 151 | final _isPostgresException = isA(); 152 | final _throwsPostgresException = throwsA(_isPostgresException); 153 | -------------------------------------------------------------------------------- /test/v3_logical_replication_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:async/async.dart'; 4 | import 'package:postgres/messages.dart'; 5 | import 'package:postgres/postgres.dart'; 6 | import 'package:stream_channel/stream_channel.dart'; 7 | import 'package:test/expect.dart'; 8 | import 'package:test/scaffolding.dart'; 9 | 10 | import 'docker.dart'; 11 | 12 | /// An Interceptor that listens to server events 13 | class _ServerMessagesInterceptor { 14 | final controller = StreamController.broadcast(); 15 | 16 | Stream get messages => controller.stream; 17 | 18 | // For the current set of tests, we are only listening to server events and 19 | // we are not sending anything to the server so the second handler is left 20 | // empty 21 | late final transformer = StreamChannelTransformer( 22 | StreamTransformer.fromHandlers( 23 | handleData: (data, sink) { 24 | if (!controller.isClosed) { 25 | controller.add(data as ServerMessage); 26 | } 27 | sink.add(data); 28 | }, 29 | ), 30 | StreamSinkTransformer.fromHandlers(), 31 | ); 32 | } 33 | 34 | void main() { 35 | // NOTES: 36 | // - Two PostgreSQL connections are needed for testing replication. 37 | // - One for listening to streaming replications (this connection will be locked). 38 | // - The other one to modify the database (e.g. insert, delete, update, truncate) 39 | _testReplication(true); 40 | _testReplication(false); 41 | } 42 | 43 | _testReplication(bool binary) { 44 | withPostgresServer( 45 | 'test logical replication with pgoutput for decoding (binary:$binary)', 46 | initSqls: replicationSchemaInit, (server) { 47 | // use this for listening to messages 48 | late final Connection replicationConn; 49 | 50 | // use this for sending queries 51 | late final Connection changesConn; 52 | 53 | // used to intercept server messages in the replication connection 54 | // the interceptor is used by tests to listen to replication stream 55 | final serverMessagesInterceptor = _ServerMessagesInterceptor(); 56 | 57 | // this table is for insert, update, and delete tests. 58 | final changesTable = 'test.temp_changes_table'; 59 | 60 | // this will be used for testing truncation 61 | // must be created before hand to add in publication 62 | final truncateTable = 'test.temp_truncate_table'; 63 | 64 | setUpAll(() async { 65 | // connection setup 66 | 67 | // replication connection setup 68 | // used for creating replication slot and listening to changes in the db 69 | replicationConn = await Connection.open( 70 | Endpoint( 71 | host: 'localhost', 72 | database: 'postgres', 73 | username: 'replication', 74 | password: 'replication', 75 | port: await server.port, 76 | ), 77 | settings: ConnectionSettings( 78 | replicationMode: ReplicationMode.logical, 79 | transformer: serverMessagesInterceptor.transformer, 80 | queryMode: QueryMode.simple), 81 | ); 82 | 83 | // changes connection setup 84 | // used to create changes in the db that are reflected in the replication 85 | // stream 86 | changesConn = await Connection.open( 87 | await server.endpoint(), 88 | ); 89 | 90 | // create testing tables 91 | // note: primary keys are necessary for replication to work and they are 92 | // used as an identity replica (to allow update & delete) on tables 93 | // that are part of a publication. 94 | await changesConn.execute('create schema test'); 95 | await changesConn.execute('create table $changesTable ' 96 | '(id int GENERATED ALWAYS AS IDENTITY, value text, ' 97 | 'PRIMARY KEY (id));'); 98 | await changesConn.execute('create table $truncateTable ' 99 | '(id int GENERATED ALWAYS AS IDENTITY, value text, ' 100 | 'PRIMARY KEY (id));'); 101 | 102 | // create publication 103 | final publicationName = 'test_publication'; 104 | await changesConn.execute('DROP PUBLICATION IF EXISTS $publicationName;'); 105 | await changesConn.execute( 106 | 'CREATE PUBLICATION $publicationName FOR TABLE $changesTable, $truncateTable;', 107 | ); 108 | 109 | final sysInfoRes = await replicationConn.execute('IDENTIFY_SYSTEM;'); 110 | 111 | final xlogpos = sysInfoRes[0][2] as String; 112 | 113 | // create replication slot 114 | final slotName = 'a_test_slot'; 115 | 116 | // the logical decoding used for testing 117 | final logicalDecodingPlugin = 'pgoutput'; 118 | 119 | // `TEMPORARY` will remove the slot after the connection is closed/dropped 120 | await replicationConn 121 | .execute('CREATE_REPLICATION_SLOT $slotName TEMPORARY LOGICAL ' 122 | '$logicalDecodingPlugin NOEXPORT_SNAPSHOT'); 123 | 124 | // start replication process 125 | final statement = 'START_REPLICATION SLOT $slotName LOGICAL $xlogpos ' 126 | "(proto_version '1', publication_names '$publicationName', binary '$binary')"; 127 | 128 | await replicationConn.execute(statement); 129 | }); 130 | 131 | tearDownAll(() async { 132 | await replicationConn.close(); 133 | await changesConn.close(); 134 | await serverMessagesInterceptor.controller.close(); 135 | }); 136 | 137 | // BeginMessage -> InsertMessage -> CommitMessage 138 | test('- Receive InsertMessage after insert statement', () async { 139 | final stream = serverMessagesInterceptor.messages 140 | .where((event) => event is XLogDataMessage) 141 | .map((event) => (event as XLogDataMessage).data) 142 | // RelationMessage isn't always present (appears conditionally) so 143 | // it's skipped when present 144 | .where((event) => event is! RelationMessage) 145 | .take(3); 146 | 147 | late final StreamController controller; 148 | controller = StreamController( 149 | onListen: () async { 150 | // don't await here otherwise what's after won't be executed. 151 | final future = controller.addStream(stream); 152 | await changesConn 153 | .execute("insert into $changesTable (value) values ('test');"); 154 | await future; 155 | await controller.close(); 156 | }, 157 | ); 158 | 159 | final matchers = [ 160 | isA(), 161 | isA(), 162 | isA(), 163 | ]; 164 | 165 | expect(controller.stream, emitsInAnyOrder(matchers)); 166 | }); 167 | 168 | // BeginMessage -> UpdateMessage -> CommitMessage 169 | test('- Receive UpdateMessage after update statement', () async { 170 | // insert data to be updated 171 | await changesConn 172 | .execute("insert into $changesTable (value) values ('update_test');"); 173 | // wait to avoid capturing INSERT 174 | await Future.delayed(Duration(seconds: 3)); 175 | final stream = serverMessagesInterceptor.messages 176 | .where((event) => event is XLogDataMessage) 177 | .map((event) => (event as XLogDataMessage).data) 178 | // RelationMessage isn't always present (appears conditionally) so 179 | // it's skipped when present 180 | .where((event) => event is! RelationMessage) 181 | .take(3); 182 | 183 | late final StreamController controller; 184 | controller = StreamController( 185 | onListen: () async { 186 | // don't await here otherwise what's after won't be executed. 187 | final future = controller.addStream(stream); 188 | await changesConn.execute( 189 | "update $changesTable set value = 'updated_test_value'" 190 | "where value = 'update_test';", 191 | ); 192 | await future; 193 | await controller.close(); 194 | }, 195 | ); 196 | 197 | final matchers = [ 198 | isA(), 199 | isA(), 200 | isA(), 201 | ]; 202 | 203 | expect(controller.stream, emitsInAnyOrder(matchers)); 204 | }); 205 | // BeginMessage -> DeleteMessage -> CommitMessage 206 | test('- Receive DeleteMessage after delete statement', () async { 207 | // insert data to be delete 208 | await changesConn 209 | .execute("insert into $changesTable (value) values ('update_test');"); 210 | // wait to avoid capturing INSERT 211 | await Future.delayed(Duration(seconds: 3)); 212 | final stream = serverMessagesInterceptor.messages 213 | .where((event) => event is XLogDataMessage) 214 | .map((event) => (event as XLogDataMessage).data) 215 | // RelationMessage isn't always present (appears conditionally) so 216 | // it's skipped when present 217 | .where((event) => event is! RelationMessage) 218 | .take(3); 219 | 220 | late final StreamController controller; 221 | controller = StreamController( 222 | onListen: () async { 223 | // don't await here otherwise what's after won't be executed. 224 | final future = controller.addStream(stream); 225 | await changesConn.execute( 226 | "delete from $changesTable where value = 'update_test';", 227 | ); 228 | await future; 229 | await controller.close(); 230 | }, 231 | ); 232 | 233 | final matchers = [ 234 | isA(), 235 | isA(), 236 | isA(), 237 | ]; 238 | 239 | expect(controller.stream, emitsInAnyOrder(matchers)); 240 | }); 241 | 242 | // BeginMessage -> TruncateMessage -> CommitMessage 243 | test('- Receive TruncateMessage after delete statement', () async { 244 | // wait to for a second 245 | await Future.delayed(Duration(seconds: 1)); 246 | final stream = serverMessagesInterceptor.messages 247 | .where((event) { 248 | return event is XLogDataMessage; 249 | }) 250 | .map((event) => (event as XLogDataMessage).data) 251 | // RelationMessage isn't always present (appears conditionally) so 252 | // it's skipped when present 253 | .where((event) => event is! RelationMessage) 254 | .take(3); 255 | 256 | late final StreamController controller; 257 | controller = StreamController( 258 | onListen: () async { 259 | // don't await here otherwise what's after won't be executed. 260 | final future = controller.addStream(stream); 261 | await changesConn.execute( 262 | 'truncate table $truncateTable;', 263 | ); 264 | await future; 265 | await controller.close(); 266 | }, 267 | ); 268 | 269 | final matchers = [ 270 | isA(), 271 | isA(), 272 | isA(), 273 | ]; 274 | 275 | expect(controller.stream, emitsInOrder(matchers)); 276 | }); 277 | }); 278 | } 279 | -------------------------------------------------------------------------------- /test/variable_tokenizer_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:postgres/postgres.dart'; 2 | import 'package:postgres/src/v3/query_description.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | test('can declare variables', () { 7 | final desc = 8 | InternalQueryDescription.named('SELECT @x:int8, @y:boolean, @z'); 9 | 10 | expect(desc.transformedSql, r'SELECT $1, $2, $3'); 11 | expect( 12 | desc.namedVariables, 13 | {'x': 1, 'y': 2, 'z': 3}, 14 | ); 15 | expect(desc.parameterTypes, [ 16 | Type.bigInteger, // x 17 | Type.boolean, // y 18 | null, // z, didn't have a specified type 19 | ]); 20 | 21 | expect(() => desc.bindParameters(null), throwsArgumentError); 22 | 23 | expect( 24 | desc.bindParameters({'x': 4, 'y': true, 'z': TypedValue(Type.text, 'z')}), 25 | [ 26 | TypedValue(Type.bigInteger, 4), 27 | TypedValue(Type.boolean, true), 28 | TypedValue(Type.text, 'z'), 29 | ], 30 | ); 31 | expect(desc.bindParameters({'x': 4, 'y': true, 'z': 'z'}), [ 32 | TypedValue(Type.bigInteger, 4), 33 | TypedValue(Type.boolean, true), 34 | TypedValue(Type.unspecified, 'z'), 35 | ]); 36 | 37 | // Make sure we can still bind by index 38 | expect( 39 | desc.bindParameters([1, true, TypedValue(Type.text, 'z')]), 40 | [ 41 | TypedValue(Type.bigInteger, 1), 42 | TypedValue(Type.boolean, true), 43 | TypedValue(Type.text, 'z'), 44 | ], 45 | ); 46 | expect( 47 | desc.bindParameters([1, true, 3]), 48 | [ 49 | TypedValue(Type.bigInteger, 1), 50 | TypedValue(Type.boolean, true), 51 | TypedValue(Type.unspecified, 3), 52 | ], 53 | ); 54 | }); 55 | 56 | test('can declare variables by index', () { 57 | final desc = InternalQueryDescription.indexed( 58 | 'SELECT ?:int8, ?3:boolean, ?2', 59 | substitution: '?'); 60 | 61 | expect(desc.transformedSql, r'SELECT $1, $3, $2'); 62 | expect(desc.namedVariables, isNull); 63 | expect(desc.parameterTypes, [Type.bigInteger, null, Type.boolean]); 64 | 65 | expect(() => desc.bindParameters(null), throwsArgumentError); 66 | expect( 67 | desc.bindParameters([4, TypedValue(Type.text, 'z'), true]), 68 | [ 69 | TypedValue(Type.bigInteger, 4), 70 | TypedValue(Type.text, 'z'), 71 | TypedValue(Type.boolean, true), 72 | ], 73 | ); 74 | expect(desc.bindParameters([4, 'z', true]), [ 75 | TypedValue(Type.bigInteger, 4), 76 | TypedValue(Type.unspecified, 'z'), 77 | TypedValue(Type.boolean, true), 78 | ]); 79 | }); 80 | 81 | test('can use the same variable more than once', () { 82 | final desc = InternalQueryDescription.named( 83 | 'SELECT * FROM foo WHERE a = @x OR bar = @y OR b = @x'); 84 | expect(desc.transformedSql, 85 | r'SELECT * FROM foo WHERE a = $1 OR bar = $2 OR b = $1'); 86 | expect(desc.namedVariables?.keys, ['x', 'y']); 87 | }); 88 | 89 | test('indexed can use same variable more than once', () { 90 | final indexed = InternalQueryDescription.indexed( 91 | 'SELECT * FROM foo WHERE a = @ OR bar = @ OR b = @1'); 92 | expect(indexed.transformedSql, 93 | r'SELECT * FROM foo WHERE a = $1 OR bar = $2 OR b = $1'); 94 | expect(indexed.namedVariables, isNull); 95 | expect(indexed.parameterTypes, hasLength(2)); 96 | }); 97 | 98 | test('can use custom variable symbol', () { 99 | final desc = InternalQueryDescription.named( 100 | 'SELECT * FROM foo WHERE a = :x:int8', 101 | substitution: ':'); 102 | expect(desc.transformedSql, r'SELECT * FROM foo WHERE a = $1'); 103 | expect(desc.namedVariables?.keys, ['x']); 104 | expect(desc.parameterTypes, [Type.bigInteger]); 105 | }); 106 | 107 | group('can use : substitution symbol and cast operator together', () { 108 | test('simple', () { 109 | final desc = InternalQueryDescription.named( 110 | 'SELECT id::text FROM foo WHERE a = :x:int8::int', 111 | substitution: ':'); 112 | expect( 113 | desc.transformedSql, r'SELECT id::text FROM foo WHERE a = $1::int'); 114 | expect(desc.namedVariables?.keys, ['x']); 115 | expect(desc.parameterTypes, [Type.bigInteger]); 116 | }); 117 | test('with comment', () { 118 | final desc = InternalQueryDescription.named( 119 | 'SELECT id /**/ :: /**/ text, b::\nint8 FROM foo WHERE a = :x:int8/**/::/**/int8', 120 | substitution: ':'); 121 | expect(desc.transformedSql, 122 | 'SELECT id :: text, b::\nint8 FROM foo WHERE a = \$1::int8'); 123 | expect(desc.namedVariables?.keys, ['x']); 124 | expect(desc.parameterTypes, [Type.bigInteger]); 125 | }); 126 | }); 127 | 128 | test('finds correct end for string literal', () { 129 | final desc = InternalQueryDescription.named(r"SELECT e'@a\\' @b"); 130 | expect(desc.transformedSql, r"SELECT e'@a\\' $1"); 131 | expect(desc.namedVariables?.keys, ['b']); 132 | }); 133 | 134 | group('VARCHAR(x)', () { 135 | test('accept with some length', () { 136 | final desc = InternalQueryDescription.named('SELECT @x:_varchar(10), 0'); 137 | expect(desc.transformedSql, r'SELECT $1, 0'); 138 | expect(desc.namedVariables, {'x': 1}); 139 | expect(desc.parameterTypes, [Type.varCharArray]); 140 | }); 141 | 142 | test('throws', () { 143 | final badSnippets = [ 144 | '@x:_varchar(', 145 | '@x:_varchar()', 146 | '@x:_varchar(())', 147 | '@x:_varchar((1))', 148 | '@x:_varchar(a)', 149 | '@x:_varchar( 0 )', 150 | ]; 151 | for (final snippet in badSnippets) { 152 | expect( 153 | () => InternalQueryDescription.named('SELECT $snippet'), 154 | throwsFormatException, 155 | reason: snippet, 156 | ); 157 | expect( 158 | () => InternalQueryDescription.named('SELECT $snippet, 0'), 159 | throwsFormatException, 160 | reason: '$snippet, 0', 161 | ); 162 | } 163 | }); 164 | }); 165 | 166 | group('ignores', () { 167 | test('line comments', () { 168 | final desc = InternalQueryDescription.named('SELECT @1, -- @2 \n @3'); 169 | expect(desc.transformedSql, r'SELECT $1, $2'); 170 | expect(desc.namedVariables?.keys, ['1', '3']); 171 | }); 172 | 173 | test('block comments', () { 174 | final desc = InternalQueryDescription.named( 175 | 'SELECT @1 /* this is ignored: @2 */, @3'); 176 | expect(desc.transformedSql, r'SELECT $1 , $2'); 177 | expect(desc.namedVariables?.keys, ['1', '3']); 178 | }); 179 | 180 | test('string literals', () { 181 | final desc = InternalQueryDescription.named( 182 | "SELECT @1, 'isn''t a variable: @2', @3"); 183 | expect(desc.transformedSql, r"SELECT $1, 'isn''t a variable: @2', $2"); 184 | expect(desc.namedVariables?.keys, ['1', '3']); 185 | }); 186 | 187 | test('string literals with C-style escapes', () { 188 | final desc = InternalQueryDescription.named( 189 | r"SELECT @1, E'isn\'t a variable: @2', @3"); 190 | expect(desc.transformedSql, r"SELECT $1, E'isn\'t a variable: @2', $2"); 191 | expect(desc.namedVariables?.keys, ['1', '3']); 192 | }); 193 | 194 | test('strings with unicode escapes', () { 195 | final desc = InternalQueryDescription.named(r"U&'d\0061t@1\+000061', @2"); 196 | expect(desc.transformedSql, r"U&'d\0061t@1\+000061', $1"); 197 | expect(desc.namedVariables?.keys, ['2']); 198 | }); 199 | 200 | test('identifiers', () { 201 | final desc = InternalQueryDescription.named('SELECT @1 AS "@2", @3'); 202 | expect(desc.transformedSql, r'SELECT $1 AS "@2", $2'); 203 | expect(desc.namedVariables?.keys, ['1', '3']); 204 | }); 205 | 206 | test('identifiers with unicode escapes', () { 207 | final desc = 208 | InternalQueryDescription.named(r'SELECT U&"d\0061t@1\+000061", @2'); 209 | expect(desc.transformedSql, r'SELECT U&"d\0061t@1\+000061", $1'); 210 | expect(desc.namedVariables?.keys, ['2']); 211 | }); 212 | 213 | test('dollar quoted string', () { 214 | final desc = InternalQueryDescription.named( 215 | r"SELECT $foo$ This is a string literal $foo that still hasn't ended here $foo$, @1", 216 | ); 217 | 218 | expect( 219 | desc.transformedSql, 220 | r"SELECT $foo$ This is a string literal $foo that still hasn't ended here $foo$, $1", 221 | ); 222 | expect(desc.namedVariables?.keys, ['1']); 223 | }); 224 | 225 | test('invalid dollar quoted string', () { 226 | final desc = InternalQueryDescription.named(r'SELECT $foo @1'); 227 | expect(desc.transformedSql, r'SELECT $foo $1'); 228 | expect(desc.namedVariables?.keys, ['1']); 229 | }); 230 | 231 | // https://www.postgresql.org/docs/current/functions-json.html 232 | final operators = ['@>', '<@', '@?', '@@']; 233 | for (final operator in operators) { 234 | test('can use $operator', () { 235 | final desc = 236 | InternalQueryDescription.named('SELECT @foo $operator @bar'); 237 | expect(desc.transformedSql, 'SELECT \$1 $operator \$2'); 238 | expect(desc.namedVariables?.keys, ['foo', 'bar']); 239 | }); 240 | } 241 | }); 242 | 243 | group('throws', () { 244 | test('for variable with empty type name', () { 245 | expect(() => InternalQueryDescription.named('SELECT @var: FROM foo'), 246 | throwsFormatException); 247 | }); 248 | 249 | test('for invalid type name', () { 250 | expect( 251 | () => 252 | InternalQueryDescription.named('SELECT @var:nosuchtype FROM foo'), 253 | throwsFormatException); 254 | }); 255 | 256 | test('for missing variable', () { 257 | expect( 258 | () => InternalQueryDescription.named('SELECT @foo').bindParameters({}), 259 | throwsA(isA().having( 260 | (e) => e.message, 261 | 'message', 262 | 'Missing variable for `foo`', 263 | )), 264 | ); 265 | }); 266 | 267 | test('for missing variable indexed', () { 268 | expect( 269 | () => InternalQueryDescription.indexed('SELECT @').bindParameters([]), 270 | throwsA(isA().having( 271 | (e) => e.message, 272 | 'message', 273 | 'Expected 1 parameters, got 0', 274 | )), 275 | ); 276 | }); 277 | 278 | test('when using map with indexed', () { 279 | expect( 280 | () => InternalQueryDescription.indexed('SELECT @') 281 | .bindParameters({'1': 'foo'}), 282 | throwsA(isA().having( 283 | (e) => e.message, 284 | 'message', 285 | 'Maps are only supported by `Sql.named`', 286 | )), 287 | ); 288 | }); 289 | 290 | test('for superfluous variables', () { 291 | expect( 292 | () => 293 | InternalQueryDescription.named('SELECT @foo:int4').bindParameters({ 294 | 'foo': 3, 295 | 'X': 'Y', 296 | 'Y': 'Z', 297 | }), 298 | throwsA(isA().having( 299 | (e) => e.message, 300 | 'message', 301 | 'Contains superfluous variables: X, Y', 302 | )), 303 | ); 304 | }); 305 | }); 306 | } 307 | --------------------------------------------------------------------------------