diff --git a/.analysis_options b/.analysis_options deleted file mode 100644 index cac765f..0000000 --- a/.analysis_options +++ /dev/null @@ -1,13 +0,0 @@ -# This file allows you to configure the Dart analyzer. -# -# The commented part below is just for inspiration. Read the guide here: -# https://github.com/dart-lang/sdk/tree/master/pkg/analyzer#configuring-the-analyzer - - analyzer: - strong-mode: true -# excludes: -# - path/to/excluded/files/** -# linter: -# rules: -# # see catalogue here: http://dart-lang.github.io/linter/lints/ -# - hash_and_equals \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2205f7b..147aa2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Files and directories created by pub .packages +.dart_tool/ .pub/ build/ packages diff --git a/.travis.yml b/.travis.yml index 5e5d158..9d14a9a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,13 @@ language: dart sudo: required +dart: + - stable addons: - postgresql: "9.4" + postgresql: "9.6" services: - postgresql before_script: - - sudo cp ci/pg_hba.conf /etc/postgresql/9.4/main/pg_hba.conf + - sudo cp ci/pg_hba.conf /etc/postgresql/9.6/main/pg_hba.conf - sudo /etc/init.d/postgresql restart - psql -c 'create database dart_test;' -U postgres - psql -c 'create user dart with createdb;' -U postgres @@ -14,7 +16,13 @@ before_script: - psql -c 'create user darttrust with createdb;' -U postgres - psql -c 'grant all on database dart_test to darttrust;' -U postgres - pub get -script: pub run test -j 1 -r expanded +dart_task: + - test: --run-skipped -r expanded -j 1 + - dartfmt + - dartanalyzer: --fatal-infos --fatal-warnings . + +#after_success: bash ci/after_script.sh branches: only: - - master \ No newline at end of file + - master + - dev diff --git a/CHANGELOG.md b/CHANGELOG.md index c4fd22e..ca20fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,93 @@ # Changelog +## 2.2.0 + +- Supporting Unix socket connections. (Thanks to [grillbiff](https://github.com/grillbiff), + [#124](https://github.com/stablekernel/postgresql-dart/pull/124)) +- Preparation for custom type converters. +- Added rowsAffected to PostgreSQLResult. (Thanks to [arturaz](https://github.com/arturaz), + [#143](https://github.com/stablekernel/postgresql-dart/pull/143)) + +## 2.1.1 + +- Fix `RuneIterator.current` use, which no longer returns `null` in 2.8 SDK. + +## 2.1.0 + +- Missing substitution value no longer throws `FormatException`. + [More details in the GitHub issue.](https://github.com/stablekernel/postgresql-dart/issues/57) + +## 2.0.0 + +- Fixed startup packet length when username is null (#111). +- Finalized dev release. + +## 2.0.0-dev1.0 + +- Restricted field access on [PostgreSQLConnection]. +- Connection-level default query timeout. +- Option to specify timeout for the transaction's `"COMMIT"` query. +- Optimized byte buffer parsing and construction with `package:buffer`. +- Hardened codebase with `package:pedantic` and additional lints. +- Updated codebase to Dart 2.2. +- `PostgreSQLResult` and `PostgreSQLResultRow` as the return value of a query. + - Returned lists are protected with `UnmodifiableListView`. + - Exposing column metadata through `ColumnDescription`. + - row-level `toTableColumnMap` and `toColumnMap` +- `PostgreSQLConnection` and `_TransactionProxy` share the OID cache. +- default value for `query(allowReuse = true)` is set only in the implementation method. + +**Breaking behaviour** + +- Table OIDs are always resolved to table names (and not only with mapped queries). + +## 1.0.2 +- Add connection queue size + +## 1.0.1 + +- Prevent the table name resolution of OIDs <= 0. + +## 1.0.0 + +- Adds support for Dart 2 + +## 0.9.9 + +- Add full support for `UUID` columns. + +## 0.9.8 + +- Preserve error stacktrace on various query or transaction errors. +- Read support for `BYTEA` columns. + +## 0.9.7 + +- Adds `Connection.mappedResultsQuery` to return query results as a `Map` with keys for table and column names. + +## 0.9.6 + +- Adds `Connection.notifications` to listen for `NOTIFY` events (thanks @andrewst) +- Adds better error reporting. +- Adds support for JSONB columns. +- Fixes issue when encoding UTF16 characters (thanks @andrewst) + +## 0.9.5 + +- Allow connect via SSL. + +## 0.9.4 + +- Fixed issue with buffer length + +## 0.9.3 + +- Fixed issue with UTF8 encoding + +## 0.9.2 + +- Bump for documentation + ## 0.9.1 - Added transactions: PostgreSQLConnection.transaction and PostgreSQLConnection.cancelTransaction. diff --git a/README.md b/README.md index 1b9e5c1..82aae83 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # postgres -[![Build Status](https://travis-ci.org/stablekernel/postgresql-dart.svg?branch=master)](https://travis-ci.org/stablekernel/postgresql-dart) +[![Build Status](https://travis-ci.org/stablekernel/postgresql-dart.svg?branch=master)](https://travis-ci.org/stablekernel/postgresql-dart) [![codecov](https://codecov.io/gh/stablekernel/postgresql-dart/branch/master/graph/badge.svg)](https://codecov.io/gh/stablekernel/postgresql-dart) A library for connecting to and querying PostgreSQL databases. @@ -11,16 +11,35 @@ This driver uses the more efficient and secure extended query format of the Post Create `PostgreSQLConnection`s and `open` them: ```dart -var connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); +var connection = PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); await connection.open(); ``` Execute queries with `query`: ```dart -var results = await connection.query("SELECT a, b FROM table WHERE a = @aValue", substitutionValues: { +List> results = await connection.query("SELECT a, b FROM table WHERE a = @aValue", substitutionValues: { "aValue" : 3 }); + +for (final row in results) { + var a = row[0]; + var b = row[1]; + +} +``` + +Return rows as maps containing table and column names: + +```dart +List>> results = await connection.mappedResultsQuery( + "SELECT t.id, t.name, u.name FROM t LEFT OUTER JOIN u ON t.id=u.t_id"); + +for (final row in results) { + var tID = row["t"]["id"]; + var tName = row["t"]["name"]; + var uName = row["u"]["name"]; +} ``` Execute queries in a transaction: @@ -28,13 +47,13 @@ Execute queries in a transaction: ```dart await connection.transaction((ctx) async { var result = await ctx.query("SELECT id FROM table"); - await ctx.query("INSERT INTO table (id) VALUES (@a:int4)", { + await ctx.query("INSERT INTO table (id) VALUES (@a:int4)", substitutionValues: { "a" : result.last[0] + 1 }); }); ``` -See the API documentation: https://www.dartdocs.org/documentation/postgres/latest. +See the API documentation: https://pub.dev/documentation/postgres/latest/ ## Features and bugs diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..860c7d7 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,62 @@ +# This file allows you to configure the Dart analyzer. +# +# The commented part below is just for inspiration. Read the guide here: +# https://github.com/dart-lang/sdk/tree/master/pkg/analyzer#configuring-the-analyzer + +include: package:pedantic/analysis_options.yaml + +analyzer: + errors: + unused_element: error + unused_import: error + unused_local_variable: error + dead_code: error + strong-mode: + implicit-casts: false + +linter: + rules: + # see catalogue here: http://dart-lang.github.io/linter/lints/ + - annotate_overrides + - avoid_unused_constructor_parameters + - await_only_futures + - camel_case_types + - cancel_subscriptions + - directives_ordering +# - empty_catches + - empty_statements + - hash_and_equals + - iterable_contains_unrelated_type + - list_remove_unrelated_type + - no_adjacent_strings_in_list + - no_duplicate_case_values + - non_constant_identifier_names + - only_throw_errors + - overridden_fields + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_contains + - prefer_final_fields + - prefer_final_locals + - prefer_initializing_formals + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_single_quotes + - prefer_typing_uninitialized_variables + - recursive_getters + - slash_for_doc_comments + - test_types_in_equals + - throw_in_finally + - type_init_formals + - unawaited_futures + - unnecessary_brace_in_string_interps + - unnecessary_getters_setters + - unnecessary_lambdas + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_statements + - unnecessary_this + - unrelated_type_equality_checks + - use_rethrow_when_possible + - valid_regexps diff --git a/ci/after_script.sh b/ci/after_script.sh new file mode 100644 index 0000000..6bc465c --- /dev/null +++ b/ci/after_script.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +if [[ "$TRAVIS_BRANCH" == "master" ]]; then + curl -s https://codecov.io/bash > .codecov + chmod +x .codecov + ./.codecov -f lcov.info -X xcode +fi \ No newline at end of file diff --git a/lib/postgres.dart b/lib/postgres.dart index f529c32..01c9235 100644 --- a/lib/postgres.dart +++ b/lib/postgres.dart @@ -1,20 +1,6 @@ library postgres; -import 'dart:convert'; -import 'dart:io'; -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:crypto/crypto.dart'; - -part 'src/transaction_proxy.dart'; -part 'src/client_messages.dart'; -part 'src/server_messages.dart'; -part 'src/postgresql_codec.dart'; -part 'src/substituter.dart'; -part 'src/connection.dart'; -part 'src/message_window.dart'; -part 'src/connection_fsm.dart'; -part 'src/query.dart'; -part 'src/exceptions.dart'; - +export 'src/connection.dart'; +export 'src/execution_context.dart'; +export 'src/substituter.dart'; +export 'src/types.dart'; diff --git a/lib/src/binary_codec.dart b/lib/src/binary_codec.dart new file mode 100644 index 0000000..89c75ce --- /dev/null +++ b/lib/src/binary_codec.dart @@ -0,0 +1,309 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:buffer/buffer.dart'; + +import '../postgres.dart'; +import 'types.dart'; + +final _bool0 = Uint8List(1)..[0] = 0; +final _bool1 = Uint8List(1)..[0] = 1; +final _dashUnit = '-'.codeUnits.first; +final _hex = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', +]; + +class PostgresBinaryEncoder extends Converter { + final PostgreSQLDataType _dataType; + + const PostgresBinaryEncoder(this._dataType); + + @override + Uint8List convert(dynamic value) { + if (value == null) { + return null; + } + + switch (_dataType) { + case PostgreSQLDataType.boolean: + { + if (value is bool) { + return value ? _bool1 : _bool0; + } + throw FormatException( + 'Invalid type for parameter value. Expected: bool Got: ${value.runtimeType}'); + } + case PostgreSQLDataType.bigSerial: + case PostgreSQLDataType.bigInteger: + { + if (value is int) { + final bd = ByteData(8); + bd.setInt64(0, value); + return bd.buffer.asUint8List(); + } + throw FormatException( + 'Invalid type for parameter value. Expected: int Got: ${value.runtimeType}'); + } + case PostgreSQLDataType.serial: + case PostgreSQLDataType.integer: + { + if (value is int) { + final bd = ByteData(4); + bd.setInt32(0, value); + return bd.buffer.asUint8List(); + } + throw FormatException( + 'Invalid type for parameter value. Expected: int Got: ${value.runtimeType}'); + } + case PostgreSQLDataType.smallInteger: + { + if (value is int) { + final bd = ByteData(2); + bd.setInt16(0, value); + return bd.buffer.asUint8List(); + } + throw FormatException( + 'Invalid type for parameter value. Expected: int Got: ${value.runtimeType}'); + } + case PostgreSQLDataType.name: + case PostgreSQLDataType.text: + { + if (value is String) { + return castBytes(utf8.encode(value)); + } + throw FormatException( + 'Invalid type for parameter value. Expected: String Got: ${value.runtimeType}'); + } + case PostgreSQLDataType.real: + { + if (value is double) { + final bd = ByteData(4); + bd.setFloat32(0, value); + return bd.buffer.asUint8List(); + } + throw FormatException( + 'Invalid type for parameter value. Expected: double Got: ${value.runtimeType}'); + } + case PostgreSQLDataType.double: + { + if (value is double) { + final bd = ByteData(8); + bd.setFloat64(0, value); + return bd.buffer.asUint8List(); + } + throw FormatException( + 'Invalid type for parameter value. Expected: double Got: ${value.runtimeType}'); + } + case PostgreSQLDataType.date: + { + if (value is DateTime) { + final bd = ByteData(4); + bd.setInt32(0, value.toUtc().difference(DateTime.utc(2000)).inDays); + return bd.buffer.asUint8List(); + } + throw FormatException( + 'Invalid type for parameter value. Expected: DateTime Got: ${value.runtimeType}'); + } + + case PostgreSQLDataType.timestampWithoutTimezone: + { + if (value is DateTime) { + final bd = ByteData(8); + final diff = value.toUtc().difference(DateTime.utc(2000)); + bd.setInt64(0, diff.inMicroseconds); + return bd.buffer.asUint8List(); + } + throw FormatException( + 'Invalid type for parameter value. Expected: DateTime Got: ${value.runtimeType}'); + } + + case PostgreSQLDataType.timestampWithTimezone: + { + if (value is DateTime) { + final bd = ByteData(8); + bd.setInt64( + 0, value.toUtc().difference(DateTime.utc(2000)).inMicroseconds); + return bd.buffer.asUint8List(); + } + throw FormatException( + 'Invalid type for parameter value. Expected: DateTime Got: ${value.runtimeType}'); + } + + case PostgreSQLDataType.json: + { + final jsonBytes = utf8.encode(json.encode(value)); + final writer = ByteDataWriter(bufferLength: jsonBytes.length + 1); + writer.writeUint8(1); + writer.write(jsonBytes); + return writer.toBytes(); + } + + case PostgreSQLDataType.byteArray: + { + if (value is List) { + return castBytes(value); + } + throw FormatException( + 'Invalid type for parameter value. Expected: List Got: ${value.runtimeType}'); + } + + case PostgreSQLDataType.uuid: + { + if (value is! String) { + throw FormatException( + 'Invalid type for parameter value. Expected: String Got: ${value.runtimeType}'); + } + + final hexBytes = (value as String) + .toLowerCase() + .codeUnits + .where((c) => c != _dashUnit) + .toList(); + if (hexBytes.length != 32) { + throw FormatException( + "Invalid UUID string. There must be exactly 32 hexadecimal (0-9 and a-f) characters and any number of '-' characters."); + } + + final byteConvert = (int charCode) { + if (charCode >= 48 && charCode <= 57) { + return charCode - 48; + } else if (charCode >= 97 && charCode <= 102) { + return charCode - 87; + } + + throw FormatException( + 'Invalid UUID string. Contains non-hexadecimal character (0-9 and a-f).'); + }; + + final outBuffer = Uint8List(16); + for (var i = 0, j = 0; i < hexBytes.length; i += 2, j++) { + final upperByte = byteConvert(hexBytes[i]); + final lowerByte = byteConvert(hexBytes[i + 1]); + + outBuffer[j] = (upperByte << 4) + lowerByte; + } + return outBuffer; + } + } + + throw PostgreSQLException('Unsupported datatype'); + } +} + +class PostgresBinaryDecoder extends Converter { + const PostgresBinaryDecoder(this.typeCode); + + final int typeCode; + + @override + dynamic convert(Uint8List value) { + final dataType = typeMap[typeCode]; + + if (value == null) { + return null; + } + + final buffer = + ByteData.view(value.buffer, value.offsetInBytes, value.lengthInBytes); + + switch (dataType) { + case PostgreSQLDataType.name: + case PostgreSQLDataType.text: + return utf8.decode(value); + case PostgreSQLDataType.boolean: + return buffer.getInt8(0) != 0; + case PostgreSQLDataType.smallInteger: + return buffer.getInt16(0); + case PostgreSQLDataType.serial: + case PostgreSQLDataType.integer: + return buffer.getInt32(0); + case PostgreSQLDataType.bigSerial: + case PostgreSQLDataType.bigInteger: + return buffer.getInt64(0); + case PostgreSQLDataType.real: + return buffer.getFloat32(0); + case PostgreSQLDataType.double: + return buffer.getFloat64(0); + case PostgreSQLDataType.timestampWithoutTimezone: + case PostgreSQLDataType.timestampWithTimezone: + return DateTime.utc(2000) + .add(Duration(microseconds: buffer.getInt64(0))); + + case PostgreSQLDataType.date: + return DateTime.utc(2000).add(Duration(days: buffer.getInt32(0))); + + case PostgreSQLDataType.json: + { + // Removes version which is first character and currently always '1' + final bytes = value.buffer + .asUint8List(value.offsetInBytes + 1, value.lengthInBytes - 1); + return json.decode(utf8.decode(bytes)); + } + + case PostgreSQLDataType.byteArray: + return value; + + case PostgreSQLDataType.uuid: + { + final buf = StringBuffer(); + for (var i = 0; i < buffer.lengthInBytes; i++) { + final byteValue = buffer.getUint8(i); + final upperByteValue = byteValue >> 4; + final lowerByteValue = byteValue & 0x0f; + + final upperByteHex = _hex[upperByteValue]; + final lowerByteHex = _hex[lowerByteValue]; + buf.write(upperByteHex); + buf.write(lowerByteHex); + if (i == 3 || i == 5 || i == 7 || i == 9) { + buf.writeCharCode(_dashUnit); + } + } + + return buf.toString(); + } + } + + // We'll try and decode this as a utf8 string and return that + // for many internal types, this is valid. If it fails, + // we just return the bytes and let the caller figure out what to + // do with it. + try { + return utf8.decode(value); + } catch (_) { + return value; + } + } + + static final Map typeMap = { + 16: PostgreSQLDataType.boolean, + 17: PostgreSQLDataType.byteArray, + 19: PostgreSQLDataType.name, + 20: PostgreSQLDataType.bigInteger, + 21: PostgreSQLDataType.smallInteger, + 23: PostgreSQLDataType.integer, + 25: PostgreSQLDataType.text, + 700: PostgreSQLDataType.real, + 701: PostgreSQLDataType.double, + 1082: PostgreSQLDataType.date, + 1114: PostgreSQLDataType.timestampWithoutTimezone, + 1184: PostgreSQLDataType.timestampWithTimezone, + 2950: PostgreSQLDataType.uuid, + 3802: PostgreSQLDataType.json, + }; +} diff --git a/lib/src/client_messages.dart b/lib/src/client_messages.dart index 014432f..e072c07 100644 --- a/lib/src/client_messages.dart +++ b/lib/src/client_messages.dart @@ -1,6 +1,13 @@ -part of postgres; +import 'dart:typed_data'; -abstract class _ClientMessage { +import 'package:buffer/buffer.dart'; +import 'package:crypto/crypto.dart'; + +import 'constants.dart'; +import 'query.dart'; +import 'utf8_backed_string.dart'; + +abstract class ClientMessage { static const int FormatText = 0; static const int FormatBinary = 1; @@ -14,180 +21,160 @@ abstract class _ClientMessage { static const int SyncIdentifier = 83; static const int PasswordIdentifier = 112; - int get length; - - int applyStringToBuffer(String string, ByteData buffer, int offset) { - var postStringOffset = string.codeUnits.fold(offset, (idx, unit) { - buffer.setInt8(idx, unit); - return idx + 1; - }); - - buffer.setInt8(postStringOffset, 0); - return postStringOffset + 1; - } - - int applyToBuffer(ByteData aggregateBuffer, int offsetIntoAggregateBuffer); + void applyToBuffer(ByteDataWriter buffer); Uint8List asBytes() { - var buffer = new ByteData(length); - applyToBuffer(buffer, 0); - return buffer.buffer.asUint8List(); + final buffer = ByteDataWriter(); + applyToBuffer(buffer); + return buffer.toBytes(); } - static Uint8List aggregateBytes(List<_ClientMessage> messages) { - var totalLength = messages.fold(0, (total, _ClientMessage next) => total + next.length); - var buffer = new ByteData(totalLength); - - var offset = 0; - messages.fold(offset, (inOffset, msg) => msg.applyToBuffer(buffer, inOffset)); - - return buffer.buffer.asUint8List(); + static Uint8List aggregateBytes(List messages) { + final buffer = ByteDataWriter(); + messages.forEach((cm) => cm.applyToBuffer(buffer)); + return buffer.toBytes(); } } -class _StartupMessage extends _ClientMessage { - _StartupMessage(this.databaseName, this.timeZone, {this.username}); +void _applyStringToBuffer(UTF8BackedString string, ByteDataWriter buffer) { + buffer.write(string.utf8Bytes); + buffer.writeInt8(0); +} - String username = null; - String databaseName; - String timeZone; +class StartupMessage extends ClientMessage { + final UTF8BackedString _username; + final UTF8BackedString _databaseName; + final UTF8BackedString _timeZone; - ByteData buffer; + StartupMessage(String databaseName, String timeZone, {String username}) + : _databaseName = UTF8BackedString(databaseName), + _timeZone = UTF8BackedString(timeZone), + _username = username == null ? null : UTF8BackedString(username); - int get length { - var fixedLength = 53; - var variableLength = (username?.length ?? 0) - + databaseName.length - + timeZone.length + 3; + @override + void applyToBuffer(ByteDataWriter buffer) { + var fixedLength = 48; + var variableLength = _databaseName.utf8Length + _timeZone.utf8Length + 2; - return fixedLength + variableLength; - } + if (_username != null) { + fixedLength += 5; + variableLength += _username.utf8Length + 1; + } - int applyToBuffer(ByteData buffer, int offset) { - buffer.setInt32(offset, length); offset += 4; - buffer.setInt32(offset, _ClientMessage.ProtocolVersion); offset += 4; + buffer.writeInt32(fixedLength + variableLength); + buffer.writeInt32(ClientMessage.ProtocolVersion); - if (username != null) { - offset = applyStringToBuffer("user", buffer, offset); - offset = applyStringToBuffer(username, buffer, offset); + if (_username != null) { + buffer.write(UTF8ByteConstants.user); + _applyStringToBuffer(_username, buffer); } - offset = applyStringToBuffer("database", buffer, offset); - offset = applyStringToBuffer(databaseName, buffer, offset); - - offset = applyStringToBuffer("client_encoding", buffer, offset); - offset = applyStringToBuffer("UTF8", buffer, offset); + buffer.write(UTF8ByteConstants.database); + _applyStringToBuffer(_databaseName, buffer); - offset = applyStringToBuffer("TimeZone", buffer, offset); - offset = applyStringToBuffer(timeZone, buffer, offset); + buffer.write(UTF8ByteConstants.clientEncoding); + buffer.write(UTF8ByteConstants.utf8); - buffer.setInt8(offset, 0); offset += 1; + buffer.write(UTF8ByteConstants.timeZone); + _applyStringToBuffer(_timeZone, buffer); - return offset; + buffer.writeInt8(0); } } -class _AuthMD5Message extends _ClientMessage { - _AuthMD5Message(String username, String password, List saltBytes) { - var passwordHash = md5.convert("${password}${username}".codeUnits).toString(); - var saltString = new String.fromCharCodes(saltBytes); - hashedAuthString = "md5" + md5.convert("$passwordHash$saltString".codeUnits).toString(); - } - - String hashedAuthString; +class AuthMD5Message extends ClientMessage { + UTF8BackedString _hashedAuthString; - int get length { - return 6 + hashedAuthString.length; + AuthMD5Message(String username, String password, List saltBytes) { + final passwordHash = md5.convert('$password$username'.codeUnits).toString(); + final saltString = String.fromCharCodes(saltBytes); + final md5Hash = + md5.convert('$passwordHash$saltString'.codeUnits).toString(); + _hashedAuthString = UTF8BackedString('md5$md5Hash'); } - int applyToBuffer(ByteData buffer, int offset) { - buffer.setUint8(offset, _ClientMessage.PasswordIdentifier); offset += 1; - buffer.setUint32(offset, length - 1); offset += 4; - offset = applyStringToBuffer(hashedAuthString, buffer, offset); - - return offset; + @override + void applyToBuffer(ByteDataWriter buffer) { + buffer.writeUint8(ClientMessage.PasswordIdentifier); + final length = 5 + _hashedAuthString.utf8Length; + buffer.writeUint32(length); + _applyStringToBuffer(_hashedAuthString, buffer); } } -class _QueryMessage extends _ClientMessage { - _QueryMessage(this.queryString); +class QueryMessage extends ClientMessage { + final UTF8BackedString _queryString; - String queryString; + QueryMessage(String queryString) + : _queryString = UTF8BackedString(queryString); - int get length { - return 6 + queryString.length; - } - - int applyToBuffer(ByteData buffer, int offset) { - buffer.setUint8(offset, _ClientMessage.QueryIdentifier); offset += 1; - buffer.setUint32(offset, length - 1); offset += 4; - offset = applyStringToBuffer(queryString, buffer, offset); - - return offset; + @override + void applyToBuffer(ByteDataWriter buffer) { + buffer.writeUint8(ClientMessage.QueryIdentifier); + final length = 5 + _queryString.utf8Length; + buffer.writeUint32(length); + _applyStringToBuffer(_queryString, buffer); } } -class _ParseMessage extends _ClientMessage { - _ParseMessage (this.statement, {this.statementName: ""}); +class ParseMessage extends ClientMessage { + final UTF8BackedString _statementName; + final UTF8BackedString _statement; - String statementName = ""; - String statement; + ParseMessage(String statement, {String statementName = ''}) + : _statement = UTF8BackedString(statement), + _statementName = UTF8BackedString(statementName); - int get length { - return 9 + statement.length + statementName.length; - } - - int applyToBuffer(ByteData buffer, int offset) { - buffer.setUint8(offset, _ClientMessage.ParseIdentifier); offset += 1; - buffer.setUint32(offset, length - 1); offset += 4; - offset = applyStringToBuffer(statementName, buffer, offset); // Name of prepared statement - offset = applyStringToBuffer(statement, buffer, offset); // Query string - buffer.setUint16(offset, 0); offset += 2; // Specifying types - may add this in the future, for now indicating we want the backend to infer. - - return offset; + @override + void applyToBuffer(ByteDataWriter buffer) { + buffer.writeUint8(ClientMessage.ParseIdentifier); + final length = 8 + _statement.utf8Length + _statementName.utf8Length; + buffer.writeUint32(length); + // Name of prepared statement + _applyStringToBuffer(_statementName, buffer); + _applyStringToBuffer(_statement, buffer); // Query string + buffer.writeUint16(0); } } -class _DescribeMessage extends _ClientMessage { - _DescribeMessage({this.statementName: ""}); +class DescribeMessage extends ClientMessage { + final UTF8BackedString _statementName; - String statementName = ""; + DescribeMessage({String statementName = ''}) + : _statementName = UTF8BackedString(statementName); - int get length { - return 7 + statementName.length; - } - - int applyToBuffer(ByteData buffer, int offset) { - buffer.setUint8(offset, _ClientMessage.DescribeIdentifier); offset += 1; - buffer.setUint32(offset, length - 1); offset += 4; - buffer.setUint8(offset, 83); offset += 1; // Indicate we are referencing a prepared statement - offset = applyStringToBuffer(statementName, buffer, offset); // Name of prepared statement - - return offset; + @override + void applyToBuffer(ByteDataWriter buffer) { + buffer.writeUint8(ClientMessage.DescribeIdentifier); + final length = 6 + _statementName.utf8Length; + buffer.writeUint32(length); + buffer.writeUint8(83); + _applyStringToBuffer(_statementName, buffer); // Name of prepared statement } } -class _BindMessage extends _ClientMessage { - _BindMessage(this.parameters, {this.statementName: ""}) { - typeSpecCount = parameters.where((p) => p.isBinary).length; - } +class BindMessage extends ClientMessage { + final List _parameters; + final UTF8BackedString _statementName; + final int _typeSpecCount; + int _cachedLength; - List<_ParameterValue> parameters; - String statementName; + BindMessage(this._parameters, {String statementName = ''}) + : _typeSpecCount = _parameters.where((p) => p.isBinary).length, + _statementName = UTF8BackedString(statementName); - int typeSpecCount; - int _cachedLength; int get length { if (_cachedLength == null) { - var inputParameterElementCount = parameters.length; - if (typeSpecCount == parameters.length || typeSpecCount == 0) { + var inputParameterElementCount = _parameters.length; + if (_typeSpecCount == _parameters.length || _typeSpecCount == 0) { inputParameterElementCount = 1; } _cachedLength = 15; - _cachedLength += statementName.length; + _cachedLength += _statementName.utf8Length; _cachedLength += inputParameterElementCount * 2; - _cachedLength += parameters.fold(0, (len, _ParameterValue paramValue) { + _cachedLength += + _parameters.fold(0, (len, ParameterValue paramValue) { if (paramValue.bytes == null) { return len + 4; } else { @@ -198,79 +185,66 @@ class _BindMessage extends _ClientMessage { return _cachedLength; } - int applyToBuffer(ByteData buffer, int offset) { - buffer.setUint8(offset, _ClientMessage.BindIdentifier); offset += 1; - buffer.setUint32(offset, length - 1); offset += 4; + @override + void applyToBuffer(ByteDataWriter buffer) { + buffer.writeUint8(ClientMessage.BindIdentifier); + buffer.writeUint32(length - 1); - offset = applyStringToBuffer("", buffer, offset); // Name of portal - currently unnamed portal. - offset = applyStringToBuffer(statementName, buffer, offset); // Name of prepared statement. + // Name of portal - currently unnamed portal. + buffer.writeUint8(0); + // Name of prepared statement. + _applyStringToBuffer(_statementName, buffer); // OK, if we have no specified types at all, we can use 0. If we have all specified types, we can use 1. If we have a mix, we have to individually // call out each type. - if (typeSpecCount == parameters.length) { - buffer.setUint16(offset, 1); offset += 2; // Apply following format code for all parameters by indicating 1 - buffer.setUint16(offset, _ClientMessage.FormatBinary); offset += 2; // Specify format code for all params is BINARY - } else if (typeSpecCount == 0) { - buffer.setUint16(offset, 1); offset += 2; // Apply following format code for all parameters by indicating 1 - buffer.setUint16(offset, _ClientMessage.FormatText); offset += 2; // Specify format code for all params is TEXT + if (_typeSpecCount == _parameters.length) { + buffer.writeUint16(1); + // Apply following format code for all parameters by indicating 1 + buffer.writeUint16(ClientMessage.FormatBinary); + } else if (_typeSpecCount == 0) { + buffer.writeUint16(1); + // Apply following format code for all parameters by indicating 1 + buffer.writeUint16(ClientMessage.FormatText); } else { // Well, we have some text and some binary, so we have to be explicit about each one - buffer.setUint16(offset, parameters.length); offset += 2; - parameters.forEach((p) { - buffer.setUint16(offset, p.isBinary ? _ClientMessage.FormatBinary : _ClientMessage.FormatText); offset += 2; + buffer.writeUint16(_parameters.length); + _parameters.forEach((p) { + buffer.writeUint16( + p.isBinary ? ClientMessage.FormatBinary : ClientMessage.FormatText); }); } // This must be the number of $n's in the query. - buffer.setUint16(offset, parameters.length); offset += 2; // Number of parameters specified by query - parameters.forEach((p) { + buffer.writeUint16(_parameters.length); + _parameters.forEach((p) { if (p.bytes == null) { - buffer.setInt32(offset, -1); offset += 4; + buffer.writeInt32(-1); } else { - buffer.setInt32(offset, p.length); offset += 4; - offset = p.bytes.fold(offset, (inOffset, byte) { - buffer.setUint8(inOffset, byte); - return inOffset + 1; - }); + buffer.writeInt32(p.length); + buffer.write(p.bytes); } }); // Result columns - we always want binary for all of them, so specify 1:1. - buffer.setUint16(offset, 1); offset += 2; // Apply format code for all result values by indicating 1 - buffer.setUint16(offset, 1); offset += 2; // Specify format code for all result values in binary - - return offset; + buffer.writeUint16(1); + buffer.writeUint16(1); } } -class _ExecuteMessage extends _ClientMessage { - _ExecuteMessage(); - - int get length { - return 10; - } - - int applyToBuffer(ByteData buffer, int offset) { - buffer.setUint8(offset, _ClientMessage.ExecuteIdentifier); offset += 1; - buffer.setUint32(offset, length - 1); offset += 4; - offset = applyStringToBuffer("", buffer, offset); // Portal name - buffer.setUint32(offset, 0); offset += 4; // Row limit - - return offset; +class ExecuteMessage extends ClientMessage { + @override + void applyToBuffer(ByteDataWriter buffer) { + buffer.writeUint8(ClientMessage.ExecuteIdentifier); + buffer.writeUint32(9); + buffer.writeUint8(0); // Portal name + buffer.writeUint32(0); } } -class _SyncMessage extends _ClientMessage { - _SyncMessage(); - - int get length { - return 5; +class SyncMessage extends ClientMessage { + @override + void applyToBuffer(ByteDataWriter buffer) { + buffer.writeUint8(ClientMessage.SyncIdentifier); + buffer.writeUint32(4); } - - int applyToBuffer(ByteData buffer, int offset) { - buffer.setUint8(offset, _ClientMessage.SyncIdentifier); offset += 1; - buffer.setUint32(offset, 4); offset += 4; - - return offset; - } -} \ No newline at end of file +} diff --git a/lib/src/connection.dart b/lib/src/connection.dart index d721d1d..ba3455e 100644 --- a/lib/src/connection.dart +++ b/lib/src/connection.dart @@ -1,50 +1,33 @@ -part of postgres; +library postgres.connection; -abstract class PostgreSQLExecutionContext { - /// Executes a query on this context. - /// - /// This method sends the query described by [fmtString] to the database and returns a [Future] whose value is the returned rows from the query after the query completes. - /// The format string may contain parameters that are provided in [substitutionValues]. Parameters are prefixed with the '@' character. Keys to replace the parameters - /// do not include the '@' character. For example: - /// - /// connection.query("SELECT * FROM table WHERE id = @idParam", {"idParam" : 2}); - /// - /// The type of the value is inferred by default, but can be made more specific by adding ':type" to the parameter pattern in the format string. The possible values - /// are declared as static variables in [PostgreSQLCodec] (e.g., [PostgreSQLCodec.TypeInt4]). For example: - /// - /// connection.query("SELECT * FROM table WHERE id = @idParam:int4", {"idParam" : 2}); - /// - /// You may also use [PostgreSQLFormat.id] to create parameter patterns. - /// - /// If successful, the returned [Future] completes with a [List] of rows. Each is row is represented by a [List] of column values for that row that were returned by the query. - /// - /// By default, instances of this class will reuse queries. This allows significantly more efficient transport to and from the database. You do not have to do - /// anything to opt in to this behavior, this connection will track the necessary information required to reuse queries without intervention. (The [fmtString] is - /// the unique identifier to look up reuse information.) You can disable reuse by passing false for [allowReuse]. - Future>> query(String fmtString, {Map substitutionValues: null, bool allowReuse: true}); +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; +import 'dart:typed_data'; - /// Executes a query on this context. - /// - /// This method sends a SQL string to the database this instance is connected to. Parameters can be provided in [fmtString], see [query] for more details. - /// - /// This method returns the number of rows affected and no additional information. This method uses the least efficient and less secure command - /// for executing queries in the PostgreSQL protocol; [query] is preferred for queries that will be executed more than once, will contain user input, - /// or return rows. - Future execute(String fmtString, {Map substitutionValues: null}); +import 'package:buffer/buffer.dart'; - /// Cancels a transaction on this context. - /// - /// If this context is an instance of [PostgreSQLConnection], this method has no effect. If the context is a transaction context (passed as the argument - /// to [PostgreSQLConnection.transaction]), this will rollback the transaction. - void cancelTransaction({String reason: null}); -} +import 'client_messages.dart'; +import 'execution_context.dart'; +import 'message_window.dart'; +import 'query.dart'; +import 'query_cache.dart'; +import 'query_queue.dart'; +import 'server_messages.dart'; + +part 'connection_fsm.dart'; + +part 'transaction_proxy.dart'; + +part 'exceptions.dart'; /// Instances of this class connect to and communicate with a PostgreSQL database. /// /// The primary type of this library, a connection is responsible for connecting to databases and executing queries. /// A connection may be opened with [open] after it is created. -class PostgreSQLConnection implements PostgreSQLExecutionContext { - +class PostgreSQLConnection extends Object + with _PostgreSQLExecutionContextMixin + implements PostgreSQLExecutionContext { /// Creates an instance of [PostgreSQLConnection]. /// /// [host] must be a hostname, e.g. "foobar.com" or IP address. Do not include scheme or port. @@ -52,43 +35,73 @@ class PostgreSQLConnection implements PostgreSQLExecutionContext { /// [databaseName] is the name of the database to connect to. /// [username] and [password] are optional if the database requires user authentication. /// [timeoutInSeconds] refers to the amount of time [PostgreSQLConnection] will wait while establishing a connection before it gives up. + /// [queryTimeoutInSeconds] refers to the default timeout for [PostgreSQLExecutionContext]'s execute and query methods. /// [timeZone] is the timezone the connection is in. Defaults to 'UTC'. /// [useSSL] when true, uses a secure socket when connecting to a PostgreSQL database. - PostgreSQLConnection(this.host, this.port, this.databaseName, {this.username: null, this.password: null, this.timeoutInSeconds: 30, this.timeZone: "UTC", this.useSSL: false}) { - _connectionState = new _PostgreSQLConnectionStateClosed(); + PostgreSQLConnection( + this.host, + this.port, + this.databaseName, { + this.username, + this.password, + this.timeoutInSeconds = 30, + this.queryTimeoutInSeconds = 30, + this.timeZone = 'UTC', + this.useSSL = false, + this.isUnixSocket = false, + }) { + _connectionState = _PostgreSQLConnectionStateClosed(); _connectionState.connection = this; } - // Add flag for debugging that captures stack trace prior to execution + final StreamController _notifications = + StreamController.broadcast(); /// Hostname of database this connection refers to. - String host; + final String host; /// Port of database this connection refers to. - int port; + final int port; /// Name of database this connection refers to. - String databaseName; + final String databaseName; /// Username for authenticating this connection. - String username; + final String username; /// Password for authenticating this connection. - String password; + final String password; /// Whether or not this connection should connect securely. - bool useSSL; + final bool useSSL; /// The amount of time this connection will wait during connecting before giving up. - int timeoutInSeconds; + final int timeoutInSeconds; + + /// The default timeout for [PostgreSQLExecutionContext]'s execute and query methods. + final int queryTimeoutInSeconds; /// The timezone of this connection for date operations that don't specify a timezone. - String timeZone; + final String timeZone; + + /// The processID of this backend. + int get processID => _processID; + + /// If true, connection is made via unix socket. + final bool isUnixSocket; + + /// Stream of notification from the database. + /// + /// Listen to this [Stream] to receive events from PostgreSQL NOTIFY commands. + /// + /// To determine whether or not the NOTIFY came from this instance, compare [processID] + /// to [Notification.processID]. + Stream get notifications => _notifications.stream; /// Whether or not this connection is open or not. /// - /// This is [true] when this instance is first created and after it has been closed or encountered an unrecoverable error. - /// If a connection has already been opened and this value is now true, the connection cannot be reopened and a new instance + /// This is `true` when this instance is first created and after it has been closed or encountered an unrecoverable error. + /// If a connection has already been opened and this value is now true, the connection cannot be reopened and a instance /// must be created. bool get isClosed => _connectionState is _PostgreSQLConnectionStateClosed; @@ -96,28 +109,25 @@ class PostgreSQLConnection implements PostgreSQLExecutionContext { /// /// After connecting to a database, this map will contain the settings values that the database returns. /// Prior to connection, it is the empty map. - Map settings = {}; + final Map settings = {}; + final _cache = QueryCache(); + final _oidCache = _OidCache(); Socket _socket; - _MessageFramer _framer = new _MessageFramer(); - - Map _reuseMap = {}; - int _reuseCounter = 0; - + MessageFramer _framer = MessageFramer(); int _processID; + // ignore: unused_field int _secretKey; List _salt; bool _hasConnectedPreviously = false; _PostgreSQLConnectionState _connectionState; - List<_Query> _queryQueue = []; - _Query get _pendingQuery { - if (_queryQueue.isEmpty) { - return null; - } - return _queryQueue.first; - } + @override + PostgreSQLExecutionContext get _transaction => null; + + @override + PostgreSQLConnection get _connection => this; /// Establishes a connection with a PostgreSQL database. /// @@ -125,97 +135,54 @@ class PostgreSQLConnection implements PostgreSQLExecutionContext { /// on this connection afterwards. If the connection fails to be established for any reason - including authentication - /// the returned [Future] will return with an error. /// - /// Connections may not be reopened after they are closed or opened more than once. If a connection has already been opened and this method is called, an exception will be thrown. + /// Connections may not be reopened after they are closed or opened more than once. If a connection has already been + /// opened and this method is called, an exception will be thrown. Future open() async { if (_hasConnectedPreviously) { - throw new PostgreSQLException("Attempting to reopen a closed connection. Create a new instance instead."); + throw PostgreSQLException( + 'Attempting to reopen a closed connection. Create a instance instead.'); } - _hasConnectedPreviously = true; + try { + _hasConnectedPreviously = true; + if (isUnixSocket) { + _socket = await Socket.connect( + InternetAddress(host, type: InternetAddressType.unix), port) + .timeout(Duration(seconds: timeoutInSeconds)); + } else { + _socket = await Socket.connect(host, port) + .timeout(Duration(seconds: timeoutInSeconds)); + } - if (useSSL) { - _socket = await SecureSocket.connect(host, port).timeout(new Duration(seconds: timeoutInSeconds)); - } else { - _socket = await Socket.connect(host, port).timeout(new Duration(seconds: timeoutInSeconds)); - } + _framer = MessageFramer(); + if (useSSL) { + _socket = await _upgradeSocketToSSL(_socket, timeout: timeoutInSeconds); + } - _framer = new _MessageFramer(); - _socket.listen(_readData, onError: _handleSocketError, onDone: _handleSocketClosed); + final connectionComplete = Completer(); + _socket.listen(_readData, onError: _close, onDone: _close); - var connectionComplete = new Completer(); - _transitionToState(new _PostgreSQLConnectionStateSocketConnected(connectionComplete)); + _transitionToState( + _PostgreSQLConnectionStateSocketConnected(connectionComplete)); - return connectionComplete.future.timeout(new Duration(seconds: timeoutInSeconds), onTimeout: () { - _connectionState = new _PostgreSQLConnectionStateClosed(); - _socket?.destroy(); + await connectionComplete.future + .timeout(Duration(seconds: timeoutInSeconds)); + } on TimeoutException catch (e, st) { + final err = PostgreSQLException( + 'Failed to connect to database $host:$port/$databaseName failed to connect.'); + await _close(err, st); + rethrow; + } catch (e, st) { + await _close(e, st); - _cancelCurrentQueries(); - throw new PostgreSQLException("Timed out trying to connect to database postgres://$host:$port/$databaseName."); - }); + rethrow; + } } /// Closes a connection. /// /// After the returned [Future] completes, this connection can no longer be used to execute queries. Any queries in progress or queued are cancelled. - Future close() async { - _connectionState = new _PostgreSQLConnectionStateClosed(); - - await _socket?.close(); - - _cancelCurrentQueries(); - } - - /// Executes a query on this connection. - /// - /// This method sends the query described by [fmtString] to the database and returns a [Future] whose value returned rows from the query after the query completes. - /// The format string may contain parameters that are provided in [substitutionValues]. Parameters are prefixed with the '@' character. Keys to replace the parameters - /// do not include the '@' character. For example: - /// - /// connection.query("SELECT * FROM table WHERE id = @idParam", {"idParam" : 2}); - /// - /// The type of the value is inferred by default, but can be made more specific by adding ':type" to the parameter pattern in the format string. The possible values - /// are declared as static variables in [PostgreSQLCodec] (e.g., [PostgreSQLCodec.TypeInt4]). For example: - /// - /// connection.query("SELECT * FROM table WHERE id = @idParam:int4", {"idParam" : 2}); - /// - /// You may also use [PostgreSQLFormat.id] to create parameter patterns. - /// - /// If successful, the returned [Future] completes with a [List] of rows. Each is row is represented by a [List] of column values for that row that were returned by the query. - /// - /// By default, instances of this class will reuse queries. This allows significantly more efficient transport to and from the database. You do not have to do - /// anything to opt in to this behavior, this connection will track the necessary information required to reuse queries without intervention. (The [fmtString] is - /// the unique identifier to look up reuse information.) You can disable reuse by passing false for [allowReuse]. - /// - Future>> query(String fmtString, {Map substitutionValues: null, bool allowReuse: true}) async { - if (isClosed) { - throw new PostgreSQLException("Attempting to execute query, but connection is not open."); - } - - var query = new _Query>>(fmtString, substitutionValues, this, null); - if (allowReuse) { - query.statementIdentifier = _reuseIdentifierForQuery(query); - } - - return await _enqueue(query); - } - - /// Executes a query on this connection. - /// - /// This method sends a SQL string to the database this instance is connected to. Parameters can be provided in [fmtString], see [query] for more details. - /// - /// This method returns the number of rows affected and no additional information. This method uses the least efficient and less secure command - /// for executing queries in the PostgreSQL protocol; [query] is preferred for queries that will be executed more than once, will contain user input, - /// or return rows. - Future execute(String fmtString, {Map substitutionValues: null}) async { - if (isClosed) { - throw new PostgreSQLException("Attempting to execute query, but connection is not open."); - } - - var query = new _Query(fmtString, substitutionValues, this, null) - ..onlyReturnAffectedRowCount = true; - - return await _enqueue(query); - } + Future close() => _close(); /// Executes a series of queries inside a transaction on this connection. /// @@ -226,10 +193,7 @@ class PostgreSQLConnection implements PostgreSQLExecutionContext { /// the transaction will fail and previous statements within the transaction will not be committed. The [Future] /// returned from this method will be completed with the error from the first failing query. /// - /// Do not catch exceptions within a transaction block, as it will prevent the transaction exception handler from fulfilling a - /// transaction. - /// - /// Transactions may be cancelled by issuing [PostgreSQLExecutionContext.cancelTransaction] + /// Transactions may be cancelled by invoking [PostgreSQLExecutionContext.cancelTransaction] /// within the transaction. This will cause this method to return a [Future] with a value of [PostgreSQLRollback]. This method does not throw an exception /// if the transaction is cancelled in this way. /// @@ -244,58 +208,33 @@ class PostgreSQLConnection implements PostgreSQLExecutionContext { /// ctx.query("INSERT INTO t (id) VALUES (2)"); /// } /// }); - Future transaction(Future queryBlock(PostgreSQLExecutionContext connection)) async { + /// + /// If specified, the final `"COMMIT"` query of the transaction will use + /// [commitTimeoutInSeconds] as its timeout, otherwise the connection's + /// default query timeout will be used. + Future transaction( + Future Function(PostgreSQLExecutionContext connection) queryBlock, { + int commitTimeoutInSeconds, + }) async { if (isClosed) { - throw new PostgreSQLException("Attempting to execute query, but connection is not open."); + throw PostgreSQLException( + 'Attempting to execute query, but connection is not open.'); } - var proxy = new _TransactionProxy(this, queryBlock); + final proxy = _TransactionProxy(this, queryBlock, commitTimeoutInSeconds); - await _enqueue(proxy.beginQuery); + await _enqueue(proxy._beginQuery); - return await proxy.completer.future; + return await proxy.future; } - void cancelTransaction({String reason: null}) { - // We aren't in a transaction if sent to PostgreSQLConnection instances, so this is a no-op. + @override + void cancelTransaction({String reason}) { + // Default is no-op } //////// - Future _enqueue(_Query query) async { - _queryQueue.add(query); - _transitionToState(_connectionState.awake()); - - var result = null; - try { - result = await query.future; - - _cacheQuery(query); - _queryQueue.remove(query); - } catch (e) { - _cacheQuery(query); - _queryQueue.remove(query); - rethrow; - } - - return result; - } - - void _cancelCurrentQueries() { - var queries = _queryQueue; - _queryQueue = []; - - // We need to jump this to the next event so that the queries - // get the error and not the close message, since completeError is - // synchronous. - scheduleMicrotask(() { - var exception = new PostgreSQLException("Connection closed or query cancelled."); - queries?.forEach((q) { - q.completeError(exception); - }); - }); - } - void _transitionToState(_PostgreSQLConnectionState newState) { if (identical(newState, _connectionState)) { return; @@ -310,75 +249,339 @@ class PostgreSQLConnection implements PostgreSQLExecutionContext { _connectionState.connection = this; } + Future _close([dynamic error, StackTrace trace]) async { + _connectionState = _PostgreSQLConnectionStateClosed(); + + await _socket?.close(); + await _notifications?.close(); + + _queue?.cancel(error, trace); + } + void _readData(List bytes) { // Note that the way this method works, if a query is in-flight, and we move to the closed state // manually, the delivery of the bytes from the socket is sent to the 'Closed State', // and the state node managing delivering data to the query no longer exists. Therefore, // as soon as a close occurs, we detach the data stream from anything that actually does // anything with that data. - _framer.addBytes(bytes); - + _framer.addBytes(castBytes(bytes)); while (_framer.hasMessage) { - var msg = _framer.popMessage().message; - + final msg = _framer.popMessage(); try { - if (msg is _ErrorResponseMessage) { + if (msg is ErrorResponseMessage) { _transitionToState(_connectionState.onErrorResponse(msg)); + } else if (msg is NotificationResponseMessage) { + _notifications + .add(Notification(msg.processID, msg.channel, msg.payload)); } else { _transitionToState(_connectionState.onMessage(msg)); } } catch (e, st) { - _handleSocketError(e); + _close(e, st); } } } - void _handleSocketError(dynamic error) { - _connectionState = new _PostgreSQLConnectionStateClosed(); - _socket.destroy(); + Future _upgradeSocketToSSL(Socket originalSocket, + {int timeout = 30}) { + final sslCompleter = Completer(); + + originalSocket.listen((data) { + if (data.length != 1) { + sslCompleter.completeError(PostgreSQLException( + 'Could not initalize SSL connection, received unknown byte stream.')); + return; + } + + sslCompleter.complete(data.first); + }, + onDone: () => sslCompleter.completeError(PostgreSQLException( + 'Could not initialize SSL connection, connection closed during handshake.')), + onError: sslCompleter.completeError); + + final byteBuffer = ByteData(8); + byteBuffer.setUint32(0, 8); + byteBuffer.setUint32(4, 80877103); + originalSocket.add(byteBuffer.buffer.asUint8List()); + + return sslCompleter.future + .timeout(Duration(seconds: timeout)) + .then((responseByte) { + if (responseByte != 83) { + throw PostgreSQLException( + 'The database server is not accepting SSL connections.'); + } + + return SecureSocket.secure(originalSocket, + onBadCertificate: (certificate) => true) + .timeout(Duration(seconds: timeout)); + }); + } +} + +class _TransactionRollbackException implements Exception { + _TransactionRollbackException(this.reason); + + String reason; +} + +/// Represents a notification from PostgreSQL. +/// +/// Instances of this type are created and sent via [PostgreSQLConnection.notifications]. +class Notification { + /// Creates an instance of this type. + Notification(this.processID, this.channel, this.payload); + + /// The backend ID from which the notification was generated. + final int processID; + + /// The name of the PostgreSQL channel that this notification occurred on. + final String channel; + + /// An optional data payload accompanying this notification. + final String payload; +} + +class _OidCache { + final _tableOIDNameMap = {}; + int _queryCount = 0; - _cancelCurrentQueries(); + int get queryCount => _queryCount; + + void clear() { + _queryCount = 0; + _tableOIDNameMap.clear(); } - void _handleSocketClosed() { - _connectionState = new _PostgreSQLConnectionStateClosed(); + Future> _resolveTableNames( + _PostgreSQLExecutionContextMixin c, + List columns) async { + if (columns == null) return null; + //todo (joeconwaystk): If this was a cached query, resolving is table oids is unnecessary. + // It's not a significant impact here, but an area for optimization. This includes + // assigning resolvedTableName + final unresolvedTableOIDs = columns + .map((f) => f.tableID) + .toSet() + .where((oid) => + oid != null && oid > 0 && !_tableOIDNameMap.containsKey(oid)) + .toList() + ..sort(); + + if (unresolvedTableOIDs.isNotEmpty) { + await _resolveTableOIDs(c, unresolvedTableOIDs); + } - _cancelCurrentQueries(); + return columns + .map((c) => c.change(tableName: _tableOIDNameMap[c.tableID])) + .toList(); } - void _cacheQuery(_Query query) { - if (query.cache == null) { - return; + Future _resolveTableOIDs( + _PostgreSQLExecutionContextMixin c, List oids) async { + _queryCount++; + final unresolvedIDString = oids.join(','); + final orderedTableNames = await c._query( + "SELECT relname FROM pg_class WHERE relkind='r' AND oid IN ($unresolvedIDString) ORDER BY oid ASC", + allowReuse: false, // inlined OIDs would make it difficult anyway + resolveOids: false, + ); + + final iterator = oids.iterator; + orderedTableNames.forEach((tableName) { + iterator.moveNext(); + if (tableName.first != null) { + _tableOIDNameMap[iterator.current] = tableName.first as String; + } + }); + } +} + +abstract class _PostgreSQLExecutionContextMixin + implements PostgreSQLExecutionContext { + final _queue = QueryQueue(); + + PostgreSQLConnection get _connection; + + PostgreSQLExecutionContext get _transaction; + + @override + int get queueSize => _queue.length; + + @override + Future query( + String fmtString, { + Map substitutionValues, + bool allowReuse, + int timeoutInSeconds, + }) => + _query( + fmtString, + substitutionValues: substitutionValues, + allowReuse: allowReuse, + timeoutInSeconds: timeoutInSeconds, + ); + + Future _query( + String fmtString, { + Map substitutionValues, + bool allowReuse, + int timeoutInSeconds, + bool resolveOids, + }) async { + allowReuse ??= true; + timeoutInSeconds ??= _connection.queryTimeoutInSeconds; + resolveOids ??= true; + + if (_connection.isClosed) { + throw PostgreSQLException( + 'Attempting to execute query, but connection is not open.'); } - if (query.cache.isValid) { - _reuseMap[query.statement] = query.cache; + final query = Query>>( + fmtString, substitutionValues, _connection, _transaction); + if (allowReuse) { + query.statementIdentifier = _connection._cache.identifierForQuery(query); } + + final queryResult = + await _enqueue(query, timeoutInSeconds: timeoutInSeconds); + var columnDescriptions = query.fieldDescriptions; + if (resolveOids) { + columnDescriptions = await _connection._oidCache + ._resolveTableNames(this, columnDescriptions); + } + final metaData = _PostgreSQLResultMetaData(columnDescriptions); + + return _PostgreSQLResult( + queryResult.affectedRowCount, + metaData, + queryResult.value + .map((columns) => _PostgreSQLResultRow(metaData, columns)) + .toList()); + } + + @override + Future>>> mappedResultsQuery( + String fmtString, + {Map substitutionValues, + bool allowReuse, + int timeoutInSeconds}) async { + final rs = await query( + fmtString, + substitutionValues: substitutionValues, + allowReuse: allowReuse, + timeoutInSeconds: timeoutInSeconds, + ); + return rs.map((row) => row.toTableColumnMap()).toList(); } - _QueryCache _cachedQuery(String statementIdentifier) { - if (statementIdentifier == null) { - return null; + @override + Future execute(String fmtString, + {Map substitutionValues, int timeoutInSeconds}) async { + timeoutInSeconds ??= _connection.queryTimeoutInSeconds; + if (_connection.isClosed) { + throw PostgreSQLException( + 'Attempting to execute query, but connection is not open.'); } - return _reuseMap[statementIdentifier]; + final query = Query( + fmtString, substitutionValues, _connection, _transaction, + onlyReturnAffectedRowCount: true); + + final result = await _enqueue(query, timeoutInSeconds: timeoutInSeconds); + return result.affectedRowCount; } - String _reuseIdentifierForQuery(_Query q) { - var existing = _reuseMap[q.statement]; - if (existing != null) { - return existing.preparedStatementName; + @override + void cancelTransaction({String reason}); + + Future> _enqueue(Query query, + {int timeoutInSeconds = 30}) async { + if (_queue.add(query)) { + _connection._transitionToState(_connection._connectionState.awake()); + + try { + final result = + await query.future.timeout(Duration(seconds: timeoutInSeconds)); + _connection._cache.add(query); + _queue.remove(query); + return result; + } catch (e, st) { + _queue.remove(query); + await _onQueryError(query, e, st); + rethrow; + } + } else { + // wrap the synchronous future in an async future to ensure that + // the caller behaves correctly in this condition. otherwise, + // the caller would complete synchronously. This future + // will always complete as a cancellation error. + return Future(() async => query.future); } + } + + Future _onQueryError(Query query, dynamic error, [StackTrace trace]) async {} +} - var string = "$_reuseCounter".padLeft(12, "0"); +class _PostgreSQLResultMetaData { + final List columnDescriptions; + List _tableNames; - _reuseCounter ++; + _PostgreSQLResultMetaData(this.columnDescriptions); - return string; + List get tableNames { + _tableNames ??= + columnDescriptions.map((column) => column.tableName).toSet().toList(); + return _tableNames; } } -class _TransactionRollbackException implements Exception { - _TransactionRollbackException(this.reason); - String reason; -} \ No newline at end of file +class _PostgreSQLResult extends UnmodifiableListView + implements PostgreSQLResult { + @override + final int affectedRowCount; + final _PostgreSQLResultMetaData _metaData; + + _PostgreSQLResult( + this.affectedRowCount, this._metaData, List rows) + : super(rows); + + @override + List get columnDescriptions => + _metaData.columnDescriptions; +} + +class _PostgreSQLResultRow extends UnmodifiableListView + implements PostgreSQLResultRow { + final _PostgreSQLResultMetaData _metaData; + + _PostgreSQLResultRow(this._metaData, List columns) : super(columns); + + @override + List get columnDescriptions => + _metaData.columnDescriptions; + + @override + Map> toTableColumnMap() { + final rowMap = >{}; + _metaData.tableNames.forEach((tableName) { + rowMap[tableName] = {}; + }); + for (var i = 0; i < _metaData.columnDescriptions.length; i++) { + final col = _metaData.columnDescriptions[i]; + rowMap[col.tableName][col.columnName] = this[i]; + } + return rowMap; + } + + @override + Map toColumnMap() { + final rowMap = {}; + for (var i = 0; i < _metaData.columnDescriptions.length; i++) { + final col = _metaData.columnDescriptions[i]; + rowMap[col.columnName] = this[i]; + } + return rowMap; + } +} diff --git a/lib/src/connection_fsm.dart b/lib/src/connection_fsm.dart index 4f48d20..3dbb2b4 100644 --- a/lib/src/connection_fsm.dart +++ b/lib/src/connection_fsm.dart @@ -1,4 +1,4 @@ -part of postgres; +part of postgres.connection; abstract class _PostgreSQLConnectionState { PostgreSQLConnection connection; @@ -11,71 +11,77 @@ abstract class _PostgreSQLConnectionState { return this; } - _PostgreSQLConnectionState onMessage(_ServerMessage message) { + _PostgreSQLConnectionState onMessage(ServerMessage message) { return this; } - _PostgreSQLConnectionState onErrorResponse(_ErrorResponseMessage message) { - var exception = new PostgreSQLException._(message.fields); + _PostgreSQLConnectionState onErrorResponse(ErrorResponseMessage message) { + final exception = PostgreSQLException._(message.fields); - if (exception.severity == PostgreSQLSeverity.fatal || exception.severity == PostgreSQLSeverity.panic) { - return new _PostgreSQLConnectionStateClosed(); + if (exception.severity == PostgreSQLSeverity.fatal || + exception.severity == PostgreSQLSeverity.panic) { + return _PostgreSQLConnectionStateClosed(); } return this; } - void onExit() { - - } + void onExit() {} } /* Closed State; starts here and ends here. */ -class _PostgreSQLConnectionStateClosed extends _PostgreSQLConnectionState { -} +class _PostgreSQLConnectionStateClosed extends _PostgreSQLConnectionState {} /* Socket connected, prior to any PostgreSQL handshaking - initiates that handshaking */ -class _PostgreSQLConnectionStateSocketConnected extends _PostgreSQLConnectionState { +class _PostgreSQLConnectionStateSocketConnected + extends _PostgreSQLConnectionState { _PostgreSQLConnectionStateSocketConnected(this.completer); + Completer completer; + @override _PostgreSQLConnectionState onEnter() { - var startupMessage = new _StartupMessage(connection.databaseName, connection.timeZone, username: connection.username); + final startupMessage = StartupMessage( + connection.databaseName, connection.timeZone, + username: connection.username); connection._socket.add(startupMessage.asBytes()); return this; } - _PostgreSQLConnectionState onErrorResponse(_ErrorResponseMessage message) { - var exception = new PostgreSQLException._(message.fields); + @override + _PostgreSQLConnectionState onErrorResponse(ErrorResponseMessage message) { + final exception = PostgreSQLException._(message.fields); completer.completeError(exception); - return new _PostgreSQLConnectionStateClosed(); + return _PostgreSQLConnectionStateClosed(); } - _PostgreSQLConnectionState onMessage(_ServerMessage message) { - _AuthenticationMessage authMessage = message; + @override + _PostgreSQLConnectionState onMessage(ServerMessage message) { + final authMessage = message as AuthenticationMessage; // Pass on the pending op to subsequent stages - if (authMessage.type == _AuthenticationMessage.KindOK) { - return new _PostgreSQLConnectionStateAuthenticated(completer); - } else if (authMessage.type == _AuthenticationMessage.KindMD5Password) { + if (authMessage.type == AuthenticationMessage.KindOK) { + return _PostgreSQLConnectionStateAuthenticated(completer); + } else if (authMessage.type == AuthenticationMessage.KindMD5Password) { connection._salt = authMessage.salt; - return new _PostgreSQLConnectionStateAuthenticating(completer); + return _PostgreSQLConnectionStateAuthenticating(completer); } - completer.completeError(new PostgreSQLException("Unsupported authentication type ${authMessage.type}, closing connection.")); + completer.completeError(PostgreSQLException( + 'Unsupported authentication type ${authMessage.type}, closing connection.')); - return new _PostgreSQLConnectionStateClosed(); + return _PostgreSQLConnectionStateClosed(); } } @@ -83,35 +89,41 @@ class _PostgreSQLConnectionStateSocketConnected extends _PostgreSQLConnectionSta Authenticating state */ -class _PostgreSQLConnectionStateAuthenticating extends _PostgreSQLConnectionState { +class _PostgreSQLConnectionStateAuthenticating + extends _PostgreSQLConnectionState { _PostgreSQLConnectionStateAuthenticating(this.completer); + Completer completer; + @override _PostgreSQLConnectionState onEnter() { - var authMessage = new _AuthMD5Message(connection.username, connection.password, connection._salt); + final authMessage = AuthMD5Message( + connection.username, connection.password, connection._salt); connection._socket.add(authMessage.asBytes()); return this; } - _PostgreSQLConnectionState onErrorResponse(_ErrorResponseMessage message) { - var exception = new PostgreSQLException._(message.fields); + @override + _PostgreSQLConnectionState onErrorResponse(ErrorResponseMessage message) { + final exception = PostgreSQLException._(message.fields); completer.completeError(exception); - return new _PostgreSQLConnectionStateClosed(); + return _PostgreSQLConnectionStateClosed(); } - _PostgreSQLConnectionState onMessage(_ServerMessage message) { - if (message is _ParameterStatusMessage) { + @override + _PostgreSQLConnectionState onMessage(ServerMessage message) { + if (message is ParameterStatusMessage) { connection.settings[message.name] = message.value; - } else if (message is _BackendKeyMessage) { - connection._secretKey = message.secretKey; + } else if (message is BackendKeyMessage) { connection._processID = message.processID; - } else if (message is _ReadyForQueryMessage) { - if (message.state == _ReadyForQueryMessage.StateIdle) { - return new _PostgreSQLConnectionStateIdle(openCompleter: completer); + connection._secretKey = message.secretKey; + } else if (message is ReadyForQueryMessage) { + if (message.state == ReadyForQueryMessage.StateIdle) { + return _PostgreSQLConnectionStateIdle(openCompleter: completer); } } @@ -123,27 +135,31 @@ class _PostgreSQLConnectionStateAuthenticating extends _PostgreSQLConnectionStat Authenticated state */ -class _PostgreSQLConnectionStateAuthenticated extends _PostgreSQLConnectionState { +class _PostgreSQLConnectionStateAuthenticated + extends _PostgreSQLConnectionState { _PostgreSQLConnectionStateAuthenticated(this.completer); + Completer completer; - _PostgreSQLConnectionState onErrorResponse(_ErrorResponseMessage message) { - var exception = new PostgreSQLException._(message.fields); + @override + _PostgreSQLConnectionState onErrorResponse(ErrorResponseMessage message) { + final exception = PostgreSQLException._(message.fields); completer.completeError(exception); - return new _PostgreSQLConnectionStateClosed(); + return _PostgreSQLConnectionStateClosed(); } - _PostgreSQLConnectionState onMessage(_ServerMessage message) { - if (message is _ParameterStatusMessage) { + @override + _PostgreSQLConnectionState onMessage(ServerMessage message) { + if (message is ParameterStatusMessage) { connection.settings[message.name] = message.value; - } else if (message is _BackendKeyMessage) { - connection._secretKey = message.secretKey; + } else if (message is BackendKeyMessage) { connection._processID = message.processID; - } else if (message is _ReadyForQueryMessage) { - if (message.state == _ReadyForQueryMessage.StateIdle) { - return new _PostgreSQLConnectionStateIdle(openCompleter: completer); + connection._secretKey = message.secretKey; + } else if (message is ReadyForQueryMessage) { + if (message.state == ReadyForQueryMessage.StateIdle) { + return _PostgreSQLConnectionStateIdle(openCompleter: completer); } } @@ -160,8 +176,9 @@ class _PostgreSQLConnectionStateIdle extends _PostgreSQLConnectionState { Completer openCompleter; + @override _PostgreSQLConnectionState awake() { - var pendingQuery = connection._pendingQuery; + final pendingQuery = connection._queue.pending; if (pendingQuery != null) { return processQuery(pendingQuery); } @@ -169,34 +186,36 @@ class _PostgreSQLConnectionStateIdle extends _PostgreSQLConnectionState { return this; } - _PostgreSQLConnectionState processQuery(_Query q) { + _PostgreSQLConnectionState processQuery(Query q) { try { if (q.onlyReturnAffectedRowCount) { q.sendSimple(connection._socket); - return new _PostgreSQLConnectionStateBusy(q); + return _PostgreSQLConnectionStateBusy(q); } - var cached = connection._cachedQuery(q.statement); + final cached = connection._cache[q.statement]; q.sendExtended(connection._socket, cacheQuery: cached); - return new _PostgreSQLConnectionStateBusy(q); - } catch (e) { + return _PostgreSQLConnectionStateBusy(q); + } catch (e, st) { scheduleMicrotask(() { - q.completeError(e); - connection._transitionToState(new _PostgreSQLConnectionStateIdle()); + q.completeError(e, st); + connection._transitionToState(_PostgreSQLConnectionStateIdle()); }); - return new _PostgreSQLConnectionStateDeferredFailure(); + return _PostgreSQLConnectionStateDeferredFailure(); } } + @override _PostgreSQLConnectionState onEnter() { openCompleter?.complete(); return awake(); } - _PostgreSQLConnectionState onMessage(_ServerMessage message) { + @override + _PostgreSQLConnectionState onMessage(ServerMessage message) { return this; } } @@ -208,60 +227,61 @@ class _PostgreSQLConnectionStateIdle extends _PostgreSQLConnectionState { class _PostgreSQLConnectionStateBusy extends _PostgreSQLConnectionState { _PostgreSQLConnectionStateBusy(this.query); - _Query query; - PostgreSQLException returningException = null; + Query query; + PostgreSQLException returningException; int rowsAffected = 0; - _PostgreSQLConnectionState onErrorResponse(_ErrorResponseMessage message) { + @override + _PostgreSQLConnectionState onErrorResponse(ErrorResponseMessage message) { // If we get an error here, then we should eat the rest of the messages // and we are always confirmed to get a _ReadyForQueryMessage to finish up. // We should only report the error once that is done. - var exception = new PostgreSQLException._(message.fields); + final exception = PostgreSQLException._(message.fields); returningException ??= exception; - if (exception.severity == PostgreSQLSeverity.fatal || exception.severity == PostgreSQLSeverity.panic) { - return new _PostgreSQLConnectionStateClosed(); + if (exception.severity == PostgreSQLSeverity.fatal || + exception.severity == PostgreSQLSeverity.panic) { + return _PostgreSQLConnectionStateClosed(); } return this; } - _PostgreSQLConnectionState onMessage(_ServerMessage message) { + @override + _PostgreSQLConnectionState onMessage(ServerMessage message) { // We ignore NoData, as it doesn't tell us anything we don't already know // or care about. - //print("(${query.statement}) -> $message"); - - if (message is _ReadyForQueryMessage) { - if (message.state == _ReadyForQueryMessage.StateIdle) { - if (returningException != null) { - query.completeError(returningException); - } else { - query.complete(rowsAffected); - } - - return new _PostgreSQLConnectionStateIdle(); - } else if (message.state == _ReadyForQueryMessage.StateTransaction) { - if (returningException != null) { - query.completeError(returningException); - } else { - query.complete(rowsAffected); - } - - return new _PostgreSQLConnectionStateReadyInTransaction(query.transaction); - } else if (message.state == _ReadyForQueryMessage.StateTransactionError) { - // This should cancel the transaction, we may have to send a commit here + // print("(${query.statement}) -> $message"); + + if (message is ReadyForQueryMessage) { + if (message.state == ReadyForQueryMessage.StateTransactionError) { + query.completeError(returningException); + return _PostgreSQLConnectionStateReadyInTransaction( + query.transaction as _TransactionProxy); + } + + if (returningException != null) { query.completeError(returningException); - return new _PostgreSQLConnectionStateTransactionFailure(query.transaction); + } else { + query.complete(rowsAffected); } - } else if (message is _CommandCompleteMessage) { + + if (message.state == ReadyForQueryMessage.StateTransaction) { + return _PostgreSQLConnectionStateReadyInTransaction( + query.transaction as _TransactionProxy); + } + + return _PostgreSQLConnectionStateIdle(); + } else if (message is CommandCompleteMessage) { rowsAffected = message.rowsAffected; - } else if (message is _RowDescriptionMessage) { + } else if (message is RowDescriptionMessage) { query.fieldDescriptions = message.fieldDescriptions; - } else if (message is _DataRowMessage) { + } else if (message is DataRowMessage) { query.addRow(message.values); - } else if (message is _ParameterDescriptionMessage) { - var validationException = query.validateParameters(message.parameterTypeIDs); + } else if (message is ParameterDescriptionMessage) { + final validationException = + query.validateParameters(message.parameterTypeIDs); if (validationException != null) { query.cache = null; } @@ -274,17 +294,20 @@ class _PostgreSQLConnectionStateBusy extends _PostgreSQLConnectionState { /* Idle Transaction State */ -class _PostgreSQLConnectionStateReadyInTransaction extends _PostgreSQLConnectionState { +class _PostgreSQLConnectionStateReadyInTransaction + extends _PostgreSQLConnectionState { _PostgreSQLConnectionStateReadyInTransaction(this.transaction); _TransactionProxy transaction; + @override _PostgreSQLConnectionState onEnter() { return awake(); } + @override _PostgreSQLConnectionState awake() { - var pendingQuery = transaction.pendingQuery; + final pendingQuery = transaction._queue.pending; if (pendingQuery != null) { return processQuery(pendingQuery); } @@ -292,45 +315,30 @@ class _PostgreSQLConnectionStateReadyInTransaction extends _PostgreSQLConnection return this; } - _PostgreSQLConnectionState processQuery(_Query q) { + _PostgreSQLConnectionState processQuery(Query q) { try { if (q.onlyReturnAffectedRowCount) { q.sendSimple(connection._socket); - return new _PostgreSQLConnectionStateBusy(q); + return _PostgreSQLConnectionStateBusy(q); } - var cached = connection._cachedQuery(q.statement); + final cached = connection._cache[q.statement]; q.sendExtended(connection._socket, cacheQuery: cached); - return new _PostgreSQLConnectionStateBusy(q); - } catch (e) { + return _PostgreSQLConnectionStateBusy(q); + } catch (e, st) { scheduleMicrotask(() { - q.completeError(e); - connection._transitionToState(new _PostgreSQLConnectionStateIdle()); + q.completeError(e, st); }); - return new _PostgreSQLConnectionStateDeferredFailure(); + return this; } } } -/* - Transaction error state - */ - -class _PostgreSQLConnectionStateTransactionFailure extends _PostgreSQLConnectionState { - _PostgreSQLConnectionStateTransactionFailure(this.transaction); - _TransactionProxy transaction; - - _PostgreSQLConnectionState awake() { - return new _PostgreSQLConnectionStateReadyInTransaction(transaction); - } -} - - /* Hack for deferred error */ -class _PostgreSQLConnectionStateDeferredFailure extends _PostgreSQLConnectionState { -} +class _PostgreSQLConnectionStateDeferredFailure + extends _PostgreSQLConnectionState {} diff --git a/lib/src/constants.dart b/lib/src/constants.dart new file mode 100644 index 0000000..2e1c0ae --- /dev/null +++ b/lib/src/constants.dart @@ -0,0 +1,24 @@ +class UTF8ByteConstants { + static const user = [117, 115, 101, 114, 0]; + static const database = [100, 97, 116, 97, 98, 97, 115, 101, 0]; + static const clientEncoding = [ + 99, + 108, + 105, + 101, + 110, + 116, + 95, + 101, + 110, + 99, + 111, + 100, + 105, + 110, + 103, + 0 + ]; + static const utf8 = [85, 84, 70, 56, 0]; + static const timeZone = [84, 105, 109, 101, 90, 111, 110, 101, 0]; +} diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index bdc7efb..44e6a00 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -1,10 +1,9 @@ -part of postgres; +part of postgres.connection; /// The severity level of a [PostgreSQLException]. /// /// [panic] and [fatal] errors will close the connection. enum PostgreSQLSeverity { - /// A [PostgreSQLException] with this severity indicates the throwing connection is now closed. panic, @@ -35,38 +34,40 @@ enum PostgreSQLSeverity { /// Exception thrown by [PostgreSQLConnection] instances. class PostgreSQLException implements Exception { - PostgreSQLException(String message, {PostgreSQLSeverity severity: PostgreSQLSeverity.error, this.stackTrace}) { - this.severity = severity; - this.message = message; - code = ""; + PostgreSQLException(this.message, + {this.severity = PostgreSQLSeverity.error, this.stackTrace}) { + code = ''; } - PostgreSQLException._(List<_ErrorField> errorFields, {this.stackTrace}) { - var finder = (int identifer) => (errorFields.firstWhere((_ErrorField e) => e.identificationToken == identifer, orElse: () => null)); - - severity = _ErrorField.severityFromString(finder(_ErrorField.SeverityIdentifier).text); - code = finder(_ErrorField.CodeIdentifier).text; - message = finder(_ErrorField.MessageIdentifier).text; - detail = finder(_ErrorField.DetailIdentifier)?.text; - hint = finder(_ErrorField.HintIdentifier)?.text; - - internalQuery = finder(_ErrorField.InternalQueryIdentifier)?.text; - trace = finder(_ErrorField.WhereIdentifier)?.text; - schemaName = finder(_ErrorField.SchemaIdentifier)?.text; - tableName = finder(_ErrorField.TableIdentifier)?.text; - columnName = finder(_ErrorField.ColumnIdentifier)?.text; - dataTypeName = finder(_ErrorField.DataTypeIdentifier)?.text; - constraintName = finder(_ErrorField.ConstraintIdentifier)?.text; - fileName = finder(_ErrorField.FileIdentifier)?.text; - routineName = finder(_ErrorField.RoutineIdentifier)?.text; - - var i = finder(_ErrorField.PositionIdentifier)?.text; + PostgreSQLException._(List errorFields, {this.stackTrace}) { + final finder = (int identifer) => (errorFields.firstWhere( + (ErrorField e) => e.identificationToken == identifer, + orElse: () => null)); + + severity = ErrorField.severityFromString( + finder(ErrorField.SeverityIdentifier).text); + code = finder(ErrorField.CodeIdentifier).text; + message = finder(ErrorField.MessageIdentifier).text; + detail = finder(ErrorField.DetailIdentifier)?.text; + hint = finder(ErrorField.HintIdentifier)?.text; + + internalQuery = finder(ErrorField.InternalQueryIdentifier)?.text; + trace = finder(ErrorField.WhereIdentifier)?.text; + schemaName = finder(ErrorField.SchemaIdentifier)?.text; + tableName = finder(ErrorField.TableIdentifier)?.text; + columnName = finder(ErrorField.ColumnIdentifier)?.text; + dataTypeName = finder(ErrorField.DataTypeIdentifier)?.text; + constraintName = finder(ErrorField.ConstraintIdentifier)?.text; + fileName = finder(ErrorField.FileIdentifier)?.text; + routineName = finder(ErrorField.RoutineIdentifier)?.text; + + var i = finder(ErrorField.PositionIdentifier)?.text; position = (i != null ? int.parse(i) : null); - i = finder(_ErrorField.InternalPositionIdentifier)?.text; + i = finder(ErrorField.InternalPositionIdentifier)?.text; internalPosition = (i != null ? int.parse(i) : null); - i = finder(_ErrorField.LineIdentifier)?.text; + i = finder(ErrorField.LineIdentifier)?.text; lineNumber = (i != null ? int.parse(i) : null); } @@ -107,54 +108,30 @@ class PostgreSQLException implements Exception { /// A [StackTrace] if available. StackTrace stackTrace; - String toString() => "$severity $code: $message Detail: $detail Hint: $hint Table: $tableName Column: $columnName Constraint: $constraintName"; -} + @override + String toString() { + final buff = StringBuffer('$severity $code: $message '); -class _ErrorField { - static const int SeverityIdentifier = 83; - static const int CodeIdentifier = 67; - static const int MessageIdentifier = 77; - static const int DetailIdentifier = 68; - static const int HintIdentifier = 72; - static const int PositionIdentifier = 80; - static const int InternalPositionIdentifier = 112; - static const int InternalQueryIdentifier = 113; - static const int WhereIdentifier = 87; - static const int SchemaIdentifier = 115; - static const int TableIdentifier = 116; - static const int ColumnIdentifier = 99; - static const int DataTypeIdentifier = 100; - static const int ConstraintIdentifier = 110; - static const int FileIdentifier = 70; - static const int LineIdentifier = 76; - static const int RoutineIdentifier = 82; - - static PostgreSQLSeverity severityFromString(String str) { - switch (str) { - case "ERROR" : return PostgreSQLSeverity.error; - case "FATAL" : return PostgreSQLSeverity.fatal; - case "PANIC" : return PostgreSQLSeverity.panic; - case "WARNING" : return PostgreSQLSeverity.warning; - case "NOTICE" : return PostgreSQLSeverity.notice; - case "DEBUG" : return PostgreSQLSeverity.debug; - case "INFO" : return PostgreSQLSeverity.info; - case "LOG" : return PostgreSQLSeverity.log; + if (detail != null) { + buff.write('Detail: $detail '); } - return PostgreSQLSeverity.unknown; - } - int identificationToken; + if (hint != null) { + buff.write('Hint: $hint '); + } - String get text => _buffer.toString(); - StringBuffer _buffer = new StringBuffer(); + if (tableName != null) { + buff.write('Table: $tableName '); + } - void add(int byte) { - if (identificationToken == null) { - identificationToken = byte; - } else { - _buffer.writeCharCode(byte); + if (columnName != null) { + buff.write('Column: $columnName '); + } + + if (constraintName != null) { + buff.write('Constraint $constraintName '); } - } - String toString() => text; + return buff.toString(); + } } diff --git a/lib/src/execution_context.dart b/lib/src/execution_context.dart new file mode 100644 index 0000000..c5f0e0e --- /dev/null +++ b/lib/src/execution_context.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'connection.dart'; +import 'query.dart'; +import 'substituter.dart'; +import 'types.dart'; + +abstract class PostgreSQLExecutionContext { + /// Returns this context queue size + int get queueSize; + + /// Executes a query on this context. + /// + /// This method sends the query described by [fmtString] to the database and returns a [Future] whose value is the returned rows from the query after the query completes. + /// The format string may contain parameters that are provided in [substitutionValues]. Parameters are prefixed with the '@' character. Keys to replace the parameters + /// do not include the '@' character. For example: + /// + /// connection.query("SELECT * FROM table WHERE id = @idParam", {"idParam" : 2}); + /// + /// The type of the value is inferred by default, but should be made more specific by adding ':type" to the parameter pattern in the format string. For example: + /// + /// connection.query("SELECT * FROM table WHERE id = @idParam:int4", {"idParam" : 2}); + /// + /// Available types are listed in [PostgreSQLFormatIdentifier.typeStringToCodeMap]. Some types have multiple options. It is preferable to use the [PostgreSQLFormat.id] + /// function to add parameters to a query string. This method inserts a parameter name and the appropriate ':type' string for a [PostgreSQLDataType]. + /// + /// If successful, the returned [Future] completes with a [List] of rows. Each is row is represented by a [List] of column values for that row that were returned by the query. + /// + /// By default, instances of this class will reuse queries. This allows significantly more efficient transport to and from the database. You do not have to do + /// anything to opt in to this behavior, this connection will track the necessary information required to reuse queries without intervention. (The [fmtString] is + /// the unique identifier to look up reuse information.) You can disable reuse by passing false for [allowReuse]. + Future query(String fmtString, + {Map substitutionValues, + bool allowReuse, + int timeoutInSeconds}); + + /// Executes a query on this context. + /// + /// This method sends a SQL string to the database this instance is connected to. Parameters can be provided in [fmtString], see [query] for more details. + /// + /// This method returns the number of rows affected and no additional information. This method uses the least efficient and less secure command + /// for executing queries in the PostgreSQL protocol; [query] is preferred for queries that will be executed more than once, will contain user input, + /// or return rows. + Future execute(String fmtString, + {Map substitutionValues, int timeoutInSeconds}); + + /// Cancels a transaction on this context. + /// + /// If this context is an instance of [PostgreSQLConnection], this method has no effect. If the context is a transaction context (passed as the argument + /// to [PostgreSQLConnection.transaction]), this will rollback the transaction. + void cancelTransaction({String reason}); + + /// Executes a query on this connection and returns each row as a [Map]. + /// + /// This method constructs and executes a query in the same way as [query], but returns each row as a [Map]. + /// + /// (Note: this method will execute additional queries to resolve table names the first time a table is encountered. These table names are cached per instance of this type.) + /// + /// Each row map contains key-value pairs for every table in the query. The value is a [Map] that contains + /// key-value pairs for each column from that table. For example, consider + /// the following query: + /// + /// SELECT employee.id, employee.name FROM employee; + /// + /// This method would return the following structure: + /// + /// [ + /// {"employee" : {"name": "Bob", "id": 1}} + /// ] + /// + /// The purpose of this nested structure is to disambiguate columns that have the same name in different tables. For example, consider a query with a SQL JOIN: + /// + /// SELECT employee.id, employee.name, company.name FROM employee LEFT OUTER JOIN company ON employee.company_id=company.id; + /// + /// Each returned [Map] would contain `employee` and `company` keys. The `name` key would be present in both inner maps. + /// + /// [ + /// { + /// "employee": {"name": "Bob", "id": 1}, + /// "company: {"name": "stable|kernel"} + /// } + /// ] + Future>>> mappedResultsQuery( + String fmtString, + {Map substitutionValues, + bool allowReuse, + int timeoutInSeconds}); +} + +/// A description of a column. +abstract class ColumnDescription { + /// The name of the column returned by the query. + String get columnName; + + /// The resolved name of the referenced table. + String get tableName; +} + +/// A single row of a query result. +/// +/// Column values can be accessed through the `[]` operator. +abstract class PostgreSQLResultRow implements List { + List get columnDescriptions; + + /// Returns a two-level map that on the first level contains the resolved + /// table name, and on the second level the column name (or its alias). + Map> toTableColumnMap(); + + /// Returns a single-level map that maps the column name (or its alias) to the + /// value returned on that position. + Map toColumnMap(); +} + +/// The query result. +/// +/// Rows can be accessed through the `[]` operator. +abstract class PostgreSQLResult implements List { + /// How many rows did this query affect? + int get affectedRowCount; + List get columnDescriptions; +} diff --git a/lib/src/message_window.dart b/lib/src/message_window.dart index 2512daa..c40129a 100644 --- a/lib/src/message_window.dart +++ b/lib/src/message_window.dart @@ -1,105 +1,73 @@ -part of postgres; +import 'dart:collection'; +import 'dart:typed_data'; -class _MessageFrame { - static Map _messageTypeMap = { - 49 : () => new _ParseCompleteMessage(), - 50 : () => new _BindCompleteMessage(), - 67 : () => new _CommandCompleteMessage(), - 68 : () => new _DataRowMessage(), - 69 : () => new _ErrorResponseMessage(), +import 'package:buffer/buffer.dart'; - 75 : () => new _BackendKeyMessage(), +import 'server_messages.dart'; - 82 : () => new _AuthenticationMessage(), - 83 : () => new _ParameterStatusMessage(), - 84 : () => new _RowDescriptionMessage(), +const int _headerByteSize = 5; +final _emptyData = Uint8List(0); - 90 : () => new _ReadyForQueryMessage(), - 110 : () => new _NoDataMessage(), - 116 : () => new _ParameterDescriptionMessage() - }; +typedef _ServerMessageFn = ServerMessage Function(Uint8List data); - BytesBuilder _inputBuffer = new BytesBuilder(copy: false); - int type; - int expectedLength; +Map _messageTypeMap = { + 49: (d) => ParseCompleteMessage(), + 50: (d) => BindCompleteMessage(), + 65: (d) => NotificationResponseMessage(d), + 67: (d) => CommandCompleteMessage(d), + 68: (d) => DataRowMessage(d), + 69: (d) => ErrorResponseMessage(d), + 75: (d) => BackendKeyMessage(d), + 82: (d) => AuthenticationMessage(d), + 83: (d) => ParameterStatusMessage(d), + 84: (d) => RowDescriptionMessage(d), + 90: (d) => ReadyForQueryMessage(d), + 110: (d) => NoDataMessage(), + 116: (d) => ParameterDescriptionMessage(d), +}; - bool get isComplete => data != null; - Uint8List data; +class MessageFramer { + final _reader = ByteDataReader(); + final messageQueue = Queue(); - int addBytes(Uint8List bytes) { - // If we just have the beginning of a packet, then consume the bytes and continue. - if (_inputBuffer.length + bytes.length < 5) { + int _type; + int _expectedLength; - _inputBuffer.add(bytes); - return bytes.length; - } - - // If we have enough data to get the header out, peek at that data and store it - // This could be 5 if we haven't collected any data yet, or 1-4 if got a few bytes - // from a previous packet. It can't be <= 0 though, as the first precondition - // would have failed and we'd be right here. - var countNeededFromIncomingToDetermineMessage = 5 - _inputBuffer.length; - var headerBuffer = new Uint8List(5); - if (countNeededFromIncomingToDetermineMessage < 5) { - var takenBytes = _inputBuffer.takeBytes(); - headerBuffer.setRange(0, takenBytes.length, takenBytes); - } - headerBuffer.setRange(5 - countNeededFromIncomingToDetermineMessage, 5, new Uint8List.view(bytes.buffer, bytes.offsetInBytes, countNeededFromIncomingToDetermineMessage)); - - var bufReader = new ByteData.view(headerBuffer.buffer); - type = bufReader.getUint8(0); - expectedLength = bufReader.getUint32(1) - 4; // Remove this length from the length needed to complete this message - - var offsetIntoIncomingBytes = countNeededFromIncomingToDetermineMessage; - var byteBufferLengthRemaining = bytes.length - offsetIntoIncomingBytes; - if (byteBufferLengthRemaining >= expectedLength) { - _inputBuffer.add(new Uint8List.view(bytes.buffer, bytes.offsetInBytes + offsetIntoIncomingBytes, expectedLength)); - data = _inputBuffer.takeBytes(); - return offsetIntoIncomingBytes + expectedLength; - } - - _inputBuffer.add(new Uint8List.view(bytes.buffer, bytes.offsetInBytes + offsetIntoIncomingBytes)); - return bytes.length; - } - - _ServerMessage get message { - var msgMaker = _messageTypeMap[type]; - if (msgMaker == null) { - msgMaker = () { - var msg = new _UnknownMessage() - ..code = type; - return msg; - }; - } + bool get _hasReadHeader => _type != null; + bool get _canReadHeader => _reader.remainingLength >= _headerByteSize; - _ServerMessage msg = msgMaker(); - - msg.readBytes(data); - - return msg; - } -} - -class _MessageFramer { - _MessageFrame messageInProgress = new _MessageFrame(); - List<_MessageFrame> messageQueue = []; + bool get _isComplete => + _expectedLength == 0 || _expectedLength <= _reader.remainingLength; void addBytes(Uint8List bytes) { - var offsetIntoBytesRead = 0; + _reader.add(bytes); - do { - offsetIntoBytesRead += messageInProgress.addBytes(new Uint8List.view(bytes.buffer, offsetIntoBytesRead)); + var evaluateNextMessage = true; + while (evaluateNextMessage) { + evaluateNextMessage = false; - if (messageInProgress.isComplete) { - messageQueue.add(messageInProgress); - messageInProgress = new _MessageFrame(); + if (!_hasReadHeader && _canReadHeader) { + _type = _reader.readUint8(); + _expectedLength = _reader.readUint32() - 4; } - } while (offsetIntoBytesRead != bytes.length); + + if (_hasReadHeader && _isComplete) { + final data = + _expectedLength == 0 ? _emptyData : _reader.read(_expectedLength); + final msgMaker = _messageTypeMap[_type]; + final msg = + msgMaker == null ? UnknownMessage(_type, data) : msgMaker(data); + messageQueue.add(msg); + _type = null; + _expectedLength = null; + evaluateNextMessage = true; + } + } } bool get hasMessage => messageQueue.isNotEmpty; - _MessageFrame popMessage() { - return messageQueue.removeAt(0); + ServerMessage popMessage() { + return messageQueue.removeFirst(); } -} \ No newline at end of file +} diff --git a/lib/src/postgresql_codec.dart b/lib/src/postgresql_codec.dart deleted file mode 100644 index 46dc95a..0000000 --- a/lib/src/postgresql_codec.dart +++ /dev/null @@ -1,360 +0,0 @@ -part of postgres; - -/// The set of available data types that [PostgreSQLConnection]s support. -enum PostgreSQLDataType { - /// Must be a [String]. - text, - - /// Must be an [int] (4-byte integer) - integer, - - /// Must be an [int] (2-byte integer) - smallInteger, - - /// Must be an [int] (8-byte integer) - bigInteger, - - /// Must be an [int] (autoincrementing 4-byte integer) - serial, - - /// Must be an [int] (autoincrementing 8-byte integer) - bigSerial, - - /// Must be a [double] (32-bit floating point value) - real, - - /// Must be a [double] (64-bit floating point value) - double, - - /// Must be a [bool] - boolean, - - /// Must be a [DateTime] (microsecond date and time precision) - timestampWithoutTimezone, - - /// Must be a [DateTime] (microsecond date and time precision) - timestampWithTimezone, - - /// Must be a [DateTime] (contains year, month and day only) - date -} - -/// A namespace for data encoding and decoding operations for PostgreSQL data. -abstract class PostgreSQLCodec { - static const int TypeBool = 16; - static const int TypeInt8 = 20; - static const int TypeInt2 = 21; - static const int TypeInt4 = 23; - static const int TypeText = 25; - static const int TypeFloat4 = 700; - static const int TypeFloat8 = 701; - static const int TypeDate = 1082; - static const int TypeTimestamp = 1114; - static const int TypeTimestampTZ = 1184; - - static String encode(dynamic value, {PostgreSQLDataType dataType: null, bool escapeStrings: true}) { - if (value == null) { - return "null"; - } - - switch (dataType) { - case PostgreSQLDataType.text: - return encodeString(value.toString(), escapeStrings); - - case PostgreSQLDataType.integer: - case PostgreSQLDataType.smallInteger: - case PostgreSQLDataType.bigInteger: - case PostgreSQLDataType.serial: - case PostgreSQLDataType.bigSerial: - return encodeNumber(value); - - case PostgreSQLDataType.double: - case PostgreSQLDataType.real: - return encodeDouble(value); - - case PostgreSQLDataType.boolean: - return encodeBoolean(value); - - case PostgreSQLDataType.timestampWithoutTimezone: - case PostgreSQLDataType.timestampWithTimezone: - case PostgreSQLDataType.date: - return encodeDateTime(value); - - default: - return encodeDefault(value, escapeStrings: escapeStrings); - } - } - - static Uint8List encodeBinary(dynamic value, int postgresType) { - if (value == null) { - return null; - } - - Uint8List outBuffer = null; - - if (postgresType == TypeBool) { - if (value is! bool) { - throw new FormatException("Invalid type for parameter value. Expected: bool Got: ${value.runtimeType}"); - } - - var bd = new ByteData(1); - bd.setUint8(0, value ? 1 : 0); - outBuffer = bd.buffer.asUint8List(); - } else if (postgresType == TypeInt8) { - if (value is! int) { - throw new FormatException("Invalid type for parameter value. Expected: int Got: ${value.runtimeType}"); - } - - var bd = new ByteData(8); - bd.setInt64(0, value); - outBuffer = bd.buffer.asUint8List(); - } else if (postgresType == TypeInt2) { - if (value is! int) { - throw new FormatException("Invalid type for parameter value. Expected: int Got: ${value.runtimeType}"); - } - - var bd = new ByteData(2); - bd.setInt16(0, value); - outBuffer = bd.buffer.asUint8List(); - } else if (postgresType == TypeInt4) { - if (value is! int) { - throw new FormatException("Invalid type for parameter value. Expected: int Got: ${value.runtimeType}"); - } - - var bd = new ByteData(4); - bd.setInt32(0, value); - outBuffer = bd.buffer.asUint8List(); - } else if (postgresType == TypeText) { - if (value is! String) { - throw new FormatException("Invalid type for parameter value. Expected: String Got: ${value.runtimeType}"); - } - - String val = value; - outBuffer = new Uint8List.fromList(val.codeUnits); - } else if (postgresType == TypeFloat4) { - if (value is! double) { - throw new FormatException("Invalid type for parameter value. Expected: double Got: ${value.runtimeType}"); - } - - var bd = new ByteData(4); - bd.setFloat32(0, value); - outBuffer = bd.buffer.asUint8List(); - } else if (postgresType == TypeFloat8) { - if (value is! double) { - throw new FormatException("Invalid type for parameter value. Expected: double Got: ${value.runtimeType}"); - } - - var bd = new ByteData(8); - bd.setFloat64(0, value); - outBuffer = bd.buffer.asUint8List(); - } else if (postgresType == TypeDate) { - if (value is! DateTime) { - throw new FormatException("Invalid type for parameter value. Expected: DateTime Got: ${value.runtimeType}"); - } - - var bd = new ByteData(4); - bd.setInt32(0, value.toUtc().difference(new DateTime.utc(2000)).inDays); - outBuffer = bd.buffer.asUint8List(); - } else if (postgresType == TypeTimestamp) { - if (value is! DateTime) { - throw new FormatException("Invalid type for parameter value. Expected: DateTime Got: ${value.runtimeType}"); - } - - var bd = new ByteData(8); - var diff = value.toUtc().difference(new DateTime.utc(2000)); - bd.setInt64(0, diff.inMicroseconds); - outBuffer = bd.buffer.asUint8List(); - } else if (postgresType == TypeTimestampTZ) { - if (value is! DateTime) { - throw new FormatException("Invalid type for parameter value. Expected: DateTime Got: ${value.runtimeType}"); - } - - var bd = new ByteData(8); - bd.setInt64(0, value.toUtc().difference(new DateTime.utc(2000)).inMicroseconds); - outBuffer = bd.buffer.asUint8List(); - } - - return outBuffer; - } - - static String encodeString(String text, bool escapeStrings) { - if (!escapeStrings) { - return text; - } - - var backslashCodeUnit = r"\".codeUnitAt(0); - var quoteCodeUnit = r"'".codeUnitAt(0); - - var quoteCount = 0; - var backslashCount = 0; - var it = new RuneIterator(text); - while (it.moveNext()) { - if (it.current == backslashCodeUnit) { - backslashCount ++; - } else if (it.current == quoteCodeUnit) { - quoteCount ++; - } - } - - var buf = new StringBuffer(); - - if (backslashCount > 0) { - buf.write(" E"); - } - - buf.write("'"); - - if (quoteCount == 0 && backslashCount == 0) { - buf.write(text); - } else { - text.codeUnits.forEach((i) { - if (i == quoteCodeUnit || i == backslashCodeUnit) { - buf.writeCharCode(i); - buf.writeCharCode(i); - } else { - buf.writeCharCode(i); - } - }); - } - - buf.write("'"); - - return buf.toString(); - } - - static String encodeNumber(dynamic value) { - if (value is! num) { - throw new PostgreSQLException("Trying to encode ${value.runtimeType}: $value as integer-like type."); - } - - if (value.isNaN) { - return "'nan'"; - } - - if (value.isInfinite) { - return value.isNegative ? "'-infinity'" : "'infinity'"; - } - - return value.toInt().toString(); - } - - static String encodeDouble(dynamic value) { - if (value is! num) { - throw new PostgreSQLException("Trying to encode ${value.runtimeType}: $value as double-like type."); - } - - if (value.isNaN) { - return "'nan'"; - } - - if (value.isInfinite) { - return value.isNegative ? "'-infinity'" : "'infinity'"; - } - - return value.toString(); - } - - static String encodeBoolean(dynamic value) { - if (value is! bool) { - throw new PostgreSQLException("Trying to encode ${value.runtimeType}: $value as boolean type."); - } - - return value ? "TRUE" : "FALSE"; - } - - static String encodeDateTime(dynamic value, {bool isDateOnly: false}) { - if (value is! DateTime) { - throw new PostgreSQLException("Trying to encode ${value.runtimeType}: $value as date-like type."); - } - - var string = value.toIso8601String(); - - if (isDateOnly) { - string = string.split("T").first; - } else { - if (!value.isUtc) { - var timezoneHourOffset = value.timeZoneOffset.inHours; - var timezoneMinuteOffset = value.timeZoneOffset.inMinutes % 60; - - var hourComponent = timezoneHourOffset.abs().toString().padLeft(2, "0"); - var minuteComponent = timezoneMinuteOffset.abs().toString().padLeft(2, "0"); - - if (timezoneHourOffset >= 0) { - hourComponent = "+${hourComponent}"; - } else { - hourComponent = "-${hourComponent}"; - } - - var timezoneString = [hourComponent, minuteComponent].join(":"); - string = [string, timezoneString].join(""); - } - } - - if (string.substring(0, 1) == "-") { - string = string.substring(1) + " BC"; - } else if (string.substring(0, 1) == "+") { - string = string.substring(1); - } - - return "'$string'"; - } - - static String encodeDefault(dynamic value, {bool escapeStrings: true}) { - if (value == null) { - return "null"; - } - - if (value is int) { - return encodeNumber(value); - } - - if (value is double) { - return encodeDouble(value); - } - - if (value is String) { - return encodeString(value, escapeStrings); - } - - if (value is DateTime) { - return encodeDateTime(value, isDateOnly: false); - } - - if (value is bool) { - return encodeBoolean(value); - } - - throw new PostgreSQLException("Unknown inferred datatype from ${value.runtimeType}: $value"); - } - - static dynamic decodeValue(ByteData value, int dbTypeCode) { - if (value == null) { - return null; - } - - switch (dbTypeCode) { - case TypeBool: - return value.getInt8(0) != 0; - case TypeInt2: - return value.getInt16(0); - case TypeInt4: - return value.getInt32(0); - case TypeInt8: - return value.getInt64(0); - case TypeFloat4: - return value.getFloat32(0); - case TypeFloat8: - return value.getFloat64(0); - - case TypeTimestamp: - case TypeTimestampTZ: - return new DateTime.utc(2000).add(new Duration(microseconds: value.getInt64(0))); - - case TypeDate: - return new DateTime.utc(2000).add(new Duration(days: value.getInt32(0))); - - default: - return new String.fromCharCodes(value.buffer.asUint8List(value.offsetInBytes, value.lengthInBytes)); - } - } -} \ No newline at end of file diff --git a/lib/src/query.dart b/lib/src/query.dart index a7b2c20..26bd2d5 100644 --- a/lib/src/query.dart +++ b/lib/src/query.dart @@ -1,39 +1,62 @@ -part of postgres; +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:buffer/buffer.dart'; + +import 'binary_codec.dart'; +import 'client_messages.dart'; +import 'connection.dart'; +import 'execution_context.dart'; +import 'substituter.dart'; +import 'text_codec.dart'; +import 'types.dart'; + +class Query { + Query( + this.statement, + this.substitutionValues, + this.connection, + this.transaction, { + this.onlyReturnAffectedRowCount = false, + }); + + final bool onlyReturnAffectedRowCount; -class _Query { - _Query(this.statement, this.substitutionValues, this.connection, this.transaction); - - bool onlyReturnAffectedRowCount = false; String statementIdentifier; - Completer onComplete = new Completer.sync(); - Future get future => onComplete.future; + + Future> get future => _onComplete.future; final String statement; final Map substitutionValues; - final _TransactionProxy transaction; + final PostgreSQLExecutionContext transaction; final PostgreSQLConnection connection; - List specifiedParameterTypeCodes; + List _specifiedParameterTypeCodes; + final rows = >[]; + + CachedQuery cache; - List<_FieldDescription> _fieldDescriptions; - List<_FieldDescription> get fieldDescriptions => _fieldDescriptions; - void set fieldDescriptions(List<_FieldDescription> fds) { + final _onComplete = Completer>.sync(); + List _fieldDescriptions; + + List get fieldDescriptions => _fieldDescriptions; + + set fieldDescriptions(List fds) { _fieldDescriptions = fds; cache?.fieldDescriptions = fds; } - List> rows = []; - - _QueryCache cache; - void sendSimple(Socket socket) { - var sqlString = PostgreSQLFormat.substitute(statement, substitutionValues); - var queryMessage = new _QueryMessage(sqlString); + final sqlString = + PostgreSQLFormat.substitute(statement, substitutionValues); + final queryMessage = QueryMessage(sqlString); socket.add(queryMessage.asBytes()); } - void sendExtended(Socket socket, {_QueryCache cacheQuery: null}) { + void sendExtended(Socket socket, {CachedQuery cacheQuery}) { if (cacheQuery != null) { fieldDescriptions = cacheQuery.fieldDescriptions; sendCachedQuery(socket, cacheQuery, substitutionValues); @@ -41,173 +64,305 @@ class _Query { return; } - String statementName = (statementIdentifier ?? ""); - var formatIdentifiers = <_PostgreSQLFormatIdentifier>[]; - var sqlString = PostgreSQLFormat.substitute(statement, substitutionValues, replace: (_PostgreSQLFormatIdentifier identifier, int index) { + final statementName = statementIdentifier ?? ''; + final formatIdentifiers = []; + final sqlString = PostgreSQLFormat.substitute(statement, substitutionValues, + replace: (PostgreSQLFormatIdentifier identifier, int index) { formatIdentifiers.add(identifier); - return "\$$index"; + return '\$$index'; }); - specifiedParameterTypeCodes = formatIdentifiers - .map((i) => i.typeCode) - .toList(); + _specifiedParameterTypeCodes = + formatIdentifiers.map((i) => i.type).toList(); - var parameterList = formatIdentifiers - .map((id) => encodeParameter(id, substitutionValues)) + final parameterList = formatIdentifiers + .map((id) => ParameterValue(id, substitutionValues)) .toList(); - var messages = [ - new _ParseMessage(sqlString, statementName: statementName), - new _DescribeMessage(statementName: statementName), - new _BindMessage(parameterList, statementName: statementName), - new _ExecuteMessage(), - new _SyncMessage() + final messages = [ + ParseMessage(sqlString, statementName: statementName), + DescribeMessage(statementName: statementName), + BindMessage(parameterList, statementName: statementName), + ExecuteMessage(), + SyncMessage(), ]; if (statementIdentifier != null) { - cache = new _QueryCache(statementIdentifier, formatIdentifiers); + cache = CachedQuery(statementIdentifier, formatIdentifiers); } - socket.add(_ClientMessage.aggregateBytes(messages)); + socket.add(ClientMessage.aggregateBytes(messages)); } - void sendCachedQuery(Socket socket, _QueryCache cacheQuery, Map substitutionValues) { - var statementName = cacheQuery.preparedStatementName; - var parameterList = cacheQuery.orderedParameters - .map((identifier) => encodeParameter(identifier, substitutionValues)) + void sendCachedQuery(Socket socket, CachedQuery cacheQuery, + Map substitutionValues) { + final statementName = cacheQuery.preparedStatementName; + final parameterList = cacheQuery.orderedParameters + .map((identifier) => ParameterValue(identifier, substitutionValues)) .toList(); - var bytes = _ClientMessage.aggregateBytes([ - new _BindMessage(parameterList, statementName: statementName), - new _ExecuteMessage(), - new _SyncMessage() + final bytes = ClientMessage.aggregateBytes([ + BindMessage(parameterList, statementName: statementName), + ExecuteMessage(), + SyncMessage() ]); socket.add(bytes); } - _ParameterValue encodeParameter(_PostgreSQLFormatIdentifier identifier, Map substitutionValues) { - if (identifier.typeCode != null) { - return new _ParameterValue.binary(substitutionValues[identifier.name], identifier.typeCode); - } else { - return new _ParameterValue.text(substitutionValues[identifier.name]); - } - } - PostgreSQLException validateParameters(List parameterTypeIDs) { - var actualParameterTypeCodeIterator = parameterTypeIDs.iterator; - var parametersAreMismatched = specifiedParameterTypeCodes.map((specifiedTypeCode) { + final actualParameterTypeCodeIterator = parameterTypeIDs.iterator; + final parametersAreMismatched = + _specifiedParameterTypeCodes.map((specifiedType) { actualParameterTypeCodeIterator.moveNext(); - return actualParameterTypeCodeIterator.current == (specifiedTypeCode ?? actualParameterTypeCodeIterator.current); + + if (specifiedType == null) { + return true; + } + + final actualType = PostgresBinaryDecoder + .typeMap[actualParameterTypeCodeIterator.current]; + return actualType == specifiedType; }).any((v) => v == false); if (parametersAreMismatched) { - return new PostgreSQLException("Specified parameter types do not match column parameter types in query ${statement}"); + return PostgreSQLException( + 'Specified parameter types do not match column parameter types in query $statement'); } return null; } - void addRow(List rawRowData) { + void addRow(List rawRowData) { if (onlyReturnAffectedRowCount) { return; } - var iterator = fieldDescriptions.iterator; - var lazyDecodedData = rawRowData.map((bd) { + final iterator = fieldDescriptions.iterator; + final lazyDecodedData = rawRowData.map((bd) { iterator.moveNext(); - - return PostgreSQLCodec.decodeValue(bd, iterator.current.typeID); + return iterator.current.converter.convert(bd); }); - rows.add(lazyDecodedData); + rows.add(lazyDecodedData.toList()); } void complete(int rowsAffected) { + if (_onComplete.isCompleted) { + return; + } + if (onlyReturnAffectedRowCount) { - onComplete.complete(rowsAffected); + _onComplete.complete(QueryResult(rowsAffected, null)); return; } - onComplete.complete(rows.map((row) => row.toList()).toList()); + _onComplete.complete(QueryResult(rowsAffected, rows as T)); } - void completeError(dynamic error) { - onComplete.completeError(error); + void completeError(dynamic error, [StackTrace stackTrace]) { + if (_onComplete.isCompleted) { + return; + } + + _onComplete.completeError(error, stackTrace); } + @override String toString() => statement; } -class _QueryCache { - _QueryCache(this.preparedStatementName, this.orderedParameters); +class QueryResult { + final int affectedRowCount; + final T value; + + const QueryResult(this.affectedRowCount, this.value); +} + +class CachedQuery { + CachedQuery(this.preparedStatementName, this.orderedParameters); + + final String preparedStatementName; + final List orderedParameters; + List fieldDescriptions; - String preparedStatementName; - List<_PostgreSQLFormatIdentifier> orderedParameters; - List<_FieldDescription> fieldDescriptions; bool get isValid { - return preparedStatementName != null - && orderedParameters != null - && fieldDescriptions != null; + return preparedStatementName != null && + orderedParameters != null && + fieldDescriptions != null; } } -class _ParameterValue { - _ParameterValue.binary(dynamic value, this.postgresType) { - isBinary = true; - bytes = PostgreSQLCodec.encodeBinary(value, this.postgresType)?.buffer?.asUint8List(); - length = bytes?.length ?? 0; +class ParameterValue { + factory ParameterValue(PostgreSQLFormatIdentifier identifier, + Map substitutionValues) { + if (identifier.type == null) { + return ParameterValue.text(substitutionValues[identifier.name]); + } + + return ParameterValue.binary( + substitutionValues[identifier.name], identifier.type); + } + + factory ParameterValue.binary( + dynamic value, PostgreSQLDataType postgresType) { + final converter = PostgresBinaryEncoder(postgresType); + final bytes = converter.convert(value); + final length = bytes?.length ?? 0; + return ParameterValue._(true, bytes, length); } - _ParameterValue.text(dynamic value) { - isBinary = false; + factory ParameterValue.text(dynamic value) { + Uint8List bytes; if (value != null) { - bytes = new Uint8List.fromList(PostgreSQLCodec.encode(value, escapeStrings: false).codeUnits); + final converter = PostgresTextEncoder(); + bytes = castBytes( + utf8.encode(converter.convert(value, escapeStrings: false))); } - length = bytes?.length; + final length = bytes?.length ?? 0; + return ParameterValue._(false, bytes, length); } - bool isBinary; - int postgresType; - Uint8List bytes; - int length; + ParameterValue._(this.isBinary, this.bytes, this.length); + + final bool isBinary; + final Uint8List bytes; + final int length; } -class _FieldDescription { - String fieldName; - int tableID; - int columnID; - int typeID; - int dataTypeSize; - int typeModifier; - int formatCode; - - int parse(ByteData byteData, int initialOffset) { - var offset = initialOffset; - var buf = new StringBuffer(); +class FieldDescription implements ColumnDescription { + final Converter converter; + + @override + final String columnName; + final int tableID; + final int columnID; + final int typeID; + final int dataTypeSize; + final int typeModifier; + final int formatCode; + + @override + final String tableName; + + FieldDescription._( + this.converter, + this.columnName, + this.tableID, + this.columnID, + this.typeID, + this.dataTypeSize, + this.typeModifier, + this.formatCode, + this.tableName, + ); + + factory FieldDescription.read(ByteDataReader reader) { + final buf = StringBuffer(); var byte = 0; do { - byte = byteData.getUint8(offset); offset += 1; + byte = reader.readUint8(); if (byte != 0) { buf.writeCharCode(byte); } } while (byte != 0); - fieldName = buf.toString(); - - tableID = byteData.getUint32(offset); offset += 4; - columnID = byteData.getUint16(offset); offset += 2; - typeID = byteData.getUint32(offset); offset += 4; - dataTypeSize = byteData.getUint16(offset); offset += 2; - typeModifier = byteData.getInt32(offset); offset += 4; - formatCode = byteData.getUint16(offset); offset += 2; + final fieldName = buf.toString(); + + final tableID = reader.readUint32(); + final columnID = reader.readUint16(); + final typeID = reader.readUint32(); + final dataTypeSize = reader.readUint16(); + final typeModifier = reader.readInt32(); + final formatCode = reader.readUint16(); + + final converter = PostgresBinaryDecoder(typeID); + return FieldDescription._( + converter, fieldName, tableID, columnID, typeID, + dataTypeSize, typeModifier, formatCode, + null, // tableName + ); + } - return offset; + FieldDescription change({String tableName}) { + return FieldDescription._(converter, columnName, tableID, columnID, typeID, + dataTypeSize, typeModifier, formatCode, tableName ?? this.tableName); } + @override String toString() { - return "$fieldName $tableID $columnID $typeID $dataTypeSize $typeModifier $formatCode"; + return '$columnName $tableID $columnID $typeID $dataTypeSize $typeModifier $formatCode'; } } +typedef SQLReplaceIdentifierFunction = String Function( + PostgreSQLFormatIdentifier identifier, int index); + +enum PostgreSQLFormatTokenType { text, variable } + +class PostgreSQLFormatToken { + PostgreSQLFormatToken(this.type); + + PostgreSQLFormatTokenType type; + StringBuffer buffer = StringBuffer(); +} + +class PostgreSQLFormatIdentifier { + static Map typeStringToCodeMap = { + 'text': PostgreSQLDataType.text, + 'int2': PostgreSQLDataType.smallInteger, + 'int4': PostgreSQLDataType.integer, + 'int8': PostgreSQLDataType.bigInteger, + 'float4': PostgreSQLDataType.real, + 'float8': PostgreSQLDataType.double, + 'boolean': PostgreSQLDataType.boolean, + 'date': PostgreSQLDataType.date, + 'timestamp': PostgreSQLDataType.timestampWithoutTimezone, + 'timestamptz': PostgreSQLDataType.timestampWithTimezone, + 'jsonb': PostgreSQLDataType.json, + 'bytea': PostgreSQLDataType.byteArray, + 'name': PostgreSQLDataType.name, + 'uuid': PostgreSQLDataType.uuid + }; + + factory PostgreSQLFormatIdentifier(String t) { + String name; + PostgreSQLDataType type; + String typeCast; + + final components = t.split('::'); + if (components.length > 1) { + typeCast = components.sublist(1).join(''); + } + + final variableComponents = components.first.split(':'); + if (variableComponents.length == 1) { + name = variableComponents.first; + } else if (variableComponents.length == 2) { + name = variableComponents.first; + + final dataTypeString = variableComponents.last; + if (dataTypeString != null) { + type = typeStringToCodeMap[dataTypeString]; + if (type == null) { + throw FormatException( + "Invalid type code in substitution variable '$t'"); + } + } + } else { + throw FormatException( + "Invalid format string identifier, must contain identifier name and optionally one data type in format '@identifier:dataType' (offending identifier: $t)"); + } + + // Strip @ + name = name.substring(1, name.length); + return PostgreSQLFormatIdentifier._(name, type, typeCast); + } + + PostgreSQLFormatIdentifier._(this.name, this.type, this.typeCast); + + final String name; + final PostgreSQLDataType type; + final String typeCast; +} diff --git a/lib/src/query_cache.dart b/lib/src/query_cache.dart new file mode 100644 index 0000000..2acf5d9 --- /dev/null +++ b/lib/src/query_cache.dart @@ -0,0 +1,40 @@ +import 'query.dart'; + +class QueryCache { + final Map _queries = {}; + int _idCounter = 0; + + int get length => _queries.length; + bool get isEmpty => _queries.isEmpty; + + void add(Query query) { + if (query.cache == null) { + return; + } + + if (query.cache.isValid) { + _queries[query.statement] = query.cache; + } + } + + CachedQuery operator [](String statementId) { + if (statementId == null) { + return null; + } + + return _queries[statementId]; + } + + String identifierForQuery(Query query) { + final existing = _queries[query.statement]; + if (existing != null) { + return existing.preparedStatementName; + } + + final string = '$_idCounter'.padLeft(12, '0'); + + _idCounter++; + + return string; + } +} diff --git a/lib/src/query_queue.dart b/lib/src/query_queue.dart new file mode 100644 index 0000000..29514cb --- /dev/null +++ b/lib/src/query_queue.dart @@ -0,0 +1,73 @@ +import 'dart:async'; +import 'dart:collection'; + +import '../postgres.dart'; + +import 'query.dart'; + +class QueryQueue extends ListBase> + implements List> { + List> _inner = >[]; + bool _isCancelled = false; + + PostgreSQLException get _cancellationException => PostgreSQLException( + 'Query cancelled due to the database connection closing.'); + + Query get pending { + if (_inner.isEmpty) { + return null; + } + return _inner.first; + } + + void cancel([dynamic error, StackTrace stackTrace]) { + _isCancelled = true; + error ??= _cancellationException; + final existing = _inner; + _inner = >[]; + + // We need to jump this to the next event so that the queries + // get the error and not the close message, since completeError is + // synchronous. + scheduleMicrotask(() { + existing?.forEach((q) { + q.completeError(error, stackTrace); + }); + }); + } + + @override + set length(int newLength) { + _inner.length = newLength; + } + + @override + Query operator [](int index) => _inner[index]; + + @override + int get length => _inner.length; + + @override + void operator []=(int index, Query value) => _inner[index] = value; + + void addEvenIfCancelled(Query element) { + _inner.add(element); + } + + @override + bool add(Query element) { + if (_isCancelled) { + element.future.catchError((_) {}); + element.completeError(_cancellationException); + return false; + } + + _inner.add(element); + return true; + } + + @override + void addAll(Iterable iterable) { + _inner.addAll(iterable); + } +} diff --git a/lib/src/server_messages.dart b/lib/src/server_messages.dart index c785004..4ee1f9a 100644 --- a/lib/src/server_messages.dart +++ b/lib/src/server_messages.dart @@ -1,32 +1,42 @@ -part of postgres; +import 'dart:convert'; +import 'dart:typed_data'; -abstract class _ServerMessage { - void readBytes(Uint8List bytes); -} +import 'package:buffer/buffer.dart'; -class _ErrorResponseMessage implements _ServerMessage { - PostgreSQLException generatedException; - List<_ErrorField> fields = [new _ErrorField()]; +import 'connection.dart'; +import 'query.dart'; - void readBytes(Uint8List bytes) { - var lastByteRemovedList = new Uint8List.view(bytes.buffer, bytes.offsetInBytes, bytes.length - 1); +abstract class ServerMessage {} - lastByteRemovedList.forEach((byte) { - if (byte != 0) { - fields.last.add(byte); - return; - } +class ErrorResponseMessage implements ServerMessage { + final fields = []; - fields.add(new _ErrorField()); - }); + ErrorResponseMessage(Uint8List bytes) { + final reader = ByteDataReader()..add(bytes); - generatedException = new PostgreSQLException._(fields); - } + int identificationToken; + StringBuffer sb; - String toString() => generatedException.toString(); + while (reader.remainingLength > 0) { + final byte = reader.readUint8(); + if (identificationToken == null) { + identificationToken = byte; + sb = StringBuffer(); + } else if (byte == 0) { + fields.add(ErrorField(identificationToken, sb.toString())); + identificationToken = null; + sb = null; + } else { + sb.writeCharCode(byte); + } + } + if (identificationToken != null && sb != null) { + fields.add(ErrorField(identificationToken, sb.toString())); + } + } } -class _AuthenticationMessage implements _ServerMessage { +class AuthenticationMessage implements ServerMessage { static const int KindOK = 0; static const int KindKerberosV5 = 2; static const int KindClearTextPassword = 3; @@ -36,172 +46,244 @@ class _AuthenticationMessage implements _ServerMessage { static const int KindGSSContinue = 8; static const int KindSSPI = 9; - int type; + final int type; + final List salt; - List salt; - - void readBytes(Uint8List bytes) { - var view = new ByteData.view(bytes.buffer, bytes.offsetInBytes); - type = view.getUint32(0); + AuthenticationMessage._(this.type, this.salt); + factory AuthenticationMessage(Uint8List bytes) { + final reader = ByteDataReader()..add(bytes); + final type = reader.readUint32(); + List salt; if (type == KindMD5Password) { - salt = new List(4); - for (var i = 0; i < 4; i++) { - salt[i] = view.getUint8(4 + i); - } + salt = reader.read(4, copy: true); } + return AuthenticationMessage._(type, salt); } - - String toString() => "Authentication: $type"; } -class _ParameterStatusMessage extends _ServerMessage { - String name; - String value; +class ParameterStatusMessage extends ServerMessage { + final String name; + final String value; - void readBytes(Uint8List bytes) { - name = new String.fromCharCodes(bytes.sublist(0, bytes.indexOf(0))); - value = new String.fromCharCodes(bytes.sublist(bytes.indexOf(0) + 1, bytes.lastIndexOf(0))); - } + ParameterStatusMessage._(this.name, this.value); - String toString() => "Parameter Message: $name $value"; + factory ParameterStatusMessage(Uint8List bytes) { + final first0 = bytes.indexOf(0); + final name = utf8.decode(bytes.sublist(0, first0)); + final value = utf8.decode(bytes.sublist(first0 + 1, bytes.lastIndexOf(0))); + return ParameterStatusMessage._(name, value); + } } -class _ReadyForQueryMessage extends _ServerMessage { - static const String StateIdle = "I"; - static const String StateTransaction = "T"; - static const String StateTransactionError = "E"; +class ReadyForQueryMessage extends ServerMessage { + static const String StateIdle = 'I'; + static const String StateTransaction = 'T'; + static const String StateTransactionError = 'E'; - String state; - - void readBytes(Uint8List bytes) { - state = new String.fromCharCodes(bytes); - } + final String state; - String toString() => "Ready Message: $state"; + ReadyForQueryMessage(Uint8List bytes) : state = utf8.decode(bytes); } -class _BackendKeyMessage extends _ServerMessage { - int processID; - int secretKey; +class BackendKeyMessage extends ServerMessage { + final int processID; + final int secretKey; - void readBytes(Uint8List bytes) { - var view = new ByteData.view(bytes.buffer, bytes.offsetInBytes); - processID = view.getUint32(0); - secretKey = view.getUint32(4); - } + BackendKeyMessage._(this.processID, this.secretKey); - String toString() => "Backend Key Message: $processID $secretKey"; + factory BackendKeyMessage(Uint8List bytes) { + final view = ByteData.view(bytes.buffer, bytes.offsetInBytes); + final processID = view.getUint32(0); + final secretKey = view.getUint32(4); + return BackendKeyMessage._(processID, secretKey); + } } -class _RowDescriptionMessage extends _ServerMessage { - List<_FieldDescription> fieldDescriptions; +class RowDescriptionMessage extends ServerMessage { + final fieldDescriptions = []; - void readBytes(Uint8List bytes) { - var view = new ByteData.view(bytes.buffer, bytes.offsetInBytes); - var offset = 0; - var fieldCount = view.getInt16(offset); offset += 2; + RowDescriptionMessage(Uint8List bytes) { + final reader = ByteDataReader()..add(bytes); + final fieldCount = reader.readInt16(); - fieldDescriptions = <_FieldDescription>[]; for (var i = 0; i < fieldCount; i++) { - var rowDesc = new _FieldDescription(); - offset = rowDesc.parse(view, offset); + final rowDesc = FieldDescription.read(reader); fieldDescriptions.add(rowDesc); } } - - String toString() => "RowDescription Message: $fieldDescriptions"; } -class _DataRowMessage extends _ServerMessage { - List values = []; +class DataRowMessage extends ServerMessage { + final values = []; - void readBytes(Uint8List bytes) { - var view = new ByteData.view(bytes.buffer, bytes.offsetInBytes); - var offset = 0; - var fieldCount = view.getInt16(offset); offset += 2; + DataRowMessage(Uint8List bytes) { + final reader = ByteDataReader()..add(bytes); + final fieldCount = reader.readInt16(); for (var i = 0; i < fieldCount; i++) { - var dataSize = view.getInt32(offset); offset += 4; + final dataSize = reader.readInt32(); if (dataSize == 0) { - values.add(new ByteData(0)); + values.add(Uint8List(0)); } else if (dataSize == -1) { values.add(null); } else { - var rawBytes = new ByteData.view(bytes.buffer, bytes.offsetInBytes + offset, dataSize); + final rawBytes = reader.read(dataSize); values.add(rawBytes); - offset += dataSize; } } } - String toString() => "Data Row Message: ${values}"; + @override + String toString() => 'Data Row Message: $values'; } -class _CommandCompleteMessage extends _ServerMessage { - int rowsAffected; +class NotificationResponseMessage extends ServerMessage { + final int processID; + final String channel; + final String payload; + + NotificationResponseMessage._(this.processID, this.channel, this.payload); + + factory NotificationResponseMessage(Uint8List bytes) { + final view = ByteData.view(bytes.buffer, bytes.offsetInBytes); + final processID = view.getUint32(0); + final first0 = bytes.indexOf(0, 4); + final channel = utf8.decode(bytes.sublist(4, first0)); + final payload = + utf8.decode(bytes.sublist(first0 + 1, bytes.lastIndexOf(0))); + return NotificationResponseMessage._(processID, channel, payload); + } +} + +class CommandCompleteMessage extends ServerMessage { + final int rowsAffected; + + static RegExp identifierExpression = RegExp(r'[A-Z ]*'); - static RegExp identifierExpression = new RegExp(r"[A-Z ]*"); - void readBytes(Uint8List bytes) { - var str = new String.fromCharCodes(bytes.sublist(0, bytes.length - 1)); + CommandCompleteMessage._(this.rowsAffected); - var match = identifierExpression.firstMatch(str); + factory CommandCompleteMessage(Uint8List bytes) { + final str = utf8.decode(bytes.sublist(0, bytes.length - 1)); + final match = identifierExpression.firstMatch(str); + var rowsAffected = 0; if (match.end < str.length) { - rowsAffected = int.parse(str.split(" ").last); - } else { - rowsAffected = 0; + rowsAffected = int.parse(str.split(' ').last); } + return CommandCompleteMessage._(rowsAffected); } - - String toString() => "Command Complete Message: $rowsAffected"; } -class _ParseCompleteMessage extends _ServerMessage { - void readBytes(Uint8List bytes) {} +class ParseCompleteMessage extends ServerMessage { + ParseCompleteMessage(); - String toString() => "Parse Complete Message"; + @override + String toString() => 'Parse Complete Message'; } -class _BindCompleteMessage extends _ServerMessage { - void readBytes(Uint8List bytes) {} - String toString() => "Bind Complete Message"; -} +class BindCompleteMessage extends ServerMessage { + BindCompleteMessage(); -class _ParameterDescriptionMessage extends _ServerMessage { - List parameterTypeIDs; + @override + String toString() => 'Bind Complete Message'; +} - void readBytes(Uint8List bytes) { - var view = new ByteData.view(bytes.buffer, bytes.offsetInBytes); +class ParameterDescriptionMessage extends ServerMessage { + final parameterTypeIDs = []; - var offset = 0; - var count = view.getUint16(0); offset += 2; + ParameterDescriptionMessage(Uint8List bytes) { + final reader = ByteDataReader()..add(bytes); + final count = reader.readUint16(); - parameterTypeIDs = []; for (var i = 0; i < count; i++) { - var v = view.getUint32(offset); offset += 4; - parameterTypeIDs.add(v); + parameterTypeIDs.add(reader.readUint32()); } } +} + +class NoDataMessage extends ServerMessage { + NoDataMessage(); - String toString() => "Parameter Description Message: $parameterTypeIDs"; + @override + String toString() => 'No Data Message'; } -class _NoDataMessage extends _ServerMessage { - void readBytes(Uint8List bytes) { +class UnknownMessage extends ServerMessage { + final int code; + final Uint8List bytes; + UnknownMessage(this.code, this.bytes); + + @override + int get hashCode { + return bytes.hashCode; } - String toString() => "No Data Message"; + @override + bool operator ==(dynamic other) { + if (bytes != null) { + if (bytes.length != other.bytes.length) { + return false; + } + for (var i = 0; i < bytes.length; i++) { + if (bytes[i] != other.bytes[i]) { + return false; + } + } + } else { + if (other.bytes != null) { + return false; + } + } + return code == other.code; + } } -class _UnknownMessage extends _ServerMessage { - Uint8List bytes; - int code; +class ErrorField { + static const int SeverityIdentifier = 83; + static const int CodeIdentifier = 67; + static const int MessageIdentifier = 77; + static const int DetailIdentifier = 68; + static const int HintIdentifier = 72; + static const int PositionIdentifier = 80; + static const int InternalPositionIdentifier = 112; + static const int InternalQueryIdentifier = 113; + static const int WhereIdentifier = 87; + static const int SchemaIdentifier = 115; + static const int TableIdentifier = 116; + static const int ColumnIdentifier = 99; + static const int DataTypeIdentifier = 100; + static const int ConstraintIdentifier = 110; + static const int FileIdentifier = 70; + static const int LineIdentifier = 76; + static const int RoutineIdentifier = 82; + + static PostgreSQLSeverity severityFromString(String str) { + switch (str) { + case 'ERROR': + return PostgreSQLSeverity.error; + case 'FATAL': + return PostgreSQLSeverity.fatal; + case 'PANIC': + return PostgreSQLSeverity.panic; + case 'WARNING': + return PostgreSQLSeverity.warning; + case 'NOTICE': + return PostgreSQLSeverity.notice; + case 'DEBUG': + return PostgreSQLSeverity.debug; + case 'INFO': + return PostgreSQLSeverity.info; + case 'LOG': + return PostgreSQLSeverity.log; + } - void readBytes(Uint8List bytes) { - this.bytes = bytes; + return PostgreSQLSeverity.unknown; } - String toString() => "Unknown message: $code $bytes"; + final int identificationToken; + final String text; + + ErrorField(this.identificationToken, this.text); } diff --git a/lib/src/substituter.dart b/lib/src/substituter.dart index fc02376..0d7f647 100644 --- a/lib/src/substituter.dart +++ b/lib/src/substituter.dart @@ -1,174 +1,153 @@ -part of postgres; - -typedef String SQLReplaceIdentifierFunction(_PostgreSQLFormatIdentifier identifier, int index); +import 'query.dart'; +import 'text_codec.dart'; +import 'types.dart'; class PostgreSQLFormat { - static int _AtSignCodeUnit = "@".codeUnitAt(0); - static Map _typeStringToCodeMap = { - "text" : PostgreSQLCodec.TypeText, - "int2" : PostgreSQLCodec.TypeInt2, - "int4" : PostgreSQLCodec.TypeInt4, - "int8" : PostgreSQLCodec.TypeInt8, - "float4" : PostgreSQLCodec.TypeFloat4, - "float8" : PostgreSQLCodec.TypeFloat8, - "boolean" : PostgreSQLCodec.TypeBool, - "date" : PostgreSQLCodec.TypeDate, - "timestamp" : PostgreSQLCodec.TypeTimestamp, - "timestamptz" : PostgreSQLCodec.TypeTimestampTZ - }; + static final int _atSignCodeUnit = '@'.codeUnitAt(0); - static String id(String name, {PostgreSQLDataType type: null}) { + static String id(String name, {PostgreSQLDataType type}) { if (type != null) { - return "@$name:${dataTypeStringForDataType(type)}"; + return '@$name:${dataTypeStringForDataType(type)}'; } - return "@$name"; + return '@$name'; } static String dataTypeStringForDataType(PostgreSQLDataType dt) { switch (dt) { - case PostgreSQLDataType.text: return "text"; - case PostgreSQLDataType.integer: return "int4"; - case PostgreSQLDataType.smallInteger: return "int2"; - case PostgreSQLDataType.bigInteger: return "int8"; - case PostgreSQLDataType.serial: return "int4"; - case PostgreSQLDataType.bigSerial: return "int8"; - case PostgreSQLDataType.real: return "float4"; - case PostgreSQLDataType.double: return "float8"; - case PostgreSQLDataType.boolean: return "boolean"; - case PostgreSQLDataType.timestampWithoutTimezone: return "timestamp"; - case PostgreSQLDataType.timestampWithTimezone: return "timestamptz"; - case PostgreSQLDataType.date: return "date"; + case PostgreSQLDataType.text: + return 'text'; + case PostgreSQLDataType.integer: + return 'int4'; + case PostgreSQLDataType.smallInteger: + return 'int2'; + case PostgreSQLDataType.bigInteger: + return 'int8'; + case PostgreSQLDataType.serial: + return 'int4'; + case PostgreSQLDataType.bigSerial: + return 'int8'; + case PostgreSQLDataType.real: + return 'float4'; + case PostgreSQLDataType.double: + return 'float8'; + case PostgreSQLDataType.boolean: + return 'boolean'; + case PostgreSQLDataType.timestampWithoutTimezone: + return 'timestamp'; + case PostgreSQLDataType.timestampWithTimezone: + return 'timestamptz'; + case PostgreSQLDataType.date: + return 'date'; + case PostgreSQLDataType.json: + return 'jsonb'; + case PostgreSQLDataType.byteArray: + return 'bytea'; + case PostgreSQLDataType.name: + return 'name'; + case PostgreSQLDataType.uuid: + return 'uuid'; } return null; } - static int _postgresCodeForDataTypeString(String dt) { - return _typeStringToCodeMap[dt]; - } - - static String substitute(String fmtString, Map values, {SQLReplaceIdentifierFunction replace: null}) { - values ??= {}; - replace ??= (spec, index) => PostgreSQLCodec.encode(values[spec.name]); - - var items = <_PostgreSQLFormatToken>[]; - _PostgreSQLFormatToken lastPtr = null; - var iterator = new RuneIterator(fmtString); - - iterator.moveNext(); - while (iterator.current != null) { - if (lastPtr == null) { - if (iterator.current == _AtSignCodeUnit) { - lastPtr = new _PostgreSQLFormatToken(_PostgreSQLFormatTokenType.marker); - lastPtr.buffer.writeCharCode(iterator.current); - items.add(lastPtr); + static String substitute(String fmtString, Map values, + {SQLReplaceIdentifierFunction replace}) { + final converter = PostgresTextEncoder(); + values ??= {}; + replace ??= (spec, index) => converter.convert(values[spec.name]); + + final items = []; + PostgreSQLFormatToken currentPtr; + final iterator = RuneIterator(fmtString); + + while (iterator.moveNext()) { + if (currentPtr == null) { + if (iterator.current == _atSignCodeUnit) { + currentPtr = + PostgreSQLFormatToken(PostgreSQLFormatTokenType.variable); + currentPtr.buffer.writeCharCode(iterator.current); + items.add(currentPtr); } else { - lastPtr = new _PostgreSQLFormatToken(_PostgreSQLFormatTokenType.text); - lastPtr.buffer.writeCharCode(iterator.current); - items.add(lastPtr); + currentPtr = PostgreSQLFormatToken(PostgreSQLFormatTokenType.text); + currentPtr.buffer.writeCharCode(iterator.current); + items.add(currentPtr); } - } else if (lastPtr.type == _PostgreSQLFormatTokenType.text) { - if (iterator.current == _AtSignCodeUnit) { - lastPtr = new _PostgreSQLFormatToken(_PostgreSQLFormatTokenType.marker); - lastPtr.buffer.writeCharCode(iterator.current); - items.add(lastPtr); + } else if (currentPtr.type == PostgreSQLFormatTokenType.text) { + if (iterator.current == _atSignCodeUnit) { + currentPtr = + PostgreSQLFormatToken(PostgreSQLFormatTokenType.variable); + currentPtr.buffer.writeCharCode(iterator.current); + items.add(currentPtr); } else { - lastPtr.buffer.writeCharCode(iterator.current); + currentPtr.buffer.writeCharCode(iterator.current); } - } else if (lastPtr.type == _PostgreSQLFormatTokenType.marker) { - if (iterator.current == _AtSignCodeUnit) { + } else if (currentPtr.type == PostgreSQLFormatTokenType.variable) { + if (iterator.current == _atSignCodeUnit) { iterator.movePrevious(); - if (iterator.current == _AtSignCodeUnit) { - lastPtr.buffer.writeCharCode(iterator.current); - lastPtr.type = _PostgreSQLFormatTokenType.text; + if (iterator.current == _atSignCodeUnit) { + currentPtr.buffer.writeCharCode(iterator.current); + currentPtr.type = PostgreSQLFormatTokenType.text; } else { - lastPtr = new _PostgreSQLFormatToken(_PostgreSQLFormatTokenType.marker); - lastPtr.buffer.writeCharCode(iterator.current); - items.add(lastPtr); + currentPtr = + PostgreSQLFormatToken(PostgreSQLFormatTokenType.variable); + currentPtr.buffer.writeCharCode(iterator.current); + items.add(currentPtr); } iterator.moveNext(); - } else if (_isIdentifier(iterator.current)) { - lastPtr.buffer.writeCharCode(iterator.current); + currentPtr.buffer.writeCharCode(iterator.current); } else { - lastPtr = new _PostgreSQLFormatToken(_PostgreSQLFormatTokenType.text); - lastPtr.buffer.writeCharCode(iterator.current); - items.add(lastPtr); + currentPtr = PostgreSQLFormatToken(PostgreSQLFormatTokenType.text); + currentPtr.buffer.writeCharCode(iterator.current); + items.add(currentPtr); } } - - iterator.moveNext(); } var idx = 1; return items.map((t) { - if (t.type == _PostgreSQLFormatTokenType.text) { + if (t.type == PostgreSQLFormatTokenType.text) { + return t.buffer; + } else if (t.buffer.length == 1 && t.buffer.toString() == '@') { return t.buffer; } else { - var identifier = new _PostgreSQLFormatIdentifier(t.buffer.toString()); + final identifier = PostgreSQLFormatIdentifier(t.buffer.toString()); if (!values.containsKey(identifier.name)) { - throw new FormatException("Format string specified identifier with name ${identifier.name}, but key was not present in values. Format string: $fmtString"); + // Format string specified identifier with name ${identifier.name}, + // but key was not present in values. + return t.buffer; + } + + final val = replace(identifier, idx); + idx++; + + if (identifier.typeCast != null) { + return '$val::${identifier.typeCast}'; } - var val = replace(identifier, idx); - idx ++; return val; } - }).join(""); + }).join(''); } - static int _lowercaseACodeUnit = "a".codeUnitAt(0); - static int _uppercaseACodeUnit = "A".codeUnitAt(0); - static int _lowercaseZCodeUnit = "z".codeUnitAt(0); - static int _uppercaseZCodeUnit = "Z".codeUnitAt(0); - static int _0CodeUnit = "0".codeUnitAt(0); - static int _9CodeUnit= "9".codeUnitAt(0); - static int _underscoreCodeUnit= "_".codeUnitAt(0); - static int _ColonCodeUnit = ":".codeUnitAt(0); + static final int _lowercaseACodeUnit = 'a'.codeUnitAt(0); + static final int _uppercaseACodeUnit = 'A'.codeUnitAt(0); + static final int _lowercaseZCodeUnit = 'z'.codeUnitAt(0); + static final int _uppercaseZCodeUnit = 'Z'.codeUnitAt(0); + static final int _codeUnit0 = '0'.codeUnitAt(0); + static final int _codeUnit9 = '9'.codeUnitAt(0); + static final int _underscoreCodeUnit = '_'.codeUnitAt(0); + static final int _colonCodeUnit = ':'.codeUnitAt(0); static bool _isIdentifier(int charCode) { - return (charCode >= _lowercaseACodeUnit && charCode <= _lowercaseZCodeUnit) - || (charCode >= _uppercaseACodeUnit && charCode <= _uppercaseZCodeUnit) - || (charCode >= _0CodeUnit && charCode <= _9CodeUnit) - || (charCode == _underscoreCodeUnit) - || (charCode == _ColonCodeUnit); + return (charCode >= _lowercaseACodeUnit && + charCode <= _lowercaseZCodeUnit) || + (charCode >= _uppercaseACodeUnit && charCode <= _uppercaseZCodeUnit) || + (charCode >= _codeUnit0 && charCode <= _codeUnit9) || + (charCode == _underscoreCodeUnit) || + (charCode == _colonCodeUnit); } } - - -enum _PostgreSQLFormatTokenType { - text, marker -} - -class _PostgreSQLFormatToken { - _PostgreSQLFormatToken(this.type); - - _PostgreSQLFormatTokenType type; - StringBuffer buffer = new StringBuffer(); -} - -class _PostgreSQLFormatIdentifier { - _PostgreSQLFormatIdentifier(String t) { - var components = t.split(":"); - if (components.length == 1) { - name = components.first; - } else if (components.length == 2) { - name = components.first; - - var dataTypeString = components.last; - if (dataTypeString != null) { - typeCode = PostgreSQLFormat._postgresCodeForDataTypeString(dataTypeString); - } - } else { - throw new FormatException("Invalid format string identifier, must contain identifier name and optionally one data type in format '@identifier:dataType' (offending identifier: ${t})"); - } - - // Strip @ - name = name.substring(1, name.length); - } - - String name; - int typeCode; -} \ No newline at end of file diff --git a/lib/src/text_codec.dart b/lib/src/text_codec.dart new file mode 100644 index 0000000..852f986 --- /dev/null +++ b/lib/src/text_codec.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart'; + +class PostgresTextEncoder { + String convert(dynamic value, {bool escapeStrings = true}) { + if (value == null) { + return 'null'; + } + + if (value is int) { + return _encodeNumber(value); + } + + if (value is double) { + return _encodeDouble(value); + } + + if (value is String) { + return _encodeString(value, escapeStrings); + } + + if (value is DateTime) { + return _encodeDateTime(value, isDateOnly: false); + } + + if (value is bool) { + return _encodeBoolean(value); + } + + if (value is Map) { + return _encodeJSON(value); + } + + // TODO: use custom type encoders + + throw PostgreSQLException("Could not infer type of value '$value'."); + } + + String _encodeString(String text, bool escapeStrings) { + if (!escapeStrings) { + return text; + } + + final backslashCodeUnit = r'\'.codeUnitAt(0); + final quoteCodeUnit = r"'".codeUnitAt(0); + + var quoteCount = 0; + var backslashCount = 0; + final it = RuneIterator(text); + while (it.moveNext()) { + if (it.current == backslashCodeUnit) { + backslashCount++; + } else if (it.current == quoteCodeUnit) { + quoteCount++; + } + } + + final buf = StringBuffer(); + + if (backslashCount > 0) { + buf.write(' E'); + } + + buf.write("'"); + + if (quoteCount == 0 && backslashCount == 0) { + buf.write(text); + } else { + text.codeUnits.forEach((i) { + if (i == quoteCodeUnit || i == backslashCodeUnit) { + buf.writeCharCode(i); + buf.writeCharCode(i); + } else { + buf.writeCharCode(i); + } + }); + } + + buf.write("'"); + + return buf.toString(); + } + + String _encodeNumber(num value) { + if (value.isNaN) { + return "'nan'"; + } + + if (value.isInfinite) { + return value.isNegative ? "'-infinity'" : "'infinity'"; + } + + return value.toInt().toString(); + } + + String _encodeDouble(double value) { + if (value.isNaN) { + return "'nan'"; + } + + if (value.isInfinite) { + return value.isNegative ? "'-infinity'" : "'infinity'"; + } + + return value.toString(); + } + + String _encodeBoolean(bool value) { + return value ? 'TRUE' : 'FALSE'; + } + + String _encodeDateTime(DateTime value, {bool isDateOnly}) { + var string = value.toIso8601String(); + + if (isDateOnly) { + string = string.split('T').first; + } else { + if (!value.isUtc) { + final timezoneHourOffset = value.timeZoneOffset.inHours; + final timezoneMinuteOffset = value.timeZoneOffset.inMinutes % 60; + + var hourComponent = timezoneHourOffset.abs().toString().padLeft(2, '0'); + final minuteComponent = + timezoneMinuteOffset.abs().toString().padLeft(2, '0'); + + if (timezoneHourOffset >= 0) { + hourComponent = '+$hourComponent'; + } else { + hourComponent = '-$hourComponent'; + } + + final timezoneString = [hourComponent, minuteComponent].join(':'); + string = [string, timezoneString].join(''); + } + } + + if (string.substring(0, 1) == '-') { + string = '${string.substring(1)} BC'; + } else if (string.substring(0, 1) == '+') { + string = string.substring(1); + } + + return "'$string'"; + } + + String _encodeJSON(dynamic value) { + if (value == null) { + return 'null'; + } + + if (value is String) { + return "'${json.encode(value)}'"; + } + + return json.encode(value); + } +} diff --git a/lib/src/transaction_proxy.dart b/lib/src/transaction_proxy.dart index 2880fa3..67ea2b1 100644 --- a/lib/src/transaction_proxy.dart +++ b/lib/src/transaction_proxy.dart @@ -1,108 +1,124 @@ -part of postgres; +part of postgres.connection; + +typedef _TransactionQuerySignature = Future Function( + PostgreSQLExecutionContext connection); + +class _TransactionProxy extends Object + with _PostgreSQLExecutionContextMixin + implements PostgreSQLExecutionContext { + _TransactionProxy( + this._connection, this.executionBlock, this.commitTimeoutInSeconds) { + _beginQuery = Query('BEGIN', {}, _connection, this, + onlyReturnAffectedRowCount: true); + + _beginQuery.future.then(startTransaction).catchError((err, StackTrace st) { + Future(() { + _completer.completeError(err, st); + }); + }); + } -typedef Future _TransactionQuerySignature(PostgreSQLExecutionContext connection); + Query _beginQuery; + final _completer = Completer(); -class _TransactionProxy implements PostgreSQLExecutionContext { - _TransactionProxy(this.connection, this.executionBlock) { - beginQuery = new _Query("BEGIN", {}, connection, this) - ..onlyReturnAffectedRowCount = true; + Future get future => _completer.future; - beginQuery.onComplete.future - .then(startTransaction) - .catchError(handleTransactionQueryError); - } + @override + final PostgreSQLConnection _connection; - _Query beginQuery; - Completer completer = new Completer(); - Future get future => completer.future; + @override + PostgreSQLExecutionContext get _transaction => this; - _Query get pendingQuery { - if (queryQueue.length > 0) { - return queryQueue.first; - } + final _TransactionQuerySignature executionBlock; + final int commitTimeoutInSeconds; + bool _hasFailed = false; + bool _hasRolledBack = false; - return null; + @override + void cancelTransaction({String reason}) { + throw _TransactionRollbackException(reason); } - List<_Query> queryQueue = []; - PostgreSQLConnection connection; - _TransactionQuerySignature executionBlock; - Future commit() async { - await execute("COMMIT"); - } + Future startTransaction(dynamic _) async { + dynamic result; + try { + result = await executionBlock(this); - Future>> query(String fmtString, {Map substitutionValues: null, bool allowReuse: true}) async { - if (connection.isClosed) { - throw new PostgreSQLException("Attempting to execute query, but connection is not open."); - } + // Place another event in the queue so that any non-awaited futures + // in the executionBlock are given a chance to run + await Future(() => null); + } on _TransactionRollbackException catch (rollback) { + await _cancelAndRollback(rollback); - var query = new _Query>>(fmtString, substitutionValues, connection, this); + return; + } catch (e, st) { + await _transactionFailed(e, st); - if (allowReuse) { - query.statementIdentifier = connection._reuseIdentifierForQuery(query); + return; + } + + // If we have queries pending, we need to wait for them to complete + // before finishing !!!! + if (_queue.isNotEmpty) { + // ignore the error from this query if there is one, it'll pop up elsewhere + await _queue.last.future.catchError((_) {}); } - return await enqueue(query); + if (!_hasRolledBack && !_hasFailed) { + await execute('COMMIT', timeoutInSeconds: commitTimeoutInSeconds); + _completer.complete(result); + } } - Future execute(String fmtString, {Map substitutionValues: null}) async { - if (connection.isClosed) { - throw new PostgreSQLException("Attempting to execute query, but connection is not open."); + Future _cancelAndRollback(dynamic object, [StackTrace trace]) async { + if (_hasRolledBack) { + return; } - var query = new _Query(fmtString, substitutionValues, connection, this) - ..onlyReturnAffectedRowCount = true; + _hasRolledBack = true; + // We'll wrap each query in an error handler here to make sure the query cancellation error + // is only emitted from the transaction itself. + _queue.forEach((q) { + q.future.catchError((_) {}); + }); - return enqueue(query); - } + final err = PostgreSQLException('Query failed prior to execution. ' + "This query's transaction encountered an error earlier in the transaction " + 'that prevented this query from executing.'); + _queue.cancel(err); - void cancelTransaction({String reason: null}) { - throw new _TransactionRollbackException(reason); - } + final rollback = Query('ROLLBACK', {}, _connection, _transaction, + onlyReturnAffectedRowCount: true); + _queue.addEvenIfCancelled(rollback); - Future startTransaction(dynamic beginResults) async { - var result; - try { - result = await executionBlock(this); - } on _TransactionRollbackException catch (rollback) { - queryQueue = []; - await execute("ROLLBACK"); - completer.complete(new PostgreSQLRollback._(rollback.reason)); - return; - } catch (e) { - queryQueue = []; + _connection._transitionToState(_connection._connectionState.awake()); - await execute("ROLLBACK"); - completer.completeError(e); - return; + try { + await rollback.future.timeout(Duration(seconds: 30)); + } finally { + _queue.remove(rollback); } - await execute("COMMIT"); - - completer.complete(result); + if (object is _TransactionRollbackException) { + _completer.complete(PostgreSQLRollback._(object.reason)); + } else { + _completer.completeError(object, trace); + } } - Future handleTransactionQueryError(dynamic err) async { - - } + Future _transactionFailed(dynamic error, [StackTrace trace]) async { + if (_hasFailed) { + return; + } - Future enqueue(_Query query) async { - queryQueue.add(query); - connection._transitionToState(connection._connectionState.awake()); + _hasFailed = true; - var result = null; - try { - result = await query.future; - - connection._cacheQuery(query); - queryQueue.remove(query); - } catch (e) { - connection._cacheQuery(query); - queryQueue.remove(query); - rethrow; - } + await _cancelAndRollback(error, trace); + } - return result; + @override + Future _onQueryError(Query query, dynamic error, [StackTrace trace]) { + return _transactionFailed(error, trace); } } @@ -115,5 +131,8 @@ class PostgreSQLRollback { PostgreSQLRollback._(this.reason); /// The reason the transaction was cancelled. - String reason; -} \ No newline at end of file + final String reason; + + @override + String toString() => 'PostgreSQLRollback: $reason'; +} diff --git a/lib/src/types.dart b/lib/src/types.dart new file mode 100644 index 0000000..ee76281 --- /dev/null +++ b/lib/src/types.dart @@ -0,0 +1,68 @@ +/* + Adding a new type: + + 1. add item to this enumeration + 2. update all switch statements on this type + 3. add pg type code -> enumeration item in PostgresBinaryDecoder.typeMap (lookup type code: https://doxygen.postgresql.org/include_2catalog_2pg__type_8h_source.html) + 4. add identifying key to PostgreSQLFormatIdentifier.typeStringToCodeMap. + */ + +/// Supported data types. +enum PostgreSQLDataType { + /// Must be a [String]. + text, + + /// Must be an [int] (4-byte integer) + integer, + + /// Must be an [int] (2-byte integer) + smallInteger, + + /// Must be an [int] (8-byte integer) + bigInteger, + + /// Must be an [int] (autoincrementing 4-byte integer) + serial, + + /// Must be an [int] (autoincrementing 8-byte integer) + bigSerial, + + /// Must be a [double] (32-bit floating point value) + real, + + /// Must be a [double] (64-bit floating point value) + double, + + /// Must be a [bool] + boolean, + + /// Must be a [DateTime] (microsecond date and time precision) + timestampWithoutTimezone, + + /// Must be a [DateTime] (microsecond date and time precision) + timestampWithTimezone, + + /// Must be a [DateTime] (contains year, month and day only) + date, + + /// Must be encodable via [json.encode]. + /// + /// Values will be encoded via [json.encode] before being sent to the database. + json, + + /// Must be a [List] of [int]. + /// + /// Each element of the list must fit into a byte (0-255). + byteArray, + + /// Must be a [String] + /// + /// Used for internal pg structure names + name, + + /// Must be a [String]. + /// + /// Must contain 32 hexadecimal characters. May contain any number of '-' characters. + /// When returned from database, format will be xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. + uuid +} diff --git a/lib/src/utf8_backed_string.dart b/lib/src/utf8_backed_string.dart new file mode 100644 index 0000000..360344d --- /dev/null +++ b/lib/src/utf8_backed_string.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +class UTF8BackedString { + UTF8BackedString(this.string); + + List _cachedUTF8Bytes; + + bool get hasCachedBytes => _cachedUTF8Bytes != null; + + final String string; + + int get utf8Length { + _cachedUTF8Bytes ??= utf8.encode(string); + return _cachedUTF8Bytes.length; + } + + List get utf8Bytes { + _cachedUTF8Bytes ??= utf8.encode(string); + return _cachedUTF8Bytes; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index ad3e3a4..48ef18e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,16 @@ name: postgres -description: A library to connect and query PostgreSQL databases. -version: 0.9.1 -author: stable|kernel +description: PostgreSQL database driver. Supports statement reuse and binary protocol. +version: 2.2.0 homepage: https://github.com/stablekernel/postgresql-dart -documentation: environment: - sdk: '>=1.19.0 <2.0.0' + sdk: ">=2.8.0 <3.0.0" dependencies: - crypto: "^2.0.0" + buffer: ^1.0.6 + crypto: ^2.0.0 dev_dependencies: - test: '>=0.12.0 <0.13.0' + pedantic: ^1.0.0 + test: ^1.3.0 + coverage: any diff --git a/test/connection_test.dart b/test/connection_test.dart index 9e51274..d95b7d0 100644 --- a/test/connection_test.dart +++ b/test/connection_test.dart @@ -1,73 +1,158 @@ -import 'package:postgres/postgres.dart'; -import 'package:test/test.dart'; -import 'dart:io'; +// ignore_for_file: unawaited_futures + import 'dart:async'; +import 'dart:io'; import 'dart:mirrors'; +import 'package:test/test.dart'; + +import 'package:postgres/postgres.dart'; + void main() { - group("Connection lifecycle", () { - PostgreSQLConnection conn = null; + group('Connection lifecycle', () { + PostgreSQLConnection conn; tearDown(() async { await conn?.close(); }); - test("Connect with md5 auth required", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + test('Connect with md5 auth required', () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + + await conn.open(); + + expect(await conn.execute('select 1'), equals(1)); + }); + + test('SSL Connect with md5 auth required', () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart', useSSL: true); + + await conn.open(); + + expect(await conn.execute('select 1'), equals(1)); + final socketMirror = reflect(conn).type.declarations.values.firstWhere( + (DeclarationMirror dm) => + dm.simpleName.toString().contains('_socket')); + final underlyingSocket = + reflect(conn).getField(socketMirror.simpleName).reflectee; + expect(underlyingSocket is SecureSocket, true); + }); + + test('Connect with no auth required', () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); + await conn.open(); + + expect(await conn.execute('select 1'), equals(1)); + }); + test('SSL Connect with no auth required', () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust', useSSL: true); await conn.open(); - expect(await conn.execute("select 1"), equals(1)); + expect(await conn.execute('select 1'), equals(1)); }); - test("Connect with no auth required", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); + test('Closing idle connection succeeds, closes underlying socket', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); await conn.open(); - expect(await conn.execute("select 1"), equals(1)); + await conn.close(); + + final socketMirror = reflect(conn).type.declarations.values.firstWhere( + (DeclarationMirror dm) => + dm.simpleName.toString().contains('_socket')); + final underlyingSocket = + reflect(conn).getField(socketMirror.simpleName).reflectee as Socket; + expect(await underlyingSocket.done, isNotNull); + + conn = null; }); - test("Closing idle connection succeeds, closes underlying socket", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); + test('SSL Closing idle connection succeeds, closes underlying socket', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust', useSSL: true); await conn.open(); await conn.close(); - var socketMirror = reflect(conn).type.declarations.values.firstWhere((DeclarationMirror dm) => dm.simpleName.toString().contains("_socket")); - Socket underlyingSocket = reflect(conn).getField(socketMirror.simpleName).reflectee; + final socketMirror = reflect(conn).type.declarations.values.firstWhere( + (DeclarationMirror dm) => + dm.simpleName.toString().contains('_socket')); + final underlyingSocket = + reflect(conn).getField(socketMirror.simpleName).reflectee as Socket; expect(await underlyingSocket.done, isNotNull); conn = null; }); - test("Closing connection while busy succeeds, queued queries are all accounted for (canceled), closes underlying socket", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); + test( + 'Closing connection while busy succeeds, queued queries are all accounted for (canceled), closes underlying socket', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); await conn.open(); - var futures = [ - conn.query("select 1", allowReuse: false), - conn.query("select 2", allowReuse: false), - conn.query("select 3", allowReuse: false), - conn.query("select 4", allowReuse: false), - conn.query("select 5", allowReuse: false) + final errors = []; + final catcher = (e) { + errors.add(e); + return null; + }; + final futures = [ + conn.query('select 1', allowReuse: false).catchError(catcher), + conn.query('select 2', allowReuse: false).catchError(catcher), + conn.query('select 3', allowReuse: false).catchError(catcher), + conn.query('select 4', allowReuse: false).catchError(catcher), + conn.query('select 5', allowReuse: false).catchError(catcher), ]; await conn.close(); + await Future.wait(futures); + expect(errors.length, 5); + expect(errors.map((e) => e.message), + everyElement(contains('Query cancelled'))); + }); - try { - await Future.wait(futures); - expect(true, false); - } on PostgreSQLException catch (e) { - expect(e.message, contains("Connection closed")); - } + test( + 'SSL Closing connection while busy succeeds, queued queries are all accounted for (canceled), closes underlying socket', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust', useSSL: true); + await conn.open(); + + final errors = []; + final catcher = (e) { + errors.add(e); + return null; + }; + final futures = [ + conn.query('select 1', allowReuse: false).catchError(catcher), + conn.query('select 2', allowReuse: false).catchError(catcher), + conn.query('select 3', allowReuse: false).catchError(catcher), + conn.query('select 4', allowReuse: false).catchError(catcher), + conn.query('select 5', allowReuse: false).catchError(catcher), + ]; + + await conn.close(); + await Future.wait(futures); + expect(errors.length, 5); + expect(errors.map((e) => e.message), + everyElement(contains('Query cancelled'))); }); }); - group("Successful queries over time", () { - PostgreSQLConnection conn = null; + group('Successful queries over time', () { + PostgreSQLConnection conn; setUp(() async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); await conn.open(); }); @@ -75,246 +160,509 @@ void main() { await conn?.close(); }); - test("Issuing multiple queries and awaiting between each one successfully returns the right value", () async { - expect(await conn.query("select 1", allowReuse: false), equals([[1]])); - expect(await conn.query("select 2", allowReuse: false), equals([[2]])); - expect(await conn.query("select 3", allowReuse: false), equals([[3]])); - expect(await conn.query("select 4", allowReuse: false), equals([[4]])); - expect(await conn.query("select 5", allowReuse: false), equals([[5]])); + test( + 'Issuing multiple queries and awaiting between each one successfully returns the right value', + () async { + expect( + await conn.query('select 1', allowReuse: false), + equals([ + [1] + ])); + expect( + await conn.query('select 2', allowReuse: false), + equals([ + [2] + ])); + expect( + await conn.query('select 3', allowReuse: false), + equals([ + [3] + ])); + expect( + await conn.query('select 4', allowReuse: false), + equals([ + [4] + ])); + expect( + await conn.query('select 5', allowReuse: false), + equals([ + [5] + ])); }); - test("Issuing multiple queries without awaiting are returned with appropriate values", () async { - var futures = [ - conn.query("select 1", allowReuse: false), - conn.query("select 2", allowReuse: false), - conn.query("select 3", allowReuse: false), - conn.query("select 4", allowReuse: false), - conn.query("select 5", allowReuse: false) + test( + 'Issuing multiple queries without awaiting are returned with appropriate values', + () async { + final futures = [ + conn.query('select 1', allowReuse: false), + conn.query('select 2', allowReuse: false), + conn.query('select 3', allowReuse: false), + conn.query('select 4', allowReuse: false), + conn.query('select 5', allowReuse: false) ]; - var results = await Future.wait(futures); - - expect(results, [[[1]], [[2]], [[3]], [[4]], [[5]]]); + final results = await Future.wait(futures); + + expect(results, [ + [ + [1] + ], + [ + [2] + ], + [ + [3] + ], + [ + [4] + ], + [ + [5] + ] + ]); }); }); - group("Unintended user-error situations", () { - PostgreSQLConnection conn = null; + group('Unintended user-error situations', () { + PostgreSQLConnection conn; + Future openFuture; tearDown(() async { + await openFuture; await conn?.close(); }); - test("Sending queries to opening connection triggers error", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); - conn.open(); + test('Sending queries to opening connection triggers error', () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); + openFuture = conn.open(); try { - await conn.execute("select 1"); + await conn.execute('select 1'); expect(true, false); } on PostgreSQLException catch (e) { - expect(e.message, contains("connection is not open")); + expect(e.message, contains('connection is not open')); } }); - test("Starting transaction while opening connection triggers error", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); - conn.open(); + test('SSL Sending queries to opening connection triggers error', () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust', useSSL: true); + openFuture = conn.open(); + + try { + await conn.execute('select 1'); + expect(true, false); + } on PostgreSQLException catch (e) { + expect(e.message, contains('connection is not open')); + } + }); + + test('Starting transaction while opening connection triggers error', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); + openFuture = conn.open(); try { await conn.transaction((ctx) async { - await ctx.execute("select 1"); + await ctx.execute('select 1'); }); expect(true, false); } on PostgreSQLException catch (e) { - expect(e.message, contains("connection is not open")); + expect(e.message, contains('connection is not open')); } }); - test("Invalid password reports error, conn is closed, disables conn", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "notdart"); + test('SSL Starting transaction while opening connection triggers error', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust', useSSL: true); + openFuture = conn.open(); + + try { + await conn.transaction((ctx) async { + await ctx.execute('select 1'); + }); + expect(true, false); + } on PostgreSQLException catch (e) { + expect(e.message, contains('connection is not open')); + } + }); + + test('Invalid password reports error, conn is closed, disables conn', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'notdart'); + + try { + await conn.open(); + expect(true, false); + } on PostgreSQLException catch (e) { + expect(e.message, contains('password authentication failed')); + } + + await expectConnectionIsInvalid(conn); + }); + + test('SSL Invalid password reports error, conn is closed, disables conn', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'notdart', useSSL: true); try { await conn.open(); expect(true, false); } on PostgreSQLException catch (e) { - expect(e.message, contains("password authentication failed")); + expect(e.message, contains('password authentication failed')); } await expectConnectionIsInvalid(conn); }); - test("A query error maintains connectivity, allows future queries", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); + test('A query error maintains connectivity, allows future queries', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); await conn.open(); - await conn.execute("CREATE TEMPORARY TABLE t (i int unique)"); - await conn.execute("INSERT INTO t (i) VALUES (1)"); + await conn.execute('CREATE TEMPORARY TABLE t (i int unique)'); + await conn.execute('INSERT INTO t (i) VALUES (1)'); try { - await conn.execute("INSERT INTO t (i) VALUES (1)"); + await conn.execute('INSERT INTO t (i) VALUES (1)'); expect(true, false); } on PostgreSQLException catch (e) { - expect(e.message, contains("duplicate key value violates")); + expect(e.message, contains('duplicate key value violates')); } - await conn.execute("INSERT INTO t (i) VALUES (2)"); + await conn.execute('INSERT INTO t (i) VALUES (2)'); }); - test("A query error maintains connectivity, continues processing pending queries", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); + test( + 'A query error maintains connectivity, continues processing pending queries', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); await conn.open(); - await conn.execute("CREATE TEMPORARY TABLE t (i int unique)"); + await conn.execute('CREATE TEMPORARY TABLE t (i int unique)'); - await conn.execute("INSERT INTO t (i) VALUES (1)"); - conn.execute("INSERT INTO t (i) VALUES (1)").catchError((err) { + await conn.execute('INSERT INTO t (i) VALUES (1)'); + //ignore: unawaited_futures + conn.execute('INSERT INTO t (i) VALUES (1)').catchError((err) { // ignore }); - var futures = [ - conn.query("select 1", allowReuse: false), - conn.query("select 2", allowReuse: false), - conn.query("select 3", allowReuse: false), + final futures = [ + conn.query('select 1', allowReuse: false), + conn.query('select 2', allowReuse: false), + conn.query('select 3', allowReuse: false), ]; - var results = await Future.wait(futures); - - expect(results, [[[1]], [[2]], [[3]]]); - - var queueMirror = reflect(conn).type - .declarations.values - .firstWhere((DeclarationMirror dm) => dm.simpleName.toString().contains("_queryQueue")); - List queue = reflect(conn).getField(queueMirror.simpleName).reflectee; + final results = await Future.wait(futures); + + expect(results, [ + [ + [1] + ], + [ + [2] + ], + [ + [3] + ] + ]); + + final queueMirror = reflect(conn).type.instanceMembers.values.firstWhere( + (DeclarationMirror dm) => + dm.simpleName.toString().contains('_queue')); + final queue = + reflect(conn).getField(queueMirror.simpleName).reflectee as List; expect(queue, isEmpty); }); - test("A query error maintains connectivity, continues processing pending transactions", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); + test( + 'A query error maintains connectivity, continues processing pending transactions', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); await conn.open(); - await conn.execute("CREATE TEMPORARY TABLE t (i int unique)"); - await conn.execute("INSERT INTO t (i) VALUES (1)"); + await conn.execute('CREATE TEMPORARY TABLE t (i int unique)'); + await conn.execute('INSERT INTO t (i) VALUES (1)'); + + final orderEnsurer = []; - var orderEnsurer = []; - conn.execute("INSERT INTO t (i) VALUES (1)").catchError((err) { + // this will emit a query error + //ignore: unawaited_futures + conn.execute('INSERT INTO t (i) VALUES (1)').catchError((err) { orderEnsurer.add(1); // ignore }); orderEnsurer.add(2); - var res = await conn.transaction((ctx) async { + final res = await conn.transaction((ctx) async { orderEnsurer.add(3); - return await ctx.query("SELECT i FROM t"); + return await ctx.query('SELECT i FROM t'); }); orderEnsurer.add(4); - expect(res, [[1]]); + expect(res, [ + [1] + ]); expect(orderEnsurer, [2, 1, 3, 4]); }); - test("Building query throws error, connection continues processing pending queries", () async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "darttrust"); + test( + 'Building query throws error, connection continues processing pending queries', + () async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'darttrust'); await conn.open(); // Make some async queries that'll exit the event loop, but then fail on a query that'll die early - conn.execute("askdl").catchError((err, st) {}); - conn.execute("abdef").catchError((err, st) {}); - conn.execute("select @a").catchError((err, st) {}); + conn.execute('askdl').catchError((err, st) {}); + conn.execute('abdef').catchError((err, st) {}); + conn.execute('select @a').catchError((err, st) {}); - var futures = [ - conn.query("select 1", allowReuse: false), - conn.query("select 2", allowReuse: false), + final futures = [ + conn.query('select 1', allowReuse: false), + conn.query('select 2', allowReuse: false), ]; - var results = await Future.wait(futures); - - expect(results, [[[1]], [[2]]]); - - var queueMirror = reflect(conn).type - .declarations.values - .firstWhere((DeclarationMirror dm) => dm.simpleName.toString().contains("_queryQueue")); - List queue = reflect(conn).getField(queueMirror.simpleName).reflectee; + final results = await Future.wait(futures); + + expect(results, [ + [ + [1] + ], + [ + [2] + ] + ]); + + final queueMirror = reflect(conn).type.instanceMembers.values.firstWhere( + (DeclarationMirror dm) => + dm.simpleName.toString().contains('_queue')); + final queue = + reflect(conn).getField(queueMirror.simpleName).reflectee as List; expect(queue, isEmpty); }); - }); - group("Network error situations", () { - ServerSocket serverSocket = null; - Socket socket = null; + group('Network error situations', () { + ServerSocket serverSocket; + Socket socket; tearDown(() async { await serverSocket?.close(); await socket?.close(); }); - test("Socket fails to connect reports error, disables connection for future use", () async { - var conn = new PostgreSQLConnection("localhost", 5431, "dart_test"); + test( + 'Socket fails to connect reports error, disables connection for future use', + () async { + final conn = PostgreSQLConnection('localhost', 5431, 'dart_test'); try { await conn.open(); expect(true, false); - } on SocketException {} + } on SocketException { + // ignore + } await expectConnectionIsInvalid(conn); }); - test("Connection that times out throws appropriate error and cannot be reused", () async { - serverSocket = await ServerSocket.bind(InternetAddress.LOOPBACK_IP_V4, 5433); + test( + 'SSL Socket fails to connect reports error, disables connection for future use', + () async { + final conn = + PostgreSQLConnection('localhost', 5431, 'dart_test', useSSL: true); + + try { + await conn.open(); + expect(true, false); + } on SocketException { + // ignore + } + + await expectConnectionIsInvalid(conn); + }); + + test( + 'Connection that times out throws appropriate error and cannot be reused', + () async { + serverSocket = + await ServerSocket.bind(InternetAddress.loopbackIPv4, 5433); serverSocket.listen((s) { socket = s; // Don't respond on purpose s.listen((bytes) {}); }); - var conn = new PostgreSQLConnection("localhost", 5433, "dart_test", timeoutInSeconds: 2); + final conn = PostgreSQLConnection('localhost', 5433, 'dart_test', + timeoutInSeconds: 2); try { await conn.open(); - } on PostgreSQLException catch (e) { - expect(e.message, contains("Timed out trying to connect")); + fail('unreachable'); + } on TimeoutException { + // ignore } await expectConnectionIsInvalid(conn); }); - test("Connection that times out triggers future for pending queries", () async { - var openCompleter = new Completer(); - serverSocket = await ServerSocket.bind(InternetAddress.LOOPBACK_IP_V4, 5433); + test( + 'SSL Connection that times out throws appropriate error and cannot be reused', + () async { + serverSocket = + await ServerSocket.bind(InternetAddress.loopbackIPv4, 5433); serverSocket.listen((s) { socket = s; // Don't respond on purpose s.listen((bytes) {}); - new Future.delayed(new Duration(milliseconds: 100), () { - openCompleter.complete(); - }); }); - var conn = new PostgreSQLConnection("localhost", 5433, "dart_test", timeoutInSeconds: 2); + final conn = PostgreSQLConnection('localhost', 5433, 'dart_test', + timeoutInSeconds: 2, useSSL: true); + + try { + await conn.open(); + fail('unreachable'); + } on TimeoutException { + // ignore + } + + await expectConnectionIsInvalid(conn); + }); + + test('Connection that times out triggers future for pending queries', + () async { + final openCompleter = Completer(); + serverSocket = + await ServerSocket.bind(InternetAddress.loopbackIPv4, 5433); + serverSocket.listen((s) { + socket = s; + // Don't respond on purpose + s.listen((bytes) {}); + Future.delayed(Duration(milliseconds: 100), openCompleter.complete); + }); + + final conn = PostgreSQLConnection('localhost', 5433, 'dart_test', + timeoutInSeconds: 2); conn.open().catchError((e) {}); await openCompleter.future; try { - await conn.execute("select 1"); + await conn.execute('select 1'); expect(true, false); } on PostgreSQLException catch (e) { - expect(e.message, contains("closed or query cancelled")); + expect(e.message, contains('Failed to connect')); } }); + + test('SSL Connection that times out triggers future for pending queries', + () async { + final openCompleter = Completer(); + serverSocket = + await ServerSocket.bind(InternetAddress.loopbackIPv4, 5433); + serverSocket.listen((s) { + socket = s; + // Don't respond on purpose + s.listen((bytes) {}); + Future.delayed(Duration(milliseconds: 100), openCompleter.complete); + }); + + final conn = PostgreSQLConnection('localhost', 5433, 'dart_test', + timeoutInSeconds: 2, useSSL: true); + conn.open().catchError((e) { + return null; + }); + + await openCompleter.future; + + try { + await conn.execute('select 1'); + expect(true, false); + } on PostgreSQLException catch (e) { + expect(e.message, contains('but connection is not open')); + } + + try { + await conn.open(); + expect(true, false); + } on PostgreSQLException { + // ignore + } + }); + }); + + test('If connection is closed, do not allow .execute', () async { + final conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + try { + await conn.execute('SELECT 1'); + fail('unreachable'); + } on PostgreSQLException catch (e) { + expect(e.toString(), contains('connection is not open')); + } + }); + + test('If connection is closed, do not allow .query', () async { + final conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + try { + await conn.query('SELECT 1'); + fail('unreachable'); + } on PostgreSQLException catch (e) { + expect(e.toString(), contains('connection is not open')); + } + }); + + test('If connection is closed, do not allow .mappedResultsQuery', () async { + final conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + try { + await conn.mappedResultsQuery('SELECT 1'); + fail('unreachable'); + } on PostgreSQLException catch (e) { + expect(e.toString(), contains('connection is not open')); + } + }); + + test( + 'Queue size, should be 0 on open, >0 if queries added and 0 again after queries executed', + () async { + final conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + await conn.open(); + expect(conn.queueSize, 0); + + final futures = [ + conn.query('select 1', allowReuse: false), + conn.query('select 2', allowReuse: false), + conn.query('select 3', allowReuse: false) + ]; + expect(conn.queueSize, 3); + + await Future.wait(futures); + expect(conn.queueSize, 0); }); } Future expectConnectionIsInvalid(PostgreSQLConnection conn) async { try { - await conn.execute("select 1"); + await conn.execute('select 1'); expect(true, false); } on PostgreSQLException catch (e) { - expect(e.message, contains("connection is not open")); + expect(e.message, contains('connection is not open')); } try { await conn.open(); expect(true, false); } on PostgreSQLException catch (e) { - expect(e.message, contains("Attempting to reopen a closed connection")); + expect(e.message, contains('Attempting to reopen a closed connection')); } -} \ No newline at end of file +} diff --git a/test/decode_test.dart b/test/decode_test.dart index fa9778f..27096fb 100644 --- a/test/decode_test.dart +++ b/test/decode_test.dart @@ -4,77 +4,136 @@ import 'package:test/test.dart'; void main() { PostgreSQLConnection connection; setUp(() async { - connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await connection.open(); - await connection.execute("CREATE TEMPORARY TABLE t (i int, s serial, bi bigint, bs bigserial, bl boolean, si smallint, t text, f real, d double precision, dt date, ts timestamp, tsz timestamptz)"); - await connection.execute("INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) VALUES (-2147483648, -9223372036854775808, TRUE, -32768, 'string', 10.0, 10.0, '1983-11-06', '1983-11-06 06:00:00.000000', '1983-11-06 06:00:00.000000')"); - await connection.execute("INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) VALUES (2147483647, 9223372036854775807, FALSE, 32767, 'a significantly longer string to the point where i doubt this actually matters', 10.25, 10.125, '2183-11-06', '2183-11-06 00:00:00.111111', '2183-11-06 00:00:00.999999')"); + await connection.execute(''' + CREATE TEMPORARY TABLE t ( + i int, s serial, bi bigint, bs bigserial, bl boolean, si smallint, + t text, f real, d double precision, dt date, ts timestamp, tsz timestamptz, j jsonb, ba bytea, + u uuid) + '''); + + await connection.execute( + 'INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz, j, ba, u) ' + 'VALUES (-2147483648, -9223372036854775808, TRUE, -32768, ' + "'string', 10.0, 10.0, '1983-11-06', " + "'1983-11-06 06:00:00.000000', '1983-11-06 06:00:00.000000', " + "'{\"key\":\"value\"}', E'\\\\000', '00000000-0000-0000-0000-000000000000')"); + await connection.execute( + 'INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz, j, ba, u) ' + 'VALUES (2147483647, 9223372036854775807, FALSE, 32767, ' + "'a significantly longer string to the point where i doubt this actually matters', " + "10.25, 10.125, '2183-11-06', '2183-11-06 00:00:00.111111', " + "'2183-11-06 00:00:00.999999', " + "'[{\"key\":1}]', E'\\\\377', 'FFFFFFFF-ffff-ffff-ffff-ffffffffffff')"); + + await connection.execute( + 'INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz, j, ba, u) ' + 'VALUES (null, null, null, null, null, null, null, null, null, null, null, null, null)'); }); tearDown(() async { await connection?.close(); }); - test("Fetch em", () async { - var res = await connection.query("select * from t"); + test('Fetch em', () async { + final res = await connection.query('select * from t'); + + final row1 = res[0]; + final row2 = res[1]; + final row3 = res[2]; - var row1 = res[0]; - var row2 = res[1]; + // lower bound row expect(row1[0], equals(-2147483648)); expect(row1[1], equals(1)); expect(row1[2], equals(-9223372036854775808)); expect(row1[3], equals(1)); expect(row1[4], equals(true)); expect(row1[5], equals(-32768)); - expect(row1[6], equals("string")); + expect(row1[6], equals('string')); expect(row1[7] is double, true); expect(row1[7], equals(10.0)); expect(row1[8] is double, true); expect(row1[8], equals(10.0)); - expect(row1[9], equals(new DateTime.utc(1983, 11, 6))); - expect(row1[10], equals(new DateTime.utc(1983, 11, 6, 6))); - expect(row1[11], equals(new DateTime.utc(1983, 11, 6, 6))); + expect(row1[9], equals(DateTime.utc(1983, 11, 6))); + expect(row1[10], equals(DateTime.utc(1983, 11, 6, 6))); + expect(row1[11], equals(DateTime.utc(1983, 11, 6, 6))); + expect(row1[12], equals({'key': 'value'})); + expect(row1[13], equals([0])); + expect(row1[14], equals('00000000-0000-0000-0000-000000000000')); + // upper bound row expect(row2[0], equals(2147483647)); expect(row2[1], equals(2)); expect(row2[2], equals(9223372036854775807)); expect(row2[3], equals(2)); expect(row2[4], equals(false)); expect(row2[5], equals(32767)); - expect(row2[6], equals("a significantly longer string to the point where i doubt this actually matters")); + expect( + row2[6], + equals( + 'a significantly longer string to the point where i doubt this actually matters')); expect(row2[7] is double, true); expect(row2[7], equals(10.25)); expect(row2[8] is double, true); expect(row2[8], equals(10.125)); - expect(row2[9], equals(new DateTime.utc(2183, 11, 6))); - expect(row2[10], equals(new DateTime.utc(2183, 11, 6, 0, 0, 0, 111, 111))); - expect(row2[11], equals(new DateTime.utc(2183, 11, 6, 0, 0, 0, 999, 999))); - }); - - test("Fetch/insert empty string", () async { - await connection.execute("CREATE TEMPORARY TABLE u (t text)"); - var results = await connection.query("INSERT INTO u (t) VALUES (@t:text) returning t", substitutionValues: { - "t" : "" - }); - expect(results, [[""]]); + expect(row2[9], equals(DateTime.utc(2183, 11, 6))); + expect(row2[10], equals(DateTime.utc(2183, 11, 6, 0, 0, 0, 111, 111))); + expect(row2[11], equals(DateTime.utc(2183, 11, 6, 0, 0, 0, 999, 999))); + expect( + row2[12], + equals([ + {'key': 1} + ])); + expect(row2[13], equals([255])); + expect(row2[14], equals('ffffffff-ffff-ffff-ffff-ffffffffffff')); - results = await connection.query("select * from u"); - expect(results, [[""]]); + // all null row + expect(row3[0], isNull); + expect(row3[1], equals(3)); + expect(row3[2], isNull); + expect(row3[3], equals(3)); + expect(row3[4], isNull); + expect(row3[5], isNull); + expect(row3[6], isNull); + expect(row3[7], isNull); + expect(row3[8], isNull); + expect(row3[9], isNull); + expect(row3[10], isNull); + expect(row3[11], isNull); + expect(row3[12], isNull); + expect(row3[13], isNull); + expect(row3[14], isNull); }); - test("Fetch/insert null value", () async { - await connection.execute("CREATE TEMPORARY TABLE u (t text)"); - var results = await connection.query("INSERT INTO u (t) VALUES (@t:text) returning t", substitutionValues: { - "t" : null - }); - expect(results, [[null]]); + test('Fetch/insert empty string', () async { + await connection.execute('CREATE TEMPORARY TABLE u (t text)'); + var results = await connection.query( + 'INSERT INTO u (t) VALUES (@t:text) returning t', + substitutionValues: {'t': ''}); + expect(results, [ + [''] + ]); - results = await connection.query("select * from u"); - expect(results, [[null]]); + results = await connection.query('select * from u'); + expect(results, [ + [''] + ]); }); + test('Fetch/insert null value', () async { + await connection.execute('CREATE TEMPORARY TABLE u (t text)'); + var results = await connection.query( + 'INSERT INTO u (t) VALUES (@t:text) returning t', + substitutionValues: {'t': null}); + expect(results, [ + [null] + ]); - test("Timezone concerns", () { - + results = await connection.query('select * from u'); + expect(results, [ + [null] + ]); }); -} \ No newline at end of file +} diff --git a/test/encoding_test.dart b/test/encoding_test.dart index 761eca8..edfc81e 100644 --- a/test/encoding_test.dart +++ b/test/encoding_test.dart @@ -1,147 +1,418 @@ -import 'package:postgres/postgres.dart'; +import 'dart:async'; +import 'dart:convert'; + import 'package:test/test.dart'; -import 'dart:typed_data'; + +import 'package:postgres/postgres.dart'; +import 'package:postgres/src/binary_codec.dart'; +import 'package:postgres/src/text_codec.dart'; +import 'package:postgres/src/types.dart'; +import 'package:postgres/src/utf8_backed_string.dart'; + +PostgreSQLConnection conn; void main() { - test("Binary encode/decode inverse", () { - expectInverse(true, PostgreSQLCodec.TypeBool); - expectInverse(false, PostgreSQLCodec.TypeBool); + group('Binary encoders', () { + setUp(() async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + await conn.open(); + }); + + tearDown(() async { + await conn.close(); + conn = null; + }); + + // expectInverse ensures that: + // 1. encoder/decoder is reversible + // 2. can actually encode and decode a real pg query + // it also creates a table named t with column v of type being tested + test('bool', () async { + await expectInverse(true, PostgreSQLDataType.boolean); + await expectInverse(false, PostgreSQLDataType.boolean); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:boolean)', + substitutionValues: {'v': 'not-bool'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: bool')); + } + }); + + test('smallint', () async { + await expectInverse(-1, PostgreSQLDataType.smallInteger); + await expectInverse(0, PostgreSQLDataType.smallInteger); + await expectInverse(1, PostgreSQLDataType.smallInteger); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:int2)', + substitutionValues: {'v': 'not-int2'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: int')); + } + }); - expectInverse(-1, PostgreSQLCodec.TypeInt2); - expectInverse(0, PostgreSQLCodec.TypeInt2); - expectInverse(1, PostgreSQLCodec.TypeInt2); + test('integer', () async { + await expectInverse(-1, PostgreSQLDataType.integer); + await expectInverse(0, PostgreSQLDataType.integer); + await expectInverse(1, PostgreSQLDataType.integer); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:int4)', + substitutionValues: {'v': 'not-int4'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: int')); + } + }); - expectInverse(-1, PostgreSQLCodec.TypeInt4); - expectInverse(0, PostgreSQLCodec.TypeInt4); - expectInverse(1, PostgreSQLCodec.TypeInt4); + test('serial', () async { + await expectInverse(0, PostgreSQLDataType.serial); + await expectInverse(1, PostgreSQLDataType.serial); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:int4)', + substitutionValues: {'v': 'not-serial'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: int')); + } + }); - expectInverse(-1, PostgreSQLCodec.TypeInt8); - expectInverse(0, PostgreSQLCodec.TypeInt8); - expectInverse(1, PostgreSQLCodec.TypeInt8); + test('bigint', () async { + await expectInverse(-1, PostgreSQLDataType.bigInteger); + await expectInverse(0, PostgreSQLDataType.bigInteger); + await expectInverse(1, PostgreSQLDataType.bigInteger); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:int8)', + substitutionValues: {'v': 'not-int8'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: int')); + } + }); - expectInverse("", PostgreSQLCodec.TypeText); - expectInverse("foo", PostgreSQLCodec.TypeText); - expectInverse("foo\n", PostgreSQLCodec.TypeText); - expectInverse("foo\nbar;s", PostgreSQLCodec.TypeText); + test('bigserial', () async { + await expectInverse(0, PostgreSQLDataType.bigSerial); + await expectInverse(1, PostgreSQLDataType.bigSerial); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:int8)', + substitutionValues: {'v': 'not-bigserial'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: int')); + } + }); - expectInverse(-1.0, PostgreSQLCodec.TypeFloat4); - expectInverse(0.0, PostgreSQLCodec.TypeFloat4); - expectInverse(1.0, PostgreSQLCodec.TypeFloat4); + test('text', () async { + await expectInverse('', PostgreSQLDataType.text); + await expectInverse('foo', PostgreSQLDataType.text); + await expectInverse('foo\n', PostgreSQLDataType.text); + await expectInverse('foo\nbar;s', PostgreSQLDataType.text); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:text)', + substitutionValues: {'v': 0}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: String')); + } + }); - expectInverse(-1.0, PostgreSQLCodec.TypeFloat8); - expectInverse(0.0, PostgreSQLCodec.TypeFloat8); - expectInverse(1.0, PostgreSQLCodec.TypeFloat8); + test('real', () async { + await expectInverse(-1.0, PostgreSQLDataType.real); + await expectInverse(0.0, PostgreSQLDataType.real); + await expectInverse(1.0, PostgreSQLDataType.real); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:float4)', + substitutionValues: {'v': 'not-real'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: double')); + } + }); - expectInverse(new DateTime.utc(2016, 10, 1), PostgreSQLCodec.TypeDate); - expectInverse(new DateTime.utc(1920, 10, 1), PostgreSQLCodec.TypeDate); - expectInverse(new DateTime.utc(2120, 10, 5), PostgreSQLCodec.TypeDate); + test('double', () async { + await expectInverse(-1.0, PostgreSQLDataType.double); + await expectInverse(0.0, PostgreSQLDataType.double); + await expectInverse(1.0, PostgreSQLDataType.double); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:float8)', + substitutionValues: {'v': 'not-double'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: double')); + } + }); - expectInverse(new DateTime.utc(1920, 10, 1), PostgreSQLCodec.TypeTimestamp); - expectInverse(new DateTime.utc(2120, 10, 5), PostgreSQLCodec.TypeTimestamp); + test('date', () async { + await expectInverse(DateTime.utc(1920, 10, 1), PostgreSQLDataType.date); + await expectInverse(DateTime.utc(2120, 10, 5), PostgreSQLDataType.date); + await expectInverse(DateTime.utc(2016, 10, 1), PostgreSQLDataType.date); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:date)', + substitutionValues: {'v': 'not-date'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: DateTime')); + } + }); + + test('timestamp', () async { + await expectInverse(DateTime.utc(1920, 10, 1), + PostgreSQLDataType.timestampWithoutTimezone); + await expectInverse(DateTime.utc(2120, 10, 5), + PostgreSQLDataType.timestampWithoutTimezone); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:timestamp)', + substitutionValues: {'v': 'not-timestamp'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: DateTime')); + } + }); - expectInverse(new DateTime.utc(1920, 10, 1), PostgreSQLCodec.TypeTimestampTZ); - expectInverse(new DateTime.utc(2120, 10, 5), PostgreSQLCodec.TypeTimestampTZ); + test('timestamptz', () async { + await expectInverse( + DateTime.utc(1920, 10, 1), PostgreSQLDataType.timestampWithTimezone); + await expectInverse( + DateTime.utc(2120, 10, 5), PostgreSQLDataType.timestampWithTimezone); + try { + await conn.query('INSERT INTO t (v) VALUES (@v:timestamptz)', + substitutionValues: {'v': 'not-timestamptz'}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: DateTime')); + } + }); + + test('jsonb', () async { + await expectInverse('string', PostgreSQLDataType.json); + await expectInverse(2, PostgreSQLDataType.json); + await expectInverse(['foo'], PostgreSQLDataType.json); + await expectInverse({ + 'key': 'val', + 'key1': 1, + 'array': ['foo'] + }, PostgreSQLDataType.json); + + try { + await conn.query('INSERT INTO t (v) VALUES (@v:jsonb)', + substitutionValues: {'v': DateTime.now()}); + fail('unreachable'); + } on JsonUnsupportedObjectError catch (_) {} + }); + + test('bytea', () async { + await expectInverse([0], PostgreSQLDataType.byteArray); + await expectInverse([1, 2, 3, 4, 5], PostgreSQLDataType.byteArray); + await expectInverse([255, 254, 253], PostgreSQLDataType.byteArray); + + try { + await conn.query('INSERT INTO t (v) VALUES (@v:bytea)', + substitutionValues: {'v': DateTime.now()}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: List')); + } + }); + + test('uuid', () async { + await expectInverse( + '00000000-0000-0000-0000-000000000000', PostgreSQLDataType.uuid); + await expectInverse( + '12345678-abcd-efab-cdef-012345678901', PostgreSQLDataType.uuid); + + try { + await conn.query('INSERT INTO t (v) VALUES (@v:uuid)', + substitutionValues: {'v': DateTime.now()}); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Expected: String')); + } + }); }); - test("Escape strings", () { - // ' b o b ' - expect(PostgreSQLCodec.encode('bob').codeUnits, equals([39, 98, 111, 98, 39])); + group('Text encoders', () { + final encoder = PostgresTextEncoder(); - // ' b o \n b ' - expect(PostgreSQLCodec.encode('bo\nb').codeUnits, equals([39, 98, 111, 10, 98, 39])); + test('Escape strings', () { + // ' b o b ' + expect( + utf8.encode(encoder.convert('bob')), equals([39, 98, 111, 98, 39])); - // ' b o \r b ' - expect(PostgreSQLCodec.encode('bo\rb').codeUnits, equals([39, 98, 111, 13, 98, 39])); + // ' b o \n b ' + expect(utf8.encode(encoder.convert('bo\nb')), + equals([39, 98, 111, 10, 98, 39])); - // ' b o \b b ' - expect(PostgreSQLCodec.encode('bo\bb').codeUnits, equals([39, 98, 111, 8, 98, 39])); + // ' b o \r b ' + expect(utf8.encode(encoder.convert('bo\rb')), + equals([39, 98, 111, 13, 98, 39])); - // ' ' ' ' - expect(PostgreSQLCodec.encode("'").codeUnits, equals([39, 39, 39, 39])); + // ' b o \b b ' + expect(utf8.encode(encoder.convert('bo\bb')), + equals([39, 98, 111, 8, 98, 39])); - // ' ' ' ' ' ' - expect(PostgreSQLCodec.encode("''").codeUnits, equals([39, 39, 39, 39, 39, 39])); + // ' ' ' ' + expect(utf8.encode(encoder.convert("'")), equals([39, 39, 39, 39])); - // ' ' ' ' ' ' - expect(PostgreSQLCodec.encode("\''").codeUnits, equals([39, 39, 39, 39, 39, 39])); + // ' ' ' ' ' ' + expect( + utf8.encode(encoder.convert("''")), equals([39, 39, 39, 39, 39, 39])); - // sp E ' \ \ ' ' ' ' ' - expect(PostgreSQLCodec.encode("\\''").codeUnits, equals([32, 69, 39, 92, 92, 39, 39, 39, 39, 39])); + // ' ' ' ' ' ' + expect(utf8.encode(encoder.convert("\''")), + equals([39, 39, 39, 39, 39, 39])); - // sp E ' \ \ ' ' ' - expect(PostgreSQLCodec.encode("\\'").codeUnits, equals([32, 69, 39, 92, 92, 39, 39, 39])); - }); + // sp E ' \ \ ' ' ' ' ' + expect(utf8.encode(encoder.convert("\\''")), + equals([32, 69, 39, 92, 92, 39, 39, 39, 39, 39])); - test("Encode DateTime", () { - // Get users current timezone - var tz = new DateTime(2001, 2, 3).timeZoneOffset; - var tzOffsetDelimiter = "${tz.isNegative ? '-' : '+'}" - "${tz.abs().inHours.toString().padLeft(2, '0')}" - ":${(tz.inSeconds % 60).toString().padLeft(2, '0')}"; - - var pairs = { - "2001-02-03T00:00:00.000$tzOffsetDelimiter" : new DateTime(2001, DateTime.FEBRUARY, 3), - "2001-02-03T04:05:06.000$tzOffsetDelimiter" : new DateTime(2001, DateTime.FEBRUARY, 3, 4, 5, 6, 0), - "2001-02-03T04:05:06.999$tzOffsetDelimiter" : new DateTime(2001, DateTime.FEBRUARY, 3, 4, 5, 6, 999), - "0010-02-03T04:05:06.123$tzOffsetDelimiter BC" : new DateTime(-10, DateTime.FEBRUARY, 3, 4, 5, 6, 123), - "0010-02-03T04:05:06.000$tzOffsetDelimiter BC" : new DateTime(-10, DateTime.FEBRUARY, 3, 4, 5, 6, 0), - "012345-02-03T04:05:06.000$tzOffsetDelimiter BC" : new DateTime(-12345, DateTime.FEBRUARY, 3, 4, 5, 6, 0), - "012345-02-03T04:05:06.000$tzOffsetDelimiter" : new DateTime(12345, DateTime.FEBRUARY, 3, 4, 5, 6, 0) - }; - - pairs.forEach((k, v) { - expect(PostgreSQLCodec.encode(v), "'$k'"); + // sp E ' \ \ ' ' ' + expect(utf8.encode(encoder.convert("\\'")), + equals([32, 69, 39, 92, 92, 39, 39, 39])); }); - }); - test("Encode Double", () { - var pairs = { - "'nan'" : double.NAN, - "'infinity'" : double.INFINITY, - "'-infinity'" : double.NEGATIVE_INFINITY, - "1.7976931348623157e+308" : double.MAX_FINITE, - "5e-324" : double.MIN_POSITIVE, - "-0.0" : -0.0, - "0.0" : 0.0 - }; + test('Encode DateTime', () { + // Get users current timezone + final tz = DateTime(2001, 2, 3).timeZoneOffset; + final tzOffsetDelimiter = '${tz.isNegative ? '-' : '+'}' + '${tz.abs().inHours.toString().padLeft(2, '0')}' + ':${(tz.inSeconds % 60).toString().padLeft(2, '0')}'; + + final pairs = { + '2001-02-03T00:00:00.000$tzOffsetDelimiter': + DateTime(2001, DateTime.february, 3), + '2001-02-03T04:05:06.000$tzOffsetDelimiter': + DateTime(2001, DateTime.february, 3, 4, 5, 6, 0), + '2001-02-03T04:05:06.999$tzOffsetDelimiter': + DateTime(2001, DateTime.february, 3, 4, 5, 6, 999), + '0010-02-03T04:05:06.123$tzOffsetDelimiter BC': + DateTime(-10, DateTime.february, 3, 4, 5, 6, 123), + '0010-02-03T04:05:06.000$tzOffsetDelimiter BC': + DateTime(-10, DateTime.february, 3, 4, 5, 6, 0), + '012345-02-03T04:05:06.000$tzOffsetDelimiter BC': + DateTime(-12345, DateTime.february, 3, 4, 5, 6, 0), + '012345-02-03T04:05:06.000$tzOffsetDelimiter': + DateTime(12345, DateTime.february, 3, 4, 5, 6, 0) + }; + + pairs.forEach((k, v) { + expect(encoder.convert(v, escapeStrings: false), "'$k'"); + }); + }); - pairs.forEach((k, v) { - expect(PostgreSQLCodec.encode(v), "$k"); - expect(PostgreSQLCodec.encode(v, dataType: PostgreSQLDataType.real), "$k"); - expect(PostgreSQLCodec.encode(v, dataType: PostgreSQLDataType.double), "$k"); + test('Encode Double', () { + final pairs = { + "'nan'": double.nan, + "'infinity'": double.infinity, + "'-infinity'": double.negativeInfinity, + '1.7976931348623157e+308': double.maxFinite, + '5e-324': double.minPositive, + '-0.0': -0.0, + '0.0': 0.0 + }; + + pairs.forEach((k, v) { + expect(encoder.convert(v, escapeStrings: false), '$k'); + }); }); - expect(PostgreSQLCodec.encode(1, dataType: PostgreSQLDataType.double), "1"); + test('Encode Int', () { + expect(encoder.convert(1), '1'); + expect(encoder.convert(1234324323), '1234324323'); + expect(encoder.convert(-1234324323), '-1234324323'); + }); - expect(PostgreSQLCodec.encode(null, dataType: PostgreSQLDataType.real), "null"); - expect(PostgreSQLCodec.encode(null, dataType: PostgreSQLDataType.double), "null"); - }); + test('Encode Bool', () { + expect(encoder.convert(true), 'TRUE'); + expect(encoder.convert(false), 'FALSE'); + }); - test("Encode Int", () { - expect(PostgreSQLCodec.encode(1.0, dataType: PostgreSQLDataType.integer), "1"); + test('Encode JSONB', () { + expect(encoder.convert({'a': 'b'}), '{"a":"b"}'); + expect(encoder.convert({'a': true}), '{"a":true}'); + expect(encoder.convert({'b': false}), '{"b":false}'); + }); + + test('Attempt to infer unknown type throws exception', () { + try { + encoder.convert([]); + fail('unreachable'); + } on PostgreSQLException catch (e) { + expect(e.toString(), contains('Could not infer type')); + } + }); + }); - expect(PostgreSQLCodec.encode(1), "1"); - expect(PostgreSQLCodec.encode(1, dataType: PostgreSQLDataType.integer), "1"); - expect(PostgreSQLCodec.encode(1, dataType: PostgreSQLDataType.bigInteger), "1"); - expect(PostgreSQLCodec.encode(1, dataType: PostgreSQLDataType.smallInteger), "1"); + test('UTF8String caches string regardless of which method is called first', + () { + final u = UTF8BackedString('abcd'); + final v = UTF8BackedString('abcd'); + u.utf8Length; + v.utf8Bytes; - expect(PostgreSQLCodec.encode(null, dataType: PostgreSQLDataType.integer), "null"); - expect(PostgreSQLCodec.encode(null, dataType: PostgreSQLDataType.bigInteger), "null"); - expect(PostgreSQLCodec.encode(null, dataType: PostgreSQLDataType.smallInteger), "null"); + expect(u.hasCachedBytes, true); + expect(v.hasCachedBytes, true); }); - test("Encode Bool", () { - expect(PostgreSQLCodec.encode(null, dataType: PostgreSQLDataType.boolean), "null"); - expect(PostgreSQLCodec.encode(true), "TRUE"); - expect(PostgreSQLCodec.encode(false), "FALSE"); - expect(PostgreSQLCodec.encode(true, dataType: PostgreSQLDataType.boolean), "TRUE"); - expect(PostgreSQLCodec.encode(false, dataType: PostgreSQLDataType.boolean), "FALSE"); + test('Invalid UUID encoding', () { + final converter = PostgresBinaryEncoder(PostgreSQLDataType.uuid); + try { + converter.convert('z0000000-0000-0000-0000-000000000000'); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Invalid UUID string')); + } + + try { + converter.convert(123123); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Invalid type for parameter')); + } + + try { + converter.convert('0000000-0000-0000-0000-000000000000'); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Invalid UUID string')); + } + + try { + converter.convert('00000000-0000-0000-0000-000000000000f'); + fail('unreachable'); + } on FormatException catch (e) { + expect(e.toString(), contains('Invalid UUID string')); + } }); } -expectInverse(dynamic value, int dataType) { - var encodedValue = PostgreSQLCodec.encodeBinary(value, dataType); - var decodedValue = PostgreSQLCodec.decodeValue(new ByteData.view(encodedValue.buffer), dataType); +Future expectInverse(dynamic value, PostgreSQLDataType dataType) async { + final type = PostgreSQLFormat.dataTypeStringForDataType(dataType); + + await conn.execute('CREATE TEMPORARY TABLE IF NOT EXISTS t (v $type)'); + final result = await conn.query( + 'INSERT INTO t (v) VALUES (${PostgreSQLFormat.id('v', type: dataType)}) RETURNING v', + substitutionValues: {'v': value}); + expect(result.first.first, equals(value)); + + final encoder = PostgresBinaryEncoder(dataType); + final encodedValue = encoder.convert(value); + + if (dataType == PostgreSQLDataType.serial) { + dataType = PostgreSQLDataType.integer; + } else if (dataType == PostgreSQLDataType.bigSerial) { + dataType = PostgreSQLDataType.bigInteger; + } + int code; + PostgresBinaryDecoder.typeMap.forEach((key, type) { + if (type == dataType) { + code = key; + } + }); + + final decoder = PostgresBinaryDecoder(code); + final decodedValue = decoder.convert(encodedValue); + expect(decodedValue, value); -} \ No newline at end of file +} diff --git a/test/framer_test.dart b/test/framer_test.dart new file mode 100644 index 0000000..77a3996 --- /dev/null +++ b/test/framer_test.dart @@ -0,0 +1,210 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:buffer/buffer.dart'; +import 'package:test/test.dart'; + +import 'package:postgres/src/message_window.dart'; +import 'package:postgres/src/server_messages.dart'; + +void main() { + MessageFramer framer; + setUp(() { + framer = MessageFramer(); + }); + + tearDown(() { + flush(framer); + }); + + test('Perfectly sized message in one buffer', () { + framer.addBytes(bufferWithMessages([ + messageWithBytes([1, 2, 3], 1) + ])); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(1, Uint8List.fromList([1, 2, 3])), + ]); + }); + + test('Two perfectly sized messages in one buffer', () { + framer.addBytes(bufferWithMessages([ + messageWithBytes([1, 2, 3], 1), + messageWithBytes([1, 2, 3, 4], 2) + ])); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(1, Uint8List.fromList([1, 2, 3])), + UnknownMessage(2, Uint8List.fromList([1, 2, 3, 4])), + ]); + }); + + test('Header fragment', () { + final message = messageWithBytes([1, 2, 3], 1); + final fragments = fragmentedMessageBuffer(message, 2); + framer.addBytes(fragments.first); + expect(framer.messageQueue, isEmpty); + + framer.addBytes(fragments.last); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(1, Uint8List.fromList([1, 2, 3])) + ]); + }); + + test('Two header fragments', () { + final message = messageWithBytes([1, 2, 3], 1); + final fragments = fragmentedMessageBuffer(message, 2); + final moreFragments = fragmentedMessageBuffer(fragments.first, 1); + + framer.addBytes(moreFragments.first); + expect(framer.messageQueue, isEmpty); + + framer.addBytes(moreFragments.last); + expect(framer.messageQueue, isEmpty); + + framer.addBytes(fragments.last); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(1, Uint8List.fromList([1, 2, 3])), + ]); + }); + + test('One message + header fragment', () { + final message1 = messageWithBytes([1, 2, 3], 1); + final message2 = messageWithBytes([2, 2, 3], 2); + final message2Fragments = fragmentedMessageBuffer(message2, 3); + + framer.addBytes(bufferWithMessages([message1, message2Fragments.first])); + + expect(framer.messageQueue.length, 1); + + framer.addBytes(message2Fragments.last); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(1, Uint8List.fromList([1, 2, 3])), + UnknownMessage(2, Uint8List.fromList([2, 2, 3])), + ]); + }); + + test('Message + header, missing rest of buffer', () { + final message1 = messageWithBytes([1, 2, 3], 1); + final message2 = messageWithBytes([2, 2, 3], 2); + final message2Fragments = fragmentedMessageBuffer(message2, 5); + + framer.addBytes(bufferWithMessages([message1, message2Fragments.first])); + + expect(framer.messageQueue.length, 1); + + framer.addBytes(message2Fragments.last); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(1, Uint8List.fromList([1, 2, 3])), + UnknownMessage(2, Uint8List.fromList([2, 2, 3])), + ]); + }); + + test('Message body spans two packets', () { + final message = messageWithBytes([1, 2, 3, 4, 5, 6, 7], 1); + final fragments = fragmentedMessageBuffer(message, 8); + framer.addBytes(fragments.first); + expect(framer.messageQueue, isEmpty); + + framer.addBytes(fragments.last); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(1, Uint8List.fromList([1, 2, 3, 4, 5, 6, 7])), + ]); + }); + + test( + 'Message spans two packets, started in a packet that contained another message', + () { + final earlierMessage = messageWithBytes([1, 2], 0); + final message = messageWithBytes([1, 2, 3, 4, 5, 6, 7], 1); + + framer.addBytes(bufferWithMessages( + [earlierMessage, fragmentedMessageBuffer(message, 8).first])); + expect(framer.messageQueue, hasLength(1)); + + framer.addBytes(fragmentedMessageBuffer(message, 8).last); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(0, Uint8List.fromList([1, 2])), + UnknownMessage(1, Uint8List.fromList([1, 2, 3, 4, 5, 6, 7])) + ]); + }); + + test('Message spans three packets, only part of header in the first', () { + final earlierMessage = messageWithBytes([1, 2], 0); + final message = + messageWithBytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], 1); + + framer.addBytes(bufferWithMessages( + [earlierMessage, fragmentedMessageBuffer(message, 3).first])); + expect(framer.messageQueue, hasLength(1)); + + framer.addBytes( + fragmentedMessageBuffer(fragmentedMessageBuffer(message, 3).last, 6) + .first); + expect(framer.messageQueue, hasLength(1)); + + framer.addBytes( + fragmentedMessageBuffer(fragmentedMessageBuffer(message, 3).last, 6) + .last); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(0, Uint8List.fromList([1, 2])), + UnknownMessage( + 1, Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13])), + ]); + }); + + test('Frame with no data', () { + framer.addBytes(bufferWithMessages([messageWithBytes([], 10)])); + + final messages = framer.messageQueue.toList(); + expect(messages, [UnknownMessage(10, Uint8List(0))]); + }); +} + +List messageWithBytes(List bytes, int messageID) { + final buffer = BytesBuilder(); + buffer.addByte(messageID); + final lengthBuffer = ByteData(4); + lengthBuffer.setUint32(0, bytes.length + 4); + buffer.add(lengthBuffer.buffer.asUint8List()); + buffer.add(bytes); + return buffer.toBytes(); +} + +List fragmentedMessageBuffer(List message, int pivotPoint) { + final l1 = message.sublist(0, pivotPoint); + final l2 = message.sublist(pivotPoint, message.length); + return [castBytes(l1), castBytes(l2)]; +} + +Uint8List bufferWithMessages(List> messages) { + return Uint8List.fromList(messages.expand((l) => l).toList()); +} + +void flush(MessageFramer framer) { + framer.messageQueue.clear(); + framer.addBytes(bufferWithMessages([ + messageWithBytes([1, 2, 3], 1) + ])); + + final messages = framer.messageQueue.toList(); + expect(messages, [ + UnknownMessage(1, Uint8List.fromList([1, 2, 3])), + ]); +} diff --git a/test/interpolation_test.dart b/test/interpolation_test.dart index b18cf3c..5f57756 100644 --- a/test/interpolation_test.dart +++ b/test/interpolation_test.dart @@ -1,41 +1,139 @@ +import 'dart:convert'; + import 'package:postgres/postgres.dart'; +import 'package:postgres/src/query.dart'; import 'package:test/test.dart'; void main() { - test("Simple replacement", () { - var result = PostgreSQLFormat.substitute("@id", {"id" : 20}); - expect(result, equals("20")); + test('Ensure all types/format type mappings are available and accurate', () { + PostgreSQLDataType.values + .where((t) => + t != PostgreSQLDataType.bigSerial && t != PostgreSQLDataType.serial) + .forEach((t) { + expect(PostgreSQLFormatIdentifier.typeStringToCodeMap.values.contains(t), + true); + final code = PostgreSQLFormat.dataTypeStringForDataType(t); + expect(PostgreSQLFormatIdentifier.typeStringToCodeMap[code], t); + }); + }); + + test('Ensure bigserial gets translated to int8', () { + expect( + PostgreSQLFormat.dataTypeStringForDataType(PostgreSQLDataType.serial), + 'int4'); + }); + + test('Ensure serial gets translated to int4', () { + expect( + PostgreSQLFormat.dataTypeStringForDataType( + PostgreSQLDataType.bigSerial), + 'int8'); + }); + + test('Simple replacement', () { + final result = PostgreSQLFormat.substitute('@id', {'id': 20}); + expect(result, equals('20')); + }); + + test('Trailing/leading space', () { + final result = PostgreSQLFormat.substitute(' @id ', {'id': 20}); + expect(result, equals(' 20 ')); + }); + + test('Two identifiers next to eachother', () { + final result = + PostgreSQLFormat.substitute('@id@bob', {'id': 20, 'bob': 13}); + expect(result, equals('2013')); + }); + + test('Identifier with underscores', () { + final result = PostgreSQLFormat.substitute('@_one_two', {'_one_two': 12}); + expect(result, equals('12')); + }); + + test('Identifier with type info', () { + final result = PostgreSQLFormat.substitute('@id:int2', {'id': 12}); + expect(result, equals('12')); }); - test("Trailing/leading space", () { - var result = PostgreSQLFormat.substitute(" @id ", {"id" : 20}); - expect(result, equals(" 20 ")); + test('Identifiers next to eachother with type info', () { + final result = PostgreSQLFormat.substitute( + '@id:int2@foo:float4', {'id': 12, 'foo': 2.0}); + expect(result, equals('122.0')); }); - test("Two identifiers next to eachother", () { - var result = PostgreSQLFormat.substitute("@id@bob", {"id" : 20, "bob" : 13}); - expect(result, equals("2013")); + test('Disambiguate PostgreSQL typecast', () { + final result = PostgreSQLFormat.substitute('@id::jsonb', {'id': '12'}); + expect(result, "'12'::jsonb"); + }); + + test('PostgreSQL typecast appears in query', () { + final results = PostgreSQLFormat.substitute( + "SELECT * FROM t WHERE id=@id:int2 WHERE blob=@blob::jsonb AND blob='{\"a\":1}'::jsonb", + {'id': 2, 'blob': '{"key":"value"}'}); + + expect(results, + "SELECT * FROM t WHERE id=2 WHERE blob='{\"key\":\"value\"}'::jsonb AND blob='{\"a\":1}'::jsonb"); }); - test("Identifier with underscores", () { - var result = PostgreSQLFormat.substitute("@_one_two", {"_one_two" : 12}); - expect(result, equals("12")); + test('Can both provide type and typecast', () { + final results = PostgreSQLFormat.substitute( + 'SELECT * FROM t WHERE id=@id:int2::int4', + {'id': 2, 'blob': '{"key":"value"}'}); + + expect(results, 'SELECT * FROM t WHERE id=2::int4'); }); - test("Identifier with type info", () { - var result = PostgreSQLFormat.substitute("@id:int2", {"id" : 12}); - expect(result, equals("12")); + test('UTF16 symbols with quotes', () { + final value = "'©™®'"; + final results = PostgreSQLFormat.substitute( + 'INSERT INTO t (t) VALUES (@t)', {'t': value}); + + expect(results, "INSERT INTO t (t) VALUES ('''©™®''')"); }); - test("Identifiers next to eachother with type info", () { - var result = PostgreSQLFormat.substitute("@id:int2@foo:float4", {"id" : 12, "foo" : 2.0}); - expect(result, equals("122.0")); + test('UTF16 symbols with backslash', () { + final value = "'©\\™®'"; + final results = PostgreSQLFormat.substitute( + 'INSERT INTO t (t) VALUES (@t)', {'t': value}); + + expect(results, "INSERT INTO t (t) VALUES ( E'''©\\\\™®''')"); }); - test("String identifiers get escaped", () { - var result = PostgreSQLFormat.substitute("@id:text @foo", {"id" : "1';select", "foo" : "3\\4"}); + test('String identifiers get escaped', () { + final result = PostgreSQLFormat.substitute( + '@id:text @foo', {'id': "1';select", 'foo': '3\\4'}); // ' 1 ' ' ; s e l e c t ' sp sp E ' 3 \ \ 4 ' - expect(result.codeUnits, [39,49,39,39,59,115,101,108,101,99,116,39, 32, 32,69,39,51,92,92,52,39]); + expect(utf8.encode(result), [ + 39, + 49, + 39, + 39, + 59, + 115, + 101, + 108, + 101, + 99, + 116, + 39, + 32, + 32, + 69, + 39, + 51, + 92, + 92, + 52, + 39 + ]); + }); + + test('JSONB operator does not throw', () { + final query = "SELECT id FROM table WHERE data @> '{\"key\": \"value\"}'"; + final results = PostgreSQLFormat.substitute(query, {}); + + expect(results, query); }); -} \ No newline at end of file +} diff --git a/test/json_test.dart b/test/json_test.dart new file mode 100644 index 0000000..cee5b27 --- /dev/null +++ b/test/json_test.dart @@ -0,0 +1,152 @@ +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +void main() { + PostgreSQLConnection connection; + + setUp(() async { + connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + await connection.open(); + + await connection.execute(''' + CREATE TEMPORARY TABLE t (j jsonb) + '''); + }); + + tearDown(() async { + await connection?.close(); + }); + + group('Storage', () { + test('Can store JSON String', () async { + var result = await connection + .query("INSERT INTO t (j) VALUES ('\"xyz\"'::jsonb) RETURNING j"); + expect(result, [ + ['xyz'] + ]); + result = await connection.query('SELECT j FROM t'); + expect(result, [ + ['xyz'] + ]); + }); + + test('Can store JSON String with driver type annotation', () async { + var result = await connection.query( + 'INSERT INTO t (j) VALUES (@a:jsonb) RETURNING j', + substitutionValues: {'a': 'xyz'}); + expect(result, [ + ['xyz'] + ]); + result = await connection.query('SELECT j FROM t'); + expect(result, [ + ['xyz'] + ]); + }); + + test('Can store JSON Number', () async { + var result = await connection + .query("INSERT INTO t (j) VALUES ('4'::jsonb) RETURNING j"); + expect(result, [ + [4] + ]); + result = await connection.query('SELECT j FROM t'); + expect(result, [ + [4] + ]); + }); + + test('Can store JSON Number with driver type annotation', () async { + var result = await connection.query( + 'INSERT INTO t (j) VALUES (@a:jsonb) RETURNING j', + substitutionValues: {'a': 4}); + expect(result, [ + [4] + ]); + result = await connection.query('SELECT j FROM t'); + expect(result, [ + [4] + ]); + }); + + test('Can store JSON map', () async { + var result = await connection + .query("INSERT INTO t (j) VALUES ('{\"a\":4}') RETURNING j"); + expect(result, [ + [ + {'a': 4} + ] + ]); + result = await connection.query('SELECT j FROM t'); + expect(result, [ + [ + {'a': 4} + ] + ]); + }); + + test('Can store JSON map with driver type annotation', () async { + var result = await connection.query( + 'INSERT INTO t (j) VALUES (@a:jsonb) RETURNING j', + substitutionValues: { + 'a': {'a': 4} + }); + expect(result, [ + [ + {'a': 4} + ] + ]); + result = await connection.query('SELECT j FROM t'); + expect(result, [ + [ + {'a': 4} + ] + ]); + }); + + test('Can store JSON list', () async { + var result = await connection + .query("INSERT INTO t (j) VALUES ('[{\"a\":4}]') RETURNING j"); + expect(result, [ + [ + [ + {'a': 4} + ] + ] + ]); + result = await connection.query('SELECT j FROM t'); + expect(result, [ + [ + [ + {'a': 4} + ] + ] + ]); + }); + + test('Can store JSON list with driver type annotation', () async { + var result = await connection.query( + 'INSERT INTO t (j) VALUES (@a:jsonb) RETURNING j', + substitutionValues: { + 'a': [ + {'a': 4} + ] + }); + expect(result, [ + [ + [ + {'a': 4} + ] + ] + ]); + result = await connection.query('SELECT j FROM t'); + expect(result, [ + [ + [ + {'a': 4} + ] + ] + ]); + }); + }); +} diff --git a/test/map_return_test.dart b/test/map_return_test.dart new file mode 100644 index 0000000..b0135ed --- /dev/null +++ b/test/map_return_test.dart @@ -0,0 +1,153 @@ +import 'dart:mirrors'; + +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +void main() { + PostgreSQLConnection connection; + + setUp(() async { + connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + await connection.open(); + + await connection.execute(''' + CREATE TEMPORARY TABLE t (id int primary key, name text) + '''); + + await connection.execute(''' + CREATE TEMPORARY TABLE u (id int primary key, name text, t_id int references t (id)) + '''); + + await connection.execute("INSERT INTO t (id, name) VALUES (1, 'a')"); + await connection.execute("INSERT INTO t (id, name) VALUES (2, 'b')"); + await connection.execute("INSERT INTO t (id, name) VALUES (3, 'c')"); + await connection + .execute("INSERT INTO u (id, name, t_id) VALUES (1, 'ua', 1)"); + await connection + .execute("INSERT INTO u (id, name, t_id) VALUES (2, 'ub', 1)"); + await connection + .execute("INSERT INTO u (id, name, t_id) VALUES (3, 'uc', 2)"); + }); + + tearDown(() async { + await connection?.close(); + }); + + test('Get row map without specifying columns', () async { + final results = + await connection.mappedResultsQuery('SELECT * from t ORDER BY id ASC'); + expect(results, [ + { + 't': {'id': 1, 'name': 'a'} + }, + { + 't': {'id': 2, 'name': 'b'} + }, + { + 't': {'id': 3, 'name': 'c'} + }, + ]); + }); + + test('Get row map by with specified columns', () async { + final results = await connection + .mappedResultsQuery('SELECT name, id from t ORDER BY id ASC'); + expect(results, [ + { + 't': {'id': 1, 'name': 'a'} + }, + { + 't': {'id': 2, 'name': 'b'} + }, + { + 't': {'id': 3, 'name': 'c'} + }, + ]); + + final nextResults = await connection + .mappedResultsQuery('SELECT name from t ORDER BY name DESC'); + expect(nextResults, [ + { + 't': {'name': 'c'} + }, + { + 't': {'name': 'b'} + }, + { + 't': {'name': 'a'} + }, + ]); + }); + + test('Get row with joined row', () async { + final results = await connection.mappedResultsQuery( + 'SELECT t.name, t.id, u.id, u.name, u.t_id from t LEFT OUTER JOIN u ON t.id=u.t_id ORDER BY t.id ASC'); + expect(results, [ + { + 't': {'name': 'a', 'id': 1}, + 'u': {'id': 1, 'name': 'ua', 't_id': 1} + }, + { + 't': {'name': 'a', 'id': 1}, + 'u': {'id': 2, 'name': 'ub', 't_id': 1} + }, + { + 't': {'name': 'b', 'id': 2}, + 'u': {'id': 3, 'name': 'uc', 't_id': 2} + }, + { + 't': {'name': 'c', 'id': 3}, + 'u': {'name': null, 'id': null, 't_id': null} + } + ]); + }); + + test('Table names get cached', () async { + clearOidQueryCount(connection); + expect(getOidQueryCount(connection), 0); + + await connection.mappedResultsQuery('SELECT id FROM t'); + expect(getOidQueryCount(connection), 1); + + await connection.mappedResultsQuery('SELECT id FROM t'); + expect(getOidQueryCount(connection), 1); + + await connection.mappedResultsQuery( + 'SELECT t.id, u.id FROM t LEFT OUTER JOIN u ON t.id=u.t_id'); + expect(getOidQueryCount(connection), 2); + + await connection.mappedResultsQuery('SELECT u.id FROM u'); + expect(getOidQueryCount(connection), 2); + }); + + test('Non-table mappedResultsQuery succeeds', () async { + final result = await connection.mappedResultsQuery('SELECT 1'); + expect(result, [ + { + null: {'?column?': 1} + } + ]); + }); +} + +void clearOidQueryCount(PostgreSQLConnection connection) { + final oidCacheMirror = reflect(connection) + .type + .declarations + .values + .firstWhere((DeclarationMirror dm) => + dm.simpleName.toString().contains('_oidCache')); + (reflect(connection).getField(oidCacheMirror.simpleName).reflectee).clear(); +} + +int getOidQueryCount(PostgreSQLConnection connection) { + final oidCacheMirror = reflect(connection) + .type + .declarations + .values + .firstWhere((DeclarationMirror dm) => + dm.simpleName.toString().contains('_oidCache')); + return (reflect(connection).getField(oidCacheMirror.simpleName).reflectee) + .queryCount as int; +} diff --git a/test/notification_test.dart b/test/notification_test.dart new file mode 100644 index 0000000..d6cb5d9 --- /dev/null +++ b/test/notification_test.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +void main() { + group('Successful notifications', () { + var connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + + setUp(() async { + connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + await connection.open(); + }); + + tearDown(() async { + await connection.close(); + }); + + test('Notification Response', () async { + final channel = 'virtual'; + final payload = 'This is the payload'; + final futureMsg = connection.notifications.first; + await connection.execute('LISTEN $channel;' + "NOTIFY $channel, '$payload';"); + + final msg = await futureMsg.timeout(Duration(milliseconds: 200)); + expect(msg.channel, channel); + expect(msg.payload, payload); + }); + + test('Notification Response empty payload', () async { + final channel = 'virtual'; + final futureMsg = connection.notifications.first; + await connection.execute('LISTEN $channel;' + 'NOTIFY $channel;'); + + final msg = await futureMsg.timeout(Duration(milliseconds: 200)); + expect(msg.channel, channel); + expect(msg.payload, ''); + }); + + test('Notification UNLISTEN', () async { + final channel = 'virtual'; + final payload = 'This is the payload'; + var futureMsg = connection.notifications.first; + await connection.execute('LISTEN $channel;' + "NOTIFY $channel, '$payload';"); + + final msg = await futureMsg.timeout(Duration(milliseconds: 200)); + + expect(msg.channel, channel); + expect(msg.payload, payload); + + await connection.execute('UNLISTEN $channel;'); + + futureMsg = connection.notifications.first; + + try { + await connection.execute("NOTIFY $channel, '$payload';"); + + await futureMsg.timeout(Duration(milliseconds: 200)); + + fail('There should be no notification'); + } on TimeoutException catch (_) {} + }); + + test('Notification many channel', () async { + final countResponse = {}; + var totalCountResponse = 0; + final finishExecute = Completer(); + connection.notifications.listen((msg) { + final count = countResponse[msg.channel]; + countResponse[msg.channel] = (count ?? 0) + 1; + totalCountResponse++; + if (totalCountResponse == 20) finishExecute.complete(); + }); + + final channel1 = 'virtual1'; + final channel2 = 'virtual2'; + + final notifier = () async { + for (var i = 0; i < 5; i++) { + await connection.execute('NOTIFY $channel1;' + 'NOTIFY $channel2;'); + } + }; + + await connection.execute('LISTEN $channel1;'); + await notifier(); + + await connection.execute('LISTEN $channel2;'); + await notifier(); + + await connection.execute('UNLISTEN $channel1;'); + await notifier(); + + await connection.execute('UNLISTEN $channel2;'); + await notifier(); + + await finishExecute.future.timeout(Duration(milliseconds: 200)); + + expect(countResponse[channel1], 10); + expect(countResponse[channel2], 10); + }, timeout: Timeout(Duration(seconds: 5))); + }); +} diff --git a/test/query_reuse_test.dart b/test/query_reuse_test.dart index f5694f7..44ebbae 100644 --- a/test/query_reuse_test.dart +++ b/test/query_reuse_test.dart @@ -1,416 +1,582 @@ -import 'package:postgres/postgres.dart'; -import 'package:test/test.dart'; import 'dart:async'; import 'dart:mirrors'; -String sid(String id, PostgreSQLDataType dt) => PostgreSQLFormat.id(id, type: dt); +import 'package:postgres/src/query_cache.dart'; +import 'package:test/test.dart'; + +import 'package:postgres/postgres.dart'; + +String sid(String id, PostgreSQLDataType dt) => + PostgreSQLFormat.id(id, type: dt); void main() { - group("Retaining type information", () { + group('Retaining type information', () { PostgreSQLConnection connection; setUp(() async { - connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await connection.open(); - await connection.execute("CREATE TEMPORARY TABLE t (i int, s serial, bi bigint, bs bigserial, bl boolean, si smallint, t text, f real, d double precision, dt date, ts timestamp, tsz timestamptz)"); + await connection.execute( + 'CREATE TEMPORARY TABLE t (i int, s serial, bi bigint, bs bigserial, bl boolean, si smallint, t text, f real, d double precision, dt date, ts timestamp, tsz timestamptz)'); }); tearDown(() async { await connection.close(); }); - test("Call query multiple times with all parameter types succeeds", () async { - var insertQueryString = "INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) VALUES " - "(${sid("i", PostgreSQLDataType.integer)}, ${sid("bi", PostgreSQLDataType.bigInteger)}," - "${sid("bl", PostgreSQLDataType.boolean)}, ${sid("si", PostgreSQLDataType.smallInteger)}," - "${sid("t", PostgreSQLDataType.text)}, ${sid("f", PostgreSQLDataType.real)}," - "${sid("d", PostgreSQLDataType.double)}, ${sid("dt", PostgreSQLDataType.date)}," - "${sid("ts", PostgreSQLDataType.timestampWithoutTimezone)}, ${sid("tsz", PostgreSQLDataType.timestampWithTimezone)}" - ") returning i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz"; - var results = await connection.query(insertQueryString, substitutionValues: { - "i" : 1, - "bi" : 2, - "bl" : true, - "si" : 3, - "t" : "foobar", - "f" : 5.0, - "d" : 6.0, - "dt" : new DateTime.utc(2000), - "ts" : new DateTime.utc(2000, 2), - "tsz" : new DateTime.utc(2000, 3) + test('Call query multiple times with all parameter types succeeds', + () async { + final insertQueryString = + 'INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) VALUES ' + '(${sid('i', PostgreSQLDataType.integer)}, ${sid('bi', PostgreSQLDataType.bigInteger)},' + '${sid('bl', PostgreSQLDataType.boolean)}, ${sid('si', PostgreSQLDataType.smallInteger)},' + '${sid('t', PostgreSQLDataType.text)}, ${sid('f', PostgreSQLDataType.real)},' + '${sid('d', PostgreSQLDataType.double)}, ${sid('dt', PostgreSQLDataType.date)},' + '${sid('ts', PostgreSQLDataType.timestampWithoutTimezone)}, ${sid('tsz', PostgreSQLDataType.timestampWithTimezone)}' + ') returning i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz'; + var results = + await connection.query(insertQueryString, substitutionValues: { + 'i': 1, + 'bi': 2, + 'bl': true, + 'si': 3, + 't': 'foobar', + 'f': 5.0, + 'd': 6.0, + 'dt': DateTime.utc(2000), + 'ts': DateTime.utc(2000, 2), + 'tsz': DateTime.utc(2000, 3) }); expect(hasCachedQueryNamed(connection, insertQueryString), true); - var expectedRow1 = [1, 1, 2, 1, true, 3, "foobar", 5.0, 6.0, new DateTime.utc(2000), new DateTime.utc(2000, 2), new DateTime.utc(2000, 3)]; + final expectedRow1 = [ + 1, + 1, + 2, + 1, + true, + 3, + 'foobar', + 5.0, + 6.0, + DateTime.utc(2000), + DateTime.utc(2000, 2), + DateTime.utc(2000, 3) + ]; expect(results, [expectedRow1]); results = await connection.query(insertQueryString, substitutionValues: { - "i" : 2, - "bi" : 3, - "bl" : false, - "si" : 4, - "t" : "barfoo", - "f" : 6.0, - "d" : 7.0, - "dt" : new DateTime.utc(2001), - "ts" : new DateTime.utc(2001, 2), - "tsz" : new DateTime.utc(2001, 3) + 'i': 2, + 'bi': 3, + 'bl': false, + 'si': 4, + 't': 'barfoo', + 'f': 6.0, + 'd': 7.0, + 'dt': DateTime.utc(2001), + 'ts': DateTime.utc(2001, 2), + 'tsz': DateTime.utc(2001, 3) }); expect(hasCachedQueryNamed(connection, insertQueryString), true); - var expectedRow2 = [2, 2, 3, 2, false, 4, "barfoo", 6.0, 7.0, new DateTime.utc(2001), new DateTime.utc(2001, 2), new DateTime.utc(2001, 3)]; + final expectedRow2 = [ + 2, + 2, + 3, + 2, + false, + 4, + 'barfoo', + 6.0, + 7.0, + DateTime.utc(2001), + DateTime.utc(2001, 2), + DateTime.utc(2001, 3) + ]; expect(results, [expectedRow2]); - results = await connection.query("select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t"); + results = await connection + .query('select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t'); expect(results, [expectedRow1, expectedRow2]); - expect(hasCachedQueryNamed(connection, "select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t"), true); + expect( + hasCachedQueryNamed(connection, + 'select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t'), + true); - results = await connection.query("select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i", substitutionValues: { - "i" : 0 - }); + results = await connection.query( + 'select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i', + substitutionValues: {'i': 0}); expect(results, []); - results = await connection.query("select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i", substitutionValues: { - "i" : 2 - }); + results = await connection.query( + 'select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i', + substitutionValues: {'i': 2}); expect(results, [expectedRow1]); - results = await connection.query("select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i", substitutionValues: { - "i" : 5 - }); + results = await connection.query( + 'select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i', + substitutionValues: {'i': 5}); expect(results, [expectedRow1, expectedRow2]); expect(hasCachedQueryNamed(connection, insertQueryString), true); }); - test("Call query multiple times without type data succeeds ", () async { - var insertQueryString = "INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) VALUES " - "(@i, @bi, @bl, @si, @t, @f, @d, @dt, @ts, @tsz) " - "returning i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz"; - var results = await connection.query(insertQueryString, substitutionValues: { - "i" : 1, - "bi" : 2, - "bl" : true, - "si" : 3, - "t" : "foobar", - "f" : 5.0, - "d" : 6.0, - "dt" : new DateTime.utc(2000), - "ts" : new DateTime.utc(2000, 2), - "tsz" : new DateTime.utc(2000, 3) + test('Call query multiple times without type data succeeds ', () async { + final insertQueryString = + 'INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) VALUES ' + '(@i, @bi, @bl, @si, @t, @f, @d, @dt, @ts, @tsz) ' + 'returning i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz'; + var results = + await connection.query(insertQueryString, substitutionValues: { + 'i': 1, + 'bi': 2, + 'bl': true, + 'si': 3, + 't': 'foobar', + 'f': 5.0, + 'd': 6.0, + 'dt': DateTime.utc(2000), + 'ts': DateTime.utc(2000, 2), + 'tsz': DateTime.utc(2000, 3) }); - var expectedRow1 = [1, 1, 2, 1, true, 3, "foobar", 5.0, 6.0, new DateTime.utc(2000), new DateTime.utc(2000, 2), new DateTime.utc(2000, 3)]; + final expectedRow1 = [ + 1, + 1, + 2, + 1, + true, + 3, + 'foobar', + 5.0, + 6.0, + DateTime.utc(2000), + DateTime.utc(2000, 2), + DateTime.utc(2000, 3) + ]; expect(results, [expectedRow1]); results = await connection.query(insertQueryString, substitutionValues: { - "i" : 2, - "bi" : 3, - "bl" : false, - "si" : 4, - "t" : "barfoo", - "f" : 6.0, - "d" : 7.0, - "dt" : new DateTime.utc(2001), - "ts" : new DateTime.utc(2001, 2), - "tsz" : new DateTime.utc(2001, 3) + 'i': 2, + 'bi': 3, + 'bl': false, + 'si': 4, + 't': 'barfoo', + 'f': 6.0, + 'd': 7.0, + 'dt': DateTime.utc(2001), + 'ts': DateTime.utc(2001, 2), + 'tsz': DateTime.utc(2001, 3) }); - var expectedRow2 = [2, 2, 3, 2, false, 4, "barfoo", 6.0, 7.0, new DateTime.utc(2001), new DateTime.utc(2001, 2), new DateTime.utc(2001, 3)]; + final expectedRow2 = [ + 2, + 2, + 3, + 2, + false, + 4, + 'barfoo', + 6.0, + 7.0, + DateTime.utc(2001), + DateTime.utc(2001, 2), + DateTime.utc(2001, 3) + ]; expect(results, [expectedRow2]); }); - test("Call query multiple times with partial parameter type info succeeds", () async { - var insertQueryString = "INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) VALUES " - "(${sid("i", PostgreSQLDataType.integer)}, @bi," - "${sid("bl", PostgreSQLDataType.boolean)}, @si," - "${sid("t", PostgreSQLDataType.text)}, @f," - "${sid("d", PostgreSQLDataType.double)}, @dt," - "${sid("ts", PostgreSQLDataType.timestampWithoutTimezone)}, @tsz" - ") returning i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz"; - var results = await connection.query(insertQueryString, substitutionValues: { - "i" : 1, - "bi" : 2, - "bl" : true, - "si" : 3, - "t" : "foobar", - "f" : 5.0, - "d" : 6.0, - "dt" : new DateTime.utc(2000), - "ts" : new DateTime.utc(2000, 2), - "tsz" : new DateTime.utc(2000, 3) + test('Call query multiple times with partial parameter type info succeeds', + () async { + final insertQueryString = + 'INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) VALUES ' + '(${sid('i', PostgreSQLDataType.integer)}, @bi,' + '${sid('bl', PostgreSQLDataType.boolean)}, @si,' + '${sid('t', PostgreSQLDataType.text)}, @f,' + '${sid('d', PostgreSQLDataType.double)}, @dt,' + '${sid('ts', PostgreSQLDataType.timestampWithoutTimezone)}, @tsz' + ') returning i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz'; + var results = + await connection.query(insertQueryString, substitutionValues: { + 'i': 1, + 'bi': 2, + 'bl': true, + 'si': 3, + 't': 'foobar', + 'f': 5.0, + 'd': 6.0, + 'dt': DateTime.utc(2000), + 'ts': DateTime.utc(2000, 2), + 'tsz': DateTime.utc(2000, 3) }); - var expectedRow1 = [1, 1, 2, 1, true, 3, "foobar", 5.0, 6.0, new DateTime.utc(2000), new DateTime.utc(2000, 2), new DateTime.utc(2000, 3)]; + final expectedRow1 = [ + 1, + 1, + 2, + 1, + true, + 3, + 'foobar', + 5.0, + 6.0, + DateTime.utc(2000), + DateTime.utc(2000, 2), + DateTime.utc(2000, 3) + ]; expect(results, [expectedRow1]); results = await connection.query(insertQueryString, substitutionValues: { - "i" : 2, - "bi" : 3, - "bl" : false, - "si" : 4, - "t" : "barfoo", - "f" : 6.0, - "d" : 7.0, - "dt" : new DateTime.utc(2001), - "ts" : new DateTime.utc(2001, 2), - "tsz" : new DateTime.utc(2001, 3) + 'i': 2, + 'bi': 3, + 'bl': false, + 'si': 4, + 't': 'barfoo', + 'f': 6.0, + 'd': 7.0, + 'dt': DateTime.utc(2001), + 'ts': DateTime.utc(2001, 2), + 'tsz': DateTime.utc(2001, 3) }); - var expectedRow2 = [2, 2, 3, 2, false, 4, "barfoo", 6.0, 7.0, new DateTime.utc(2001), new DateTime.utc(2001, 2), new DateTime.utc(2001, 3)]; + final expectedRow2 = [ + 2, + 2, + 3, + 2, + false, + 4, + 'barfoo', + 6.0, + 7.0, + DateTime.utc(2001), + DateTime.utc(2001, 2), + DateTime.utc(2001, 3) + ]; expect(results, [expectedRow2]); - results = await connection.query("select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t"); + results = await connection + .query('select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t'); expect(results, [expectedRow1, expectedRow2]); - results = await connection.query("select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i", substitutionValues: { - "i" : 0 - }); + results = await connection.query( + 'select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i', + substitutionValues: {'i': 0}); expect(results, []); - results = await connection.query("select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i", substitutionValues: { - "i" : 2 - }); + results = await connection.query( + 'select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i', + substitutionValues: {'i': 2}); expect(results, [expectedRow1]); - results = await connection.query("select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i", substitutionValues: { - "i" : 5 - }); + results = await connection.query( + 'select i, s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t where i < @i', + substitutionValues: {'i': 5}); expect(results, [expectedRow1, expectedRow2]); }); }); - group("Mixing prepared statements", () { + group('Mixing prepared statements', () { PostgreSQLConnection connection; setUp(() async { - connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await connection.open(); - await connection.execute("CREATE TEMPORARY TABLE t (i1 int not null, i2 int not null)"); - await connection.execute("INSERT INTO t (i1, i2) VALUES (0, 1)"); - await connection.execute("INSERT INTO t (i1, i2) VALUES (1, 2)"); - await connection.execute("INSERT INTO t (i1, i2) VALUES (2, 3)"); - await connection.execute("INSERT INTO t (i1, i2) VALUES (3, 4)"); + await connection.execute( + 'CREATE TEMPORARY TABLE t (i1 int not null, i2 int not null)'); + await connection.execute('INSERT INTO t (i1, i2) VALUES (0, 1)'); + await connection.execute('INSERT INTO t (i1, i2) VALUES (1, 2)'); + await connection.execute('INSERT INTO t (i1, i2) VALUES (2, 3)'); + await connection.execute('INSERT INTO t (i1, i2) VALUES (3, 4)'); }); tearDown(() async { await connection.close(); }); - test("Call query multiple times, mixing in unnammed queries, succeeds", () async { - var results = await connection.query("select i1, i2 from t where i1 > @i1", substitutionValues: { - "i1" : 1 - }); - expect(results, [[2, 3], [3, 4]]); - - results = await connection.query("select i1,i2 from t where i1 > @i1", substitutionValues: { - "i1" : 1 - }, allowReuse: false); - expect(results, [[2, 3], [3, 4]]); - - results = await connection.query("select i1, i2 from t where i1 > @i1", substitutionValues: { - "i1" : 2 - }); - expect(results, [[3, 4]]); - - results = await connection.query("select i1,i2 from t where i1 > @i1", substitutionValues: { - "i1" : 0 - }, allowReuse: false); - expect(results, [[1, 2], [2, 3], [3, 4]]); - - results = await connection.query("select i1, i2 from t where i1 > @i1", substitutionValues: { - "i1" : 2 - }); - expect(results, [[3, 4]]); - - expect(hasCachedQueryNamed(connection, "select i1, i2 from t where i1 > @i1"), true); - expect(cachedQueryMap(connection).length, 1); + test('Call query multiple times, mixing in unnammed queries, succeeds', + () async { + var results = await connection.query( + 'select i1, i2 from t where i1 > @i1', + substitutionValues: {'i1': 1}); + expect(results, [ + [2, 3], + [3, 4] + ]); + + results = await connection.query('select i1,i2 from t where i1 > @i1', + substitutionValues: {'i1': 1}, allowReuse: false); + expect(results, [ + [2, 3], + [3, 4] + ]); + + results = await connection.query('select i1, i2 from t where i1 > @i1', + substitutionValues: {'i1': 2}); + expect(results, [ + [3, 4] + ]); + + results = await connection.query('select i1,i2 from t where i1 > @i1', + substitutionValues: {'i1': 0}, allowReuse: false); + expect(results, [ + [1, 2], + [2, 3], + [3, 4] + ]); + + results = await connection.query('select i1, i2 from t where i1 > @i1', + substitutionValues: {'i1': 2}); + expect(results, [ + [3, 4] + ]); + + expect( + hasCachedQueryNamed( + connection, 'select i1, i2 from t where i1 > @i1'), + true); + expect(getQueryCache(connection).length, 1); }); - test("Call query multiple times, mixing in other named queries, succeeds", () async { - var results = await connection.query("select i1, i2 from t where i1 > @i1", substitutionValues: { - "i1" : 1 - }); - expect(results, [[2, 3], [3, 4]]); - - results = await connection.query("select i1,i2 from t where i2 < @i2", substitutionValues: { - "i2" : 1 - }); + test('Call query multiple times, mixing in other named queries, succeeds', + () async { + var results = await connection.query( + 'select i1, i2 from t where i1 > @i1', + substitutionValues: {'i1': 1}); + expect(results, [ + [2, 3], + [3, 4] + ]); + + results = await connection.query('select i1,i2 from t where i2 < @i2', + substitutionValues: {'i2': 1}); expect(results, []); - results = await connection.query("select i1, i2 from t where i1 > @i1", substitutionValues: { - "i1" : 2 - }); - expect(results, [[3, 4]]); - - results = await connection.query("select i1,i2 from t where i2 < @i2", substitutionValues: { - "i2" : 2 - }); - expect(results, [[0, 1]]); - - results = await connection.query("select i1, i2 from t where i1 > @i1", substitutionValues: { - "i1" : 2 - }); - expect(results, [[3, 4]]); - - expect(hasCachedQueryNamed(connection, "select i1, i2 from t where i1 > @i1"), true); - expect(hasCachedQueryNamed(connection, "select i1,i2 from t where i2 < @i2"), true); - expect(cachedQueryMap(connection).length, 2); + results = await connection.query('select i1, i2 from t where i1 > @i1', + substitutionValues: {'i1': 2}); + expect(results, [ + [3, 4] + ]); + + results = await connection.query('select i1,i2 from t where i2 < @i2', + substitutionValues: {'i2': 2}); + expect(results, [ + [0, 1] + ]); + + results = await connection.query('select i1, i2 from t where i1 > @i1', + substitutionValues: {'i1': 2}); + expect(results, [ + [3, 4] + ]); + + expect( + hasCachedQueryNamed( + connection, 'select i1, i2 from t where i1 > @i1'), + true); + expect( + hasCachedQueryNamed(connection, 'select i1,i2 from t where i2 < @i2'), + true); + expect(getQueryCache(connection).length, 2); }); - test("Call a bunch of named and unnamed queries without awaiting, still process correctly", () async { - var futures = [ - connection.query("select i1, i2 from t where i1 > @i1", substitutionValues: { - "i1" : 1 - }), - connection.execute("select 1"), - connection.query("select i1,i2 from t where i2 < @i2", substitutionValues: { - "i2" : 1 - }), - connection.query("select i1, i2 from t where i1 > @i1", substitutionValues: { - "i1" : 2 - }), - connection.query("select 1", allowReuse: false), - connection.query("select i1,i2 from t where i2 < @i2", substitutionValues: { - "i2" : 2 - }), - connection.query("select i1, i2 from t where i1 > @i1", substitutionValues: { - "i1" : 2 - }) + test( + 'Call a bunch of named and unnamed queries without awaiting, still process correctly', + () async { + final futures = [ + connection.query('select i1, i2 from t where i1 > @i1', + substitutionValues: {'i1': 1}), + connection.execute('select 1'), + connection.query('select i1,i2 from t where i2 < @i2', + substitutionValues: {'i2': 1}), + connection.query('select i1, i2 from t where i1 > @i1', + substitutionValues: {'i1': 2}), + connection.query('select 1', allowReuse: false), + connection.query('select i1,i2 from t where i2 < @i2', + substitutionValues: {'i2': 2}), + connection.query('select i1, i2 from t where i1 > @i1', + substitutionValues: {'i1': 2}) ]; - var results = await Future.wait(futures); - expect(results, [[[2, 3], [3, 4]], 1, [], [[3, 4]], [[1]], [[0, 1]], [[3, 4]]]); + final results = await Future.wait(futures); + expect(results, [ + [ + [2, 3], + [3, 4] + ], + 1, + [], + [ + [3, 4] + ], + [ + [1] + ], + [ + [0, 1] + ], + [ + [3, 4] + ] + ]); }); - test("Make a prepared query that has no parameters", () async { - var results = await connection.query("select 1"); - expect(results, [[1]]); + test('Make a prepared query that has no parameters', () async { + var results = await connection.query('select 1'); + expect(results, [ + [1] + ]); - results = await connection.query("select 1"); - expect(results, [[1]]); + results = await connection.query('select 1'); + expect(results, [ + [1] + ]); }); }); - group("Failure cases", () { - var connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + group('Failure cases', () { + var connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); setUp(() async { - connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await connection.open(); - await connection.execute("CREATE TEMPORARY TABLE t (i int, s serial, bi bigint, bs bigserial, bl boolean, si smallint, t text, f real, d double precision, dt date, ts timestamp, tsz timestamptz)"); - await connection.execute("CREATE TEMPORARY TABLE u (i1 int not null, i2 int not null);"); - await connection.execute("CREATE TEMPORARY TABLE n (i1 int, i2 int not null);"); + await connection.execute( + 'CREATE TEMPORARY TABLE t (i int, s serial, bi bigint, bs bigserial, bl boolean, si smallint, t text, f real, d double precision, dt date, ts timestamp, tsz timestamptz)'); + await connection.execute( + 'CREATE TEMPORARY TABLE u (i1 int not null, i2 int not null);'); + await connection + .execute('CREATE TEMPORARY TABLE n (i1 int, i2 int not null);'); }); tearDown(() async { await connection.close(); }); - test("A failed parse does not generate cached query", () async { + test('A failed parse does not generate cached query', () async { try { - await connection.query("ljkasd"); + await connection.query('ljkasd'); expect(true, false); - } on PostgreSQLException {} + } on PostgreSQLException { + // ignore + } - expect(cachedQueryMap(connection).isEmpty, true); + expect(getQueryCache(connection).isEmpty, true); }); - test("Trying to parse/describe a query with inaccurate types fails and does not cache query", () async { - var string = "insert into u (i1, i2) values (@i1:text, @i2:text) returning i1, i2"; + test( + 'Trying to parse/describe a query with inaccurate types fails and does not cache query', + () async { + final string = + 'insert into u (i1, i2) values (@i1:text, @i2:text) returning i1, i2'; try { - await connection.query(string, substitutionValues: { - "i1" : "foo", "i2" : "bar" - }); + await connection + .query(string, substitutionValues: {'i1': 'foo', 'i2': 'bar'}); expect(true, false); - } on PostgreSQLException {} + } on PostgreSQLException { + // ignore + } - expect(cachedQueryMap(connection).length, 0); + expect(getQueryCache(connection).length, 0); }); - test("A failed bind on initial query fails query, but cached query is available", () async { - var string = "insert into u (i1, i2) values (@i1, @i2) returning i1, i2"; + test( + 'A failed bind on initial query fails query, but can still make query later', + () async { + final string = + 'insert into u (i1, i2) values (@i1, @i2) returning i1, i2'; try { - await connection.query(string, substitutionValues: { - "i1" : "foo", "i2" : "bar" - }); + await connection + .query(string, substitutionValues: {'i1': 'foo', 'i2': 'bar'}); expect(true, false); - } on PostgreSQLException {} + } on PostgreSQLException { + // ignores + } - expect(hasCachedQueryNamed(connection, string), true); + expect(hasCachedQueryNamed(connection, string), false); - var results = await connection.query("select i1, i2 from u"); + var results = await connection.query('select i1, i2 from u'); expect(results, []); - await connection.query(string, substitutionValues: { - "i1" : 1, - "i2" : 2 - }); - results = await connection.query("select i1, i2 from u"); - expect(results, [[1, 2]]); + await connection.query(string, substitutionValues: {'i1': 1, 'i2': 2}); + results = await connection.query('select i1, i2 from u'); + expect(results, [ + [1, 2] + ]); + expect(hasCachedQueryNamed(connection, string), true); }); - test("Cached query that works the first time, wrong type for params the next time throws early error but can still be used", () async { - await connection.query("insert into u (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2", substitutionValues: { - "i1" : 1, "i2" : 2 - }); - await connection.query("insert into u (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2", substitutionValues: { - "i1" : 2, "i2" : 3 - }); - - var string = "select i1, i2 from u where i1 = @i:int4"; - var results = await connection.query(string, substitutionValues: { - "i" : 1 - }); - expect(results, [[1, 2]]); + test( + 'Cached query that works the first time, wrong type for params the next time throws early error but can still be used', + () async { + await connection.query( + 'insert into u (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2', + substitutionValues: {'i1': 1, 'i2': 2}); + await connection.query( + 'insert into u (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2', + substitutionValues: {'i1': 2, 'i2': 3}); + + final string = 'select i1, i2 from u where i1 = @i:int4'; + var results = + await connection.query(string, substitutionValues: {'i': 1}); + expect(results, [ + [1, 2] + ]); expect(hasCachedQueryNamed(connection, string), true); try { - await connection.query(string, substitutionValues: { - "i" : "foo" - }); - } on FormatException {} - - results = await connection.query(string, substitutionValues: { - "i" : 2 - }); - expect(results, [[2, 3]]); + await connection.query(string, substitutionValues: {'i': 'foo'}); + } on FormatException { + // ignore + } + + results = await connection.query(string, substitutionValues: {'i': 2}); + expect(results, [ + [2, 3] + ]); expect(hasCachedQueryNamed(connection, string), true); }); - test("Send two queries that will be the same prepared statement async, first one fails on bind", () async { - await connection.query("insert into u (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2", substitutionValues: { - "i1" : 1, "i2" : 2 - }, allowReuse: false); - - var string = "select i1, i2 from u where i1 = @i:int4"; - connection.query(string, substitutionValues: { - "i" : "foo" - }).catchError((e) {}); - - var results = await connection.query(string, substitutionValues: { - "i" : 1 - }); - - expect(results, [[1, 2]]); - expect(cachedQueryMap(connection).length, 1); + test( + 'Send two queries that will be the same prepared statement async, first one fails on bind', + () async { + await connection.query( + 'insert into u (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2', + substitutionValues: {'i1': 1, 'i2': 2}, + allowReuse: false); + + final string = 'select i1, i2 from u where i1 = @i:int4'; + // ignore: unawaited_futures + connection + .query(string, substitutionValues: {'i': 'foo'}).catchError((e) {}); + + final results = + await connection.query(string, substitutionValues: {'i': 1}); + + expect(results, [ + [1, 2] + ]); + expect(getQueryCache(connection).length, 1); expect(hasCachedQueryNamed(connection, string), true); }); }); } -Map cachedQueryMap(PostgreSQLConnection connection) { - var reuseMapMirror = reflect(connection).type - .declarations.values - .firstWhere((DeclarationMirror dm) => dm.simpleName.toString().contains("_reuseMap")); - return reflect(connection).getField(reuseMapMirror.simpleName).reflectee as Map; +QueryCache getQueryCache(PostgreSQLConnection connection) { + final cacheMirror = reflect(connection).type.declarations.values.firstWhere( + (DeclarationMirror dm) => dm.simpleName.toString().contains('_cache')); + return reflect(connection).getField(cacheMirror.simpleName).reflectee + as QueryCache; } bool hasCachedQueryNamed(PostgreSQLConnection connection, String name) { - return cachedQueryMap(connection)[name] != null; -} \ No newline at end of file + return getQueryCache(connection)[name] != null; +} diff --git a/test/query_test.dart b/test/query_test.dart index c9a09de..9627632 100644 --- a/test/query_test.dart +++ b/test/query_test.dart @@ -1,194 +1,515 @@ import 'package:postgres/postgres.dart'; import 'package:test/test.dart'; +import 'package:postgres/src/types.dart'; void main() { - group("Successful queries", () { - var connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + group('Successful queries', () { + var connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); setUp(() async { - connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await connection.open(); - await connection.execute("CREATE TEMPORARY TABLE t (i int, s serial, bi bigint, bs bigserial, bl boolean, si smallint, t text, f real, d double precision, dt date, ts timestamp, tsz timestamptz)"); - await connection.execute("CREATE TEMPORARY TABLE u (i1 int not null, i2 int not null);"); - await connection.execute("CREATE TEMPORARY TABLE n (i1 int, i2 int not null);"); + await connection.execute('CREATE TEMPORARY TABLE t ' + '(i int, s serial, bi bigint, ' + 'bs bigserial, bl boolean, si smallint, ' + 't text, f real, d double precision, ' + 'dt date, ts timestamp, tsz timestamptz, j jsonb, u uuid)'); + await connection.execute( + 'CREATE TEMPORARY TABLE u (i1 int not null, i2 int not null);'); + await connection + .execute('CREATE TEMPORARY TABLE n (i1 int, i2 int not null);'); }); tearDown(() async { await connection.close(); }); - test("Query without specifying types", () async { - var result = await connection.query("INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) values " - "(${PostgreSQLFormat.id("i")}," - "${PostgreSQLFormat.id("bi")}," - "${PostgreSQLFormat.id("bl")}," - "${PostgreSQLFormat.id("si")}," - "${PostgreSQLFormat.id("t")}," - "${PostgreSQLFormat.id("f")}," - "${PostgreSQLFormat.id("d")}," - "${PostgreSQLFormat.id("dt")}," - "${PostgreSQLFormat.id("ts")}," - "${PostgreSQLFormat.id("tsz")}) returning i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz", + test('UTF16 strings in value', () async { + var result = await connection.query( + 'INSERT INTO t (t) values ' + "(${PostgreSQLFormat.id("t", type: PostgreSQLDataType.text)})" + 'returning t', substitutionValues: { - "i" : 1, - "bi" : 2, - "bl" : true, - "si" : 3, - "t" : "foobar", - "f" : 5.0, - "d" : 6.0, - "dt" : new DateTime.utc(2000), - "ts" : new DateTime.utc(2000, 2), - "tsz" : new DateTime.utc(2000, 3), + 't': '°∆', }); - var expectedRow = [1, 1, 2, 1, true, 3, "foobar", 5.0, 6.0, new DateTime.utc(2000), new DateTime.utc(2000, 2), new DateTime.utc(2000, 3)]; + final expectedRow = ['°∆']; expect(result, [expectedRow]); - result = await connection.query("select i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t"); + + result = await connection.query('select t from t'); + expect(result.columnDescriptions, hasLength(1)); + expect(result.columnDescriptions.single.tableName, 't'); + expect(result.columnDescriptions.single.columnName, 't'); + expect(result, [expectedRow]); + }); + + test('UTF16 strings in query', () async { + var result = + await connection.query("INSERT INTO t (t) values ('°∆') RETURNING t"); + + final expectedRow = ['°∆']; + expect(result, [expectedRow]); + + result = await connection.query('select t from t'); expect(result, [expectedRow]); }); - test("Query by specifying all types", () async { - var result = await connection.query("INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) values " - "(${PostgreSQLFormat.id("i", type: PostgreSQLDataType.integer)}," - "${PostgreSQLFormat.id("bi", type: PostgreSQLDataType.bigInteger)}," - "${PostgreSQLFormat.id("bl", type: PostgreSQLDataType.boolean)}," - "${PostgreSQLFormat.id("si", type: PostgreSQLDataType.smallInteger)}," - "${PostgreSQLFormat.id("t", type: PostgreSQLDataType.text)}," - "${PostgreSQLFormat.id("f", type: PostgreSQLDataType.real)}," - "${PostgreSQLFormat.id("d", type: PostgreSQLDataType.double)}," - "${PostgreSQLFormat.id("dt", type: PostgreSQLDataType.date)}," - "${PostgreSQLFormat.id("ts", type: PostgreSQLDataType.timestampWithoutTimezone)}," - "${PostgreSQLFormat.id("tsz", type: PostgreSQLDataType.timestampWithTimezone)}) returning i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz", + test('UTF16 strings in value with escape characters', () async { + await connection.execute( + 'INSERT INTO t (t) values ' + '(${PostgreSQLFormat.id('t', type: PostgreSQLDataType.text)})', substitutionValues: { - "i" : 1, - "bi" : 2, - "bl" : true, - "si" : 3, - "t" : "foobar", - "f" : 5.0, - "d" : 6.0, - "dt" : new DateTime.utc(2000), - "ts" : new DateTime.utc(2000, 2), - "tsz" : new DateTime.utc(2000, 3), + 't': "'©™®'", }); - var expectedRow = [1, 1, 2, 1, true, 3, "foobar", 5.0, 6.0, new DateTime.utc(2000), new DateTime.utc(2000, 2), new DateTime.utc(2000, 3)]; + final expectedRow = ["'©™®'"]; + + final result = await connection.query('select t from t'); expect(result, [expectedRow]); + }); - result = await connection.query("select i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t"); + test('UTF16 strings in value with backslash', () async { + await connection.execute( + 'INSERT INTO t (t) values ' + '(${PostgreSQLFormat.id('t', type: PostgreSQLDataType.text)})', + substitutionValues: { + 't': "°\\'©™®'", + }); + + final expectedRow = ["°\\'©™®'"]; + + final result = await connection.query('select t from t'); expect(result, [expectedRow]); }); - test("Query by specifying some types", () async { - var result = await connection.query("INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) values " - "(${PostgreSQLFormat.id("i")}," - "${PostgreSQLFormat.id("bi", type: PostgreSQLDataType.bigInteger)}," - "${PostgreSQLFormat.id("bl")}," - "${PostgreSQLFormat.id("si", type: PostgreSQLDataType.smallInteger)}," - "${PostgreSQLFormat.id("t")}," - "${PostgreSQLFormat.id("f", type: PostgreSQLDataType.real)}," - "${PostgreSQLFormat.id("d")}," - "${PostgreSQLFormat.id("dt", type: PostgreSQLDataType.date)}," - "${PostgreSQLFormat.id("ts")}," - "${PostgreSQLFormat.id("tsz", type: PostgreSQLDataType.timestampWithTimezone)}) returning i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz", + test('UTF16 strings in query with escape characters', () async { + await connection.execute("INSERT INTO t (t) values ('°''©™®''')"); + + final expectedRow = ["°'©™®'"]; + + final result = await connection.query('select t from t'); + expect(result, [expectedRow]); + }); + + test('Really long raw substitution value', () async { + final result = await connection.query( + "INSERT INTO t (t) VALUES (${PostgreSQLFormat.id("t", type: PostgreSQLDataType.text)}) returning t;", + substitutionValues: {'t': lorumIpsum}); + expect(result, [ + [lorumIpsum] + ]); + }); + + test('Really long SQL string in execute', () async { + final result = await connection + .execute("INSERT INTO t (t) VALUES ('$lorumIpsum') returning t;"); + expect(result, 1); + }); + + test('Query without specifying types', () async { + var result = await connection.query( + 'INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz, j, u) values ' + '(${PostgreSQLFormat.id('i')},' + '${PostgreSQLFormat.id('bi')},' + '${PostgreSQLFormat.id('bl')},' + '${PostgreSQLFormat.id('si')},' + '${PostgreSQLFormat.id('t')},' + '${PostgreSQLFormat.id('f')},' + '${PostgreSQLFormat.id('d')},' + '${PostgreSQLFormat.id('dt')},' + '${PostgreSQLFormat.id('ts')},' + '${PostgreSQLFormat.id('tsz')},' + '${PostgreSQLFormat.id('j')},' + '${PostgreSQLFormat.id('u')}' + ') returning i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz, j, u', substitutionValues: { - "i" : 1, - "bi" : 2, - "bl" : true, - "si" : 3, - "t" : "foobar", - "f" : 5.0, - "d" : 6.0, - "dt" : new DateTime.utc(2000), - "ts" : new DateTime.utc(2000, 2), - "tsz" : new DateTime.utc(2000, 3), + 'i': 1, + 'bi': 2, + 'bl': true, + 'si': 3, + 't': 'foobar', + 'f': 5.0, + 'd': 6.0, + 'dt': DateTime.utc(2000), + 'ts': DateTime.utc(2000, 2), + 'tsz': DateTime.utc(2000, 3), + 'j': {'a': 'b'}, + 'u': '01234567-89ab-cdef-0123-0123456789ab' }); - var expectedRow = [1, 1, 2, 1, true, 3, "foobar", 5.0, 6.0, new DateTime.utc(2000), new DateTime.utc(2000, 2), new DateTime.utc(2000, 3)]; + final expectedRow = [ + 1, + 1, + 2, + 1, + true, + 3, + 'foobar', + 5.0, + 6.0, + DateTime.utc(2000), + DateTime.utc(2000, 2), + DateTime.utc(2000, 3), + {'a': 'b'}, + '01234567-89ab-cdef-0123-0123456789ab' + ]; + expect(result.columnDescriptions, hasLength(14)); + expect(result.columnDescriptions.first.tableName, 't'); + expect(result.columnDescriptions.first.columnName, 'i'); + expect(result.columnDescriptions.last.tableName, 't'); + expect(result.columnDescriptions.last.columnName, 'u'); expect(result, [expectedRow]); - result = await connection.query("select i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t"); + result = await connection.query( + 'select i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz, j, u from t'); expect(result, [expectedRow]); }); - test("Can supply null for values (binary)", () async { - var results = await connection.query("INSERT INTO n (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2", substitutionValues: { - "i1" : null, - "i2" : 1, - }); + test('Query by specifying all types', () async { + var result = await connection.query( + 'INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz, j, u) values ' + '(${PostgreSQLFormat.id('i', type: PostgreSQLDataType.integer)},' + '${PostgreSQLFormat.id('bi', type: PostgreSQLDataType.bigInteger)},' + '${PostgreSQLFormat.id('bl', type: PostgreSQLDataType.boolean)},' + '${PostgreSQLFormat.id('si', type: PostgreSQLDataType.smallInteger)},' + '${PostgreSQLFormat.id('t', type: PostgreSQLDataType.text)},' + '${PostgreSQLFormat.id('f', type: PostgreSQLDataType.real)},' + '${PostgreSQLFormat.id('d', type: PostgreSQLDataType.double)},' + '${PostgreSQLFormat.id('dt', type: PostgreSQLDataType.date)},' + '${PostgreSQLFormat.id('ts', type: PostgreSQLDataType.timestampWithoutTimezone)},' + '${PostgreSQLFormat.id('tsz', type: PostgreSQLDataType.timestampWithTimezone)},' + '${PostgreSQLFormat.id('j', type: PostgreSQLDataType.json)},' + '${PostgreSQLFormat.id('u', type: PostgreSQLDataType.uuid)})' + ' returning i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz, j, u', + substitutionValues: { + 'i': 1, + 'bi': 2, + 'bl': true, + 'si': 3, + 't': 'foobar', + 'f': 5.0, + 'd': 6.0, + 'dt': DateTime.utc(2000), + 'ts': DateTime.utc(2000, 2), + 'tsz': DateTime.utc(2000, 3), + 'j': {'key': 'value'}, + 'u': '01234567-89ab-cdef-0123-0123456789ab' + }); + + final expectedRow = [ + 1, + 1, + 2, + 1, + true, + 3, + 'foobar', + 5.0, + 6.0, + DateTime.utc(2000), + DateTime.utc(2000, 2), + DateTime.utc(2000, 3), + {'key': 'value'}, + '01234567-89ab-cdef-0123-0123456789ab' + ]; + expect(result, [expectedRow]); - expect(results, [[null, 1]]); + result = await connection.query( + 'select i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz, j, u from t'); + expect(result, [expectedRow]); }); - test("Can supply null for values (text)", () async { - var results = await connection.query("INSERT INTO n (i1, i2) values (@i1, @i2:int4) returning i1, i2", substitutionValues: { - "i1" : null, - "i2" : 1, - }); + test('Query by specifying some types', () async { + var result = await connection.query( + 'INSERT INTO t (i, bi, bl, si, t, f, d, dt, ts, tsz) values ' + '(${PostgreSQLFormat.id('i')},' + '${PostgreSQLFormat.id('bi', type: PostgreSQLDataType.bigInteger)},' + '${PostgreSQLFormat.id('bl')},' + '${PostgreSQLFormat.id('si', type: PostgreSQLDataType.smallInteger)},' + '${PostgreSQLFormat.id('t')},' + '${PostgreSQLFormat.id('f', type: PostgreSQLDataType.real)},' + '${PostgreSQLFormat.id('d')},' + '${PostgreSQLFormat.id('dt', type: PostgreSQLDataType.date)},' + '${PostgreSQLFormat.id('ts')},' + '${PostgreSQLFormat.id('tsz', type: PostgreSQLDataType.timestampWithTimezone)}) returning i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz', + substitutionValues: { + 'i': 1, + 'bi': 2, + 'bl': true, + 'si': 3, + 't': 'foobar', + 'f': 5.0, + 'd': 6.0, + 'dt': DateTime.utc(2000), + 'ts': DateTime.utc(2000, 2), + 'tsz': DateTime.utc(2000, 3), + }); + + final expectedRow = [ + 1, + 1, + 2, + 1, + true, + 3, + 'foobar', + 5.0, + 6.0, + DateTime.utc(2000), + DateTime.utc(2000, 2), + DateTime.utc(2000, 3) + ]; + expect(result, [expectedRow]); + result = await connection + .query('select i,s, bi, bs, bl, si, t, f, d, dt, ts, tsz from t'); + expect(result, [expectedRow]); + }); + + test('Can supply null for values (binary)', () async { + final results = await connection.query( + 'INSERT INTO n (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2', + substitutionValues: { + 'i1': null, + 'i2': 1, + }); + + expect(results, [ + [null, 1] + ]); + }); + + test('Can supply null for values (text)', () async { + final results = await connection.query( + 'INSERT INTO n (i1, i2) values (@i1, @i2:int4) returning i1, i2', + substitutionValues: { + 'i1': null, + 'i2': 1, + }); - expect(results, [[null, 1]]); + expect(results, [ + [null, 1] + ]); }); - test("Overspecifying parameters does not impact query (text)", () async { - var results = await connection.query("INSERT INTO u (i1, i2) values (@i1, @i2) returning i1, i2", substitutionValues: { - "i1" : 0, - "i2" : 1, - "i3" : 0, - }); + test('Overspecifying parameters does not impact query (text)', () async { + final results = await connection.query( + 'INSERT INTO u (i1, i2) values (@i1, @i2) returning i1, i2', + substitutionValues: { + 'i1': 0, + 'i2': 1, + 'i3': 0, + }); - expect(results, [[0, 1]]); + expect(results, [ + [0, 1] + ]); }); - test("Overspecifying parameters does not impact query (binary)", () async { - var results = await connection.query("INSERT INTO u (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2", substitutionValues: { - "i1" : 0, - "i2" : 1, - "i3" : 0, - }); + test('Overspecifying parameters does not impact query (binary)', () async { + final results = await connection.query( + 'INSERT INTO u (i1, i2) values (@i1:int4, @i2:int4) returning i1, i2', + substitutionValues: { + 'i1': 0, + 'i2': 1, + 'i3': 0, + }); - expect(results, [[0, 1]]); + expect(results, [ + [0, 1] + ]); + }); + + test('Can cast text to int on db server', () async { + final results = await connection.query( + 'INSERT INTO u (i1, i2) VALUES (@i1::int4, @i2::int4) RETURNING i1, i2', + substitutionValues: {'i1': '0', 'i2': '1'}); + + expect(results, [ + [0, 1] + ]); }); }); - group("Unsuccesful queries", () { - var connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + group('Unsuccesful queries', () { + var connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); setUp(() async { - connection = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + connection = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await connection.open(); - await connection.execute("CREATE TEMPORARY TABLE t (i1 int not null, i2 int not null)"); + await connection.execute( + 'CREATE TEMPORARY TABLE t (i1 int not null, i2 int not null)'); }); tearDown(() async { await connection.close(); }); - test("A query that fails on the server will report back an exception through the query method", () async { + test( + 'A query that fails on the server will report back an exception through the query method', + () async { try { - await connection.query("INSERT INTO t (i1) values (@i1)", substitutionValues: { - "i1" : 0 - }); + await connection.query('INSERT INTO t (i1) values (@i1)', + substitutionValues: {'i1': 0}); expect(true, false); } on PostgreSQLException catch (e) { expect(e.severity, PostgreSQLSeverity.error); - expect(e.message, contains("null value in column \"i2\"")); + expect(e.message, contains('null value in column "i2"')); } }); - test("Not enough parameters to support format string throws error prior to sending to server", () async { + test( + 'Missing substitution value does not throw, query is sent to the server without changing that part.', + () async { + final rs1 = await connection + .query('SELECT * FROM (VALUES (\'user@domain.com\')) t1 (col1)'); + expect(rs1.first.toColumnMap(), {'col1': 'user@domain.com'}); + + final rs2 = await connection.query( + 'SELECT * FROM (VALUES (\'user@domain.com\')) t1 (col1) WHERE col1 > @u1', + substitutionValues: {'u1': 'hello@domain.com'}, + ); + expect(rs2.first.toColumnMap(), {'col1': 'user@domain.com'}); + }); + + test('Wrong type for parameter in substitution values fails', () async { try { - await connection.query("INSERT INTO t (i1) values (@i1)", substitutionValues: {}); + await connection.query( + 'INSERT INTO t (i1, i2) values (@i1:int4, @i2:int4)', + substitutionValues: {'i1': '1', 'i2': 1}); expect(true, false); } on FormatException catch (e) { - expect(e.message, contains("Format string specified identifier with name i1")); + expect(e.toString(), contains('Invalid type for parameter value')); } + }); + test('Invalid type code', () async { try { - await connection.query("INSERT INTO t (i1) values (@i1)"); + await connection.query( + 'INSERT INTO t (i1, i2) values (@i1:qwerty, @i2:int4)', + substitutionValues: {'i1': '1', 'i2': 1}); expect(true, false); } on FormatException catch (e) { - expect(e.message, contains("Format string specified identifier with name i1")); + expect(e.toString(), contains('Invalid type code')); + expect(e.toString(), contains("'@i1:qwerty")); } }); }); -} \ No newline at end of file +} + +const String lorumIpsum = '''Lorem + ipsum dolor sit amet, consectetur adipiscing elit. Quisque in accumsan + felis. Nunc semper velit purus, a pellentesque mauris aliquam ut. Sed + laoreet iaculis nunc sit amet dignissim. Aenean venenatis sollicitudin + justo, quis imperdiet diam fringilla quis. Fusce nec mauris imperdiet + dui iaculis consequat. Integer convallis justo a neque finibus imperdiet + et nec sem. In laoreet quis ante eget pellentesque. Nunc posuere faucibus + nibh eu aliquet. Aliquam rutrum posuere nisi, ut maximus mauris tincidunt + at. Integer fermentum venenatis viverra. Vivamus non magna malesuada, + ullamcorper neque ut, auctor justo. Donec ut mattis elit, eget varius urna. + Vestibulum consectetur aliquet semper. Nullam pellentesque nunc quis risus + rutrum viverra. Fusce porta tortor in neque maximus efficitur. Aenean + euismod sollicitudin neque a tristique. Donec consequat egestas vulputate. + Pellentesque ultricies pellentesque ex pellentesque gravida. Praesent + lacinia tortor vitae dolor vehicula iaculis. In sed egestas lacus, eget + semper mauris. Sed augue augue, vehicula eu ornare quis, egestas id libero. + Sed quis enim lobortis, sollicitudin nibh eu, maximus justo. Nam mauris + tortor, suscipit dapibus sodales non, suscipit eu felis. Nam pellentesque + eleifend risus rhoncus facilisis. Vestibulum commodo fringilla enim tempus + hendrerit. Quisque a est varius, efficitur magna ac, condimentum metus. + In quam nisi, facilisis at pulvinar vitae, placerat quis est. Duis sagittis + non leo id placerat. Integer lobortis tellus rhoncus mi gravida, vel posuere + eros convallis. Suspendisse finibus elit viverra purus dictum, eget ultrices + risus hendrerit. Sed fermentum elit eu nibh pellentesque, eget suscipit + purus malesuada. Duis quis convallis quam, vel rutrum metus. Sed pulvinar + nisi non mauris laoreet, a faucibus turpis euismod. Cras et arcu hendrerit, + commodo elit eget, gravida lectus. Nulla euismod erat id venenatis sodales. + Duis non dolor facilisis, egestas felis pellentesque, porttitor augue. + Vestibulum eu tincidunt sapien, volutpat lobortis mi. Cum sociis natoque + penatibus et magnis dis parturient montes, nascetur ridiculus mus. + Praesent nec rhoncus erat, molestie imperdiet magna. Quisque vel eleifend + lectus. Cras ut orci et sem pellentesque pharetra. Donec ac urna sit amet + est viverra placerat. Duis sit amet ipsum venenatis, aliquam mauris quis, + fringilla leo. Suspendisse potenti. Cum sociis natoque penatibus et magnis + dis parturient montes, nascetur ridiculus mus. Sed eu condimentum nisi, + lobortis mollis est. Nam auctor auctor enim sit amet tincidunt. Proin + hendrerit volutpat vestibulum. Fusce facilisis rutrum pretium. Proin eget + imperdiet elit. Phasellus vulputate ex malesuada porttitor lobortis. + Curabitur vitae orci et lacus condimentum varius fringilla blandit metus. + Class aptent taciti sociosqu ad litora torquent per conubia nostra, per + inceptos himenaeos. Suspendisse vehicula mauris in libero finibus bibendum. + Phasellus ligula odio, pharetra vel metus maximus, efficitur pretium erat. + Morbi mi purus, sagittis quis congue et, pharetra id mauris. Cras eget neque + id erat cursus pellentesque et sed ipsum. In vel nibh at nulla pellentesque + elementum. Cras ultricies molestie massa, nec consequat urna scelerisque eu. + Etiam varius fermentum mi non tincidunt. Pellentesque vel elit id turpis + lobortis ullamcorper et a lorem. Nunc purus nulla, feugiat vitae congue + imperdiet, auctor sit amet ante. Nulla facilisi. Donec luctus sem vel diam + fringilla, vel fermentum augue placerat. Suspendisse et eros dignissim ipsum + vestibulum elementum. Curabitur scelerisque tortor sit amet libero pharetra + condimentum. Maecenas molestie non erat sed blandit. Ut lectus est, consequat + a auctor in, vulputate ac mi. Sed sem tortor, consectetur eget tincidunt et, + iaculis non diam. Praesent quis ipsum sem. Nulla lobortis nec ex non facilisis. + Aliquam porttitor metus eu velit convallis volutpat. Duis nec euismod urna. + Nullam molestie ligula urna, non laoreet mi facilisis quis. Donec aliquam + eget diam sit amet facilisis. Sed suscipit, justo non congue fringilla, + augue tellus volutpat velit, a dignissim felis quam sit amet metus. + Interdum et malesuada fames ac ante ipsum primis in faucibus. Duis + malesuada cursus dolor, eget aliquam leo ultricies at. Fusce fringilla + sed quam id finibus. Suspendisse ullamcorper, urna non feugiat elementum, + neque tortor suscipit elit, id condimentum lacus augue ut massa. Lorem + ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit + amet, consectetur adipiscing elit. Mauris tempor faucibus ipsum, vitae + blandit libero sollicitudin nec. Cras elementum mauris id ipsum tempus + ullamcorper. Class aptent taciti sociosqu ad litora torquent per conubia + nostra, per inceptos himenaeos. Donec vehicula, sapien sit amet pulvinar + pretium, elit mauris finibus nunc, ac pellentesque justo dolor eu dui. + Nulla tincidunt porttitor semper. Maecenas nunc enim, feugiat vel ex a, + pulvinar lacinia dolor. Donec in tortor ac justo porta malesuada et nec + ante. Maecenas vel bibendum nunc. Ut sollicitudin elementum orci ac auctor. + Duis blandit quam quis dapibus rhoncus. Proin sagittis feugiat mi ac + consequat. Sed maximus sodales diam id luctus. In cursus dictum rutrum. + Vestibulum vitae enim odio. Morbi non pharetra sem, at molestie lorem. + Nam libero est, imperdiet at aliquam vitae, mollis eget erat. Vivamus + eu nisi auctor, pharetra ligula nec, rhoncus augue. Quisque viverra + mollis velit, nec euismod lectus sagittis eget. Curabitur sed augue + vestibulum, luctus dolor nec, ornare ligula. Fusce lectus nunc, + tincidunt ut felis sed, placerat molestie risus. Etiam vel libero tellus. + Quisque elementum turpis non tempus dignissim. Pellentesque consectetur + tellus et urna ultrices elementum. Proin feugiat mi eu cursus mattis. + Proin tincidunt tincidunt turpis, in vulputate mauris. Cras posuere + lorem in erat lobortis sollicitudin. Proin in pulvinar diam, in convallis + urna. Praesent eget quam non velit dapibus tempus. Maecenas molestie nec + magna id auctor. Integer in sem non arcu dapibus iaculis. Sed eget massa + est. Cras dictum erat vel rutrum suscipit. In vehicula lorem non tempus + dignissim. Praesent gravida condimentum sem id elementum. Duis laoreet, + diam quis imperdiet mollis, nulla erat dapibus nisl, ac varius ex quam + id purus. Donec dignissim nulla lacinia eros venenatis tempor. Proin purus + lacus, ultrices non sodales quis, commodo et metus. Duis ante massa, + faucibus nec pharetra ut, ultricies et turpis. Morbi volutpat hendrerit + lacus, ut vehicula nibh tempor eget. Cras quis iaculis nisi, sit amet + placerat orci. Nam scelerisque velit malesuada, iaculis urna et, condimentum + dui. Nulla convallis augue vitae consequat laoreet. Quisque fermentum + ullamcorper magna, ut aliquam nunc facilisis in. Praesent tempus ullamcorper + massa, et fermentum purus bibendum quis. Sed sed venenatis odio, eget + euismod nisl. Nam et imperdiet dolor. Nam convallis justo a diam ultrices + gravida quis vel sapien. Vivamus aliquet lobortis augue ut accumsan. Donec + mi dolor, bibendum in mattis nec, porta vitae tellus. Donec eu tincidunt + lectus. Fusce placerat euismod turpis, et porta ligula tincidunt non. + Cras ac vestibulum diam. Cras eu quam finibus, feugiat libero vel, ornare + purus. Duis consectetur dictum metus non cursus. Vestibulum semper id erat + eget bibendum. Etiam vitae dui quis justo pretium pellentesque. Aenean sed + tellus eu odio volutpat consectetur condimentum vel leo. Etiam vulputate + risus tellus, at viverra enim vulputate vel. Mauris eu tortor nulla. + Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere + cubilia Curae; Nam ac nulla in ex lobortis tincidunt at non urna. Donec + congue lectus ut mauris eleifend cursus. Interdum et malesuada fames ac ante + ipsum primis in faucibus. Mauris sit amet porta mi, non mollis dui. Nullam + cursus sapien at pretium porta. Donec ac mauris pharetra, vehicula dolor + nec, lacinia mauris. Aliquam et felis finibus, cursus neque a, viverra sem. + Pellentesque habitant morbi tristique senectus et netus et malesuada fames + ac turpis egestas. Proin malesuada orci sit amet neque dapibus bibendum. + In lobortis imperdiet condimentum. Nullam est nisi, efficitur ac consectetur + eu, efficitur a libero. In nullam.'''; diff --git a/test/timeout_test.dart b/test/timeout_test.dart new file mode 100644 index 0000000..5ff8f32 --- /dev/null +++ b/test/timeout_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +import 'package:postgres/postgres.dart'; + +void main() { + PostgreSQLConnection conn; + + setUp(() async { + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); + await conn.open(); + await conn.execute('CREATE TEMPORARY TABLE t (id INT UNIQUE)'); + }); + + tearDown(() async { + await conn?.close(); + }); + + test( + 'Timeout fires on query while in queue does not execute query, query throws exception', + () async { + //ignore: unawaited_futures + final f = conn.query('SELECT pg_sleep(2)'); + try { + await conn.query('SELECT 1', timeoutInSeconds: 1); + fail('unreachable'); + } on TimeoutException { + // ignore + } + + expect(f, completes); + }); + + test('Timeout fires during transaction rolls ack transaction', () async { + try { + await conn.transaction((ctx) async { + await ctx.query('INSERT INTO t (id) VALUES (1)'); + await ctx.query('SELECT pg_sleep(2)', timeoutInSeconds: 1); + }); + fail('unreachable'); + } on TimeoutException { + // ignore + } + + expect(await conn.query('SELECT * from t'), hasLength(0)); + }); + + test( + 'Query on parent context for transaction completes (with error) after timeout', + () async { + try { + await conn.transaction((ctx) async { + await conn.query('SELECT 1', timeoutInSeconds: 1); + await ctx.query('INSERT INTO t (id) VALUES (1)'); + }); + fail('unreachable'); + } on TimeoutException { + // ignore + } + + expect(await conn.query('SELECT * from t'), hasLength(0)); + }); + + test( + 'If query is already on the wire and times out, safely throws timeoutexception and nothing else', + () async { + try { + await conn.query('SELECT pg_sleep(2)', timeoutInSeconds: 1); + fail('unreachable'); + } on TimeoutException { + // ignore + } + }); + + test('Query times out, next query in the queue runs', () async { + //ignore: unawaited_futures + conn + .query('SELECT pg_sleep(2)', timeoutInSeconds: 1) + .catchError((_) => null); + + expect(await conn.query('SELECT 1'), [ + [1] + ]); + }); + + test('Query that succeeds does not timeout', () async { + await conn.query('SELECT 1', timeoutInSeconds: 1); + expect(Future.delayed(Duration(seconds: 2)), completes); + }); + + test('Query that fails does not timeout', () async { + await conn + .query("INSERT INTO t (id) VALUES ('foo')", timeoutInSeconds: 1) + .catchError((_) => null); + expect(Future.delayed(Duration(seconds: 2)), completes); + }); +} diff --git a/test/transaction_test.dart b/test/transaction_test.dart index 841a8db..e588032 100644 --- a/test/transaction_test.dart +++ b/test/transaction_test.dart @@ -1,44 +1,67 @@ -import 'package:postgres/postgres.dart'; -import 'package:test/test.dart'; -import 'dart:io'; +// ignore_for_file: unawaited_futures import 'dart:async'; -import 'dart:mirrors'; + +import 'package:test/test.dart'; + +import 'package:postgres/postgres.dart'; void main() { - group("Transaction behavior", () { - PostgreSQLConnection conn = null; + group('Transaction behavior', () { + PostgreSQLConnection conn; setUp(() async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await conn.open(); - await conn.execute("CREATE TEMPORARY TABLE t (id INT UNIQUE)"); + await conn.execute('CREATE TEMPORARY TABLE t (id INT UNIQUE)'); }); tearDown(() async { await conn?.close(); }); - test("Send successful transaction succeeds, returns returned value", () async { - var outResult = await conn.transaction((c) async { - await c.query("INSERT INTO t (id) VALUES (1)"); + test('Rows are Lists of column values', () async { + await conn.execute('INSERT INTO t (id) VALUES (1)'); - return await c.query("SELECT id FROM t"); - }); - expect(outResult, [[1]]); + final outValue = await conn.transaction((ctx) async { + return await ctx.query('SELECT * FROM t WHERE id = @id LIMIT 1', + substitutionValues: {'id': 1}); + }) as List; - var result = await conn.query("SELECT id FROM t"); - expect(result, [[1]]); + expect(outValue.length, 1); + expect(outValue.first is List, true); + final firstItem = outValue.first as List; + expect(firstItem.length, 1); + expect(firstItem.first, 1); }); - test("Query during transaction must wait until transaction is finished", () async { - var orderEnsurer = []; - var nextCompleter = new Completer.sync(); - var outResult = conn.transaction((c) async { + test('Send successful transaction succeeds, returns returned value', + () async { + final outResult = await conn.transaction((c) async { + await c.query('INSERT INTO t (id) VALUES (1)'); + + return await c.query('SELECT id FROM t'); + }); + expect(outResult, [ + [1] + ]); + + final result = await conn.query('SELECT id FROM t'); + expect(result, [ + [1] + ]); + }); + + test('Query during transaction must wait until transaction is finished', + () async { + final orderEnsurer = []; + final nextCompleter = Completer.sync(); + final outResult = conn.transaction((c) async { orderEnsurer.add(1); - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); orderEnsurer.add(2); nextCompleter.complete(); - var result = await c.query("SELECT id FROM t"); + final result = await c.query('SELECT id FROM t'); orderEnsurer.add(3); return result; @@ -46,77 +69,92 @@ void main() { await nextCompleter.future; orderEnsurer.add(11); - await conn.query("INSERT INTO t (id) VALUES (2)"); + await conn.query('INSERT INTO t (id) VALUES (2)'); orderEnsurer.add(12); - var laterResults = await conn.query("SELECT id FROM t"); + final laterResults = await conn.query('SELECT id FROM t'); orderEnsurer.add(13); - var firstResult = await outResult; + final firstResult = await outResult; expect(orderEnsurer, [1, 2, 11, 3, 12, 13]); - expect(firstResult, [[1]]); - expect(laterResults, [[1],[2]]); + expect(firstResult, [ + [1] + ]); + expect(laterResults, [ + [1], + [2] + ]); }); - test("Make sure two simultaneous transactions cannot be interwoven", () async { - var orderEnsurer = []; + test('Make sure two simultaneous transactions cannot be interwoven', + () async { + final orderEnsurer = []; - var firstTransactionFuture = conn.transaction((c) async { + final firstTransactionFuture = conn.transaction((c) async { orderEnsurer.add(11); - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); orderEnsurer.add(12); - var result = await c.query("SELECT id FROM t"); + final result = await c.query('SELECT id FROM t'); orderEnsurer.add(13); return result; }); - var secondTransactionFuture = conn.transaction((c) async { + final secondTransactionFuture = conn.transaction((c) async { orderEnsurer.add(21); - await c.query("INSERT INTO t (id) VALUES (2)"); + await c.query('INSERT INTO t (id) VALUES (2)'); orderEnsurer.add(22); - var result = await c.query("SELECT id FROM t"); + final result = await c.query('SELECT id FROM t'); orderEnsurer.add(23); return result; }); - var firstResults = await firstTransactionFuture; - var secondResults = await secondTransactionFuture; + final firstResults = await firstTransactionFuture; + final secondResults = await secondTransactionFuture; expect(orderEnsurer, [11, 12, 13, 21, 22, 23]); - expect(firstResults, [[1]]); - expect(secondResults, [[1], [2]]); + expect(firstResults, [ + [1] + ]); + expect(secondResults, [ + [1], + [2] + ]); }); - test("May intentionally rollback transaction", () async { + test('May intentionally rollback transaction', () async { + var reached = false; await conn.transaction((c) async { - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); c.cancelTransaction(); - await c.query("INSERT INTO t (id) VALUES (2)"); + reached = true; + await c.query('INSERT INTO t (id) VALUES (2)'); }); - var result = await conn.query("SELECT id FROM t"); + expect(reached, false); + final result = await conn.query('SELECT id FROM t'); expect(result, []); }); - test("Intentional rollback on non-transaction has no impact", () async { + test('Intentional rollback on non-transaction has no impact', () async { conn.cancelTransaction(); - var result = await conn.query("SELECT id FROM t"); + final result = await conn.query('SELECT id FROM t'); expect(result, []); }); - test("Intentional rollback from outside of a transaction has no impact", () async { - var orderEnsurer = []; - var nextCompleter = new Completer.sync(); - var outResult = conn.transaction((c) async { + test('Intentional rollback from outside of a transaction has no impact', + () async { + final orderEnsurer = []; + final nextCompleter = Completer.sync(); + final outResult = conn.transaction((c) async { orderEnsurer.add(1); - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); orderEnsurer.add(2); nextCompleter.complete(); - var result = await c.query("SELECT id FROM t"); + final result = await c.query('SELECT id FROM t'); orderEnsurer.add(3); return result; @@ -126,45 +164,129 @@ void main() { conn.cancelTransaction(); orderEnsurer.add(11); - var results = await outResult; + final results = await outResult; expect(orderEnsurer, [1, 2, 11, 3]); - expect(results, [[1]]); + expect(results, [ + [1] + ]); }); - test("A transaction does not preempt pending queries", () async { + test('A transaction does not preempt pending queries', () async { // Add a few insert queries but don't await, then do a transaction that does a fetch, // make sure that transaction contains all of the elements. - conn.execute("INSERT INTO t (id) VALUES (1)"); - conn.execute("INSERT INTO t (id) VALUES (2)"); - conn.execute("INSERT INTO t (id) VALUES (3)"); + conn.execute('INSERT INTO t (id) VALUES (1)'); + conn.execute('INSERT INTO t (id) VALUES (2)'); + conn.execute('INSERT INTO t (id) VALUES (3)'); - var results = await conn.transaction((ctx) async { - return await ctx.query("SELECT id FROM t"); + final results = await conn.transaction((ctx) async { + return await ctx.query('SELECT id FROM t'); }); - expect(results, [[1], [2], [3]]); + expect(results, [ + [1], + [2], + [3] + ]); }); test("A transaction doesn't have to await on queries", () async { conn.transaction((ctx) async { - ctx.query("INSERT INTO t (id) VALUES (1)"); - ctx.query("INSERT INTO t (id) VALUES (2)"); - ctx.query("INSERT INTO t (id) VALUES (3)"); + ctx.query('INSERT INTO t (id) VALUES (1)'); + ctx.query('INSERT INTO t (id) VALUES (2)'); + ctx.query('INSERT INTO t (id) VALUES (3)'); }); - var total = await conn.query("SELECT id FROM t"); - expect(total, [[1], [2], [3]]); + final total = await conn.query('SELECT id FROM t'); + expect(total, [ + [1], + [2], + [3] + ]); }); - test("A transaction with a rollback and non-await queries rolls back transaction", () async { - conn.transaction((ctx) async { - ctx.query("INSERT INTO t (id) VALUES (1)"); - ctx.query("INSERT INTO t (id) VALUES (2)"); + test( + "A transaction doesn't have to await on queries, when the last query fails, it still emits an error from the transaction", + () async { + dynamic transactionError; + await conn.transaction((ctx) async { + ctx.query('INSERT INTO t (id) VALUES (1)'); + ctx.query('INSERT INTO t (id) VALUES (2)'); + ctx.query("INSERT INTO t (id) VALUES ('foo')").catchError((e) {}); + }).catchError((e) => transactionError = e); + + expect(transactionError, isNotNull); + + final total = await conn.query('SELECT id FROM t'); + expect(total, []); + }); + + test( + "A transaction doesn't have to await on queries, when the non-last query fails, it still emits an error from the transaction", + () async { + dynamic failingQueryError; + dynamic pendingQueryError; + dynamic transactionError; + await conn.transaction((ctx) async { + ctx.query('INSERT INTO t (id) VALUES (1)'); + ctx.query("INSERT INTO t (id) VALUES ('foo')").catchError((e) { + failingQueryError = e; + }); + ctx.query('INSERT INTO t (id) VALUES (2)').catchError((e) { + pendingQueryError = e; + }); + }).catchError((e) => transactionError = e); + expect(transactionError, isNotNull); + expect(failingQueryError.toString(), contains('invalid input')); + expect( + pendingQueryError.toString(), contains('failed prior to execution')); + final total = await conn.query('SELECT id FROM t'); + expect(total, []); + }); + + test( + 'A transaction with a rollback and non-await queries rolls back transaction', + () async { + final errs = []; + await conn.transaction((ctx) async { + ctx.query('INSERT INTO t (id) VALUES (1)').catchError(errs.add); + ctx.query('INSERT INTO t (id) VALUES (2)').catchError(errs.add); ctx.cancelTransaction(); - ctx.query("INSERT INTO t (id) VALUES (3)"); + ctx.query('INSERT INTO t (id) VALUES (3)').catchError((e) {}); }); - var total = await conn.query("SELECT id FROM t"); + final total = await conn.query('SELECT id FROM t'); + expect(total, []); + + expect(errs.length, 2); + }); + + test( + 'A transaction that mixes awaiting and non-awaiting queries fails gracefully when an awaited query fails', + () async { + dynamic transactionError; + await conn.transaction((ctx) async { + ctx.query('INSERT INTO t (id) VALUES (1)'); + await ctx.query("INSERT INTO t (id) VALUES ('foo')").catchError((_) {}); + ctx.query('INSERT INTO t (id) VALUES (2)').catchError((_) {}); + }).catchError((e) => transactionError = e); + + expect(transactionError, isNotNull); + final total = await conn.query('SELECT id FROM t'); + expect(total, []); + }); + + test( + 'A transaction that mixes awaiting and non-awaiting queries fails gracefully when an unawaited query fails', + () async { + dynamic transactionError; + await conn.transaction((ctx) async { + await ctx.query('INSERT INTO t (id) VALUES (1)'); + ctx.query("INSERT INTO t (id) VALUES ('foo')").catchError((_) {}); + await ctx.query('INSERT INTO t (id) VALUES (2)').catchError((_) {}); + }).catchError((e) => transactionError = e); + + expect(transactionError, isNotNull); + final total = await conn.query('SELECT id FROM t'); expect(total, []); }); }); @@ -172,73 +294,76 @@ void main() { // A transaction can fail for three reasons: query error, exception in code, or a rollback. // After a transaction fails, the changes must be rolled back, it should continue with pending queries, pending transactions, later queries, later transactions - group("Transaction:Query recovery", () { - PostgreSQLConnection conn = null; + group('Transaction:Query recovery', () { + PostgreSQLConnection conn; setUp(() async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await conn.open(); - await conn.execute("CREATE TEMPORARY TABLE t (id INT UNIQUE)"); + await conn.execute('CREATE TEMPORARY TABLE t (id INT UNIQUE)'); }); tearDown(() async { await conn?.close(); }); - test("Is rolled back/executes later query", () async { + test('Is rolled back/executes later query', () async { try { await conn.transaction((c) async { - await c.query("INSERT INTO t (id) VALUES (1)"); - var oneRow = await c.query("SELECT id FROM t"); - expect(oneRow, [[1]]); + await c.query('INSERT INTO t (id) VALUES (1)'); + final oneRow = await c.query('SELECT id FROM t'); + expect(oneRow, [ + [1] + ]); // This will error - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); }); expect(true, false); } on PostgreSQLException catch (e) { - expect(e.message, contains("unique constraint")); + expect(e.message, contains('unique constraint')); } - var noRows = await conn.query("SELECT id FROM t"); + final noRows = await conn.query('SELECT id FROM t'); expect(noRows, []); }); - test("Executes pending query", () async { - var orderEnsurer = []; + test('Executes pending query', () async { + final orderEnsurer = []; conn.transaction((c) async { orderEnsurer.add(1); - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); orderEnsurer.add(2); // This will error - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); }).catchError((e) => null); orderEnsurer.add(11); - var result = await conn.query("SELECT id FROM t"); + final result = await conn.query('SELECT id FROM t'); orderEnsurer.add(12); expect(orderEnsurer, [11, 1, 2, 12]); expect(result, []); }); - test("Executes pending transaction", () async { - var orderEnsurer = []; + test('Executes pending transaction', () async { + final orderEnsurer = []; conn.transaction((c) async { orderEnsurer.add(1); - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); orderEnsurer.add(2); // This will error - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); }).catchError((e) => null); - var result = await conn.transaction((ctx) async { + final result = await conn.transaction((ctx) async { orderEnsurer.add(11); - return await ctx.query("SELECT id FROM t"); + return await ctx.query('SELECT id FROM t'); }); orderEnsurer.add(12); @@ -246,83 +371,90 @@ void main() { expect(result, []); }); - test("Executes later transaction", () async { + test('Executes later transaction', () async { try { await conn.transaction((c) async { - await c.query("INSERT INTO t (id) VALUES (1)"); - var oneRow = await c.query("SELECT id FROM t"); - expect(oneRow, [[1]]); + await c.query('INSERT INTO t (id) VALUES (1)'); + final oneRow = await c.query('SELECT id FROM t'); + expect(oneRow, [ + [1] + ]); // This will error - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); }); expect(true, false); - } on PostgreSQLException catch (e) {} + } on PostgreSQLException { + // ignore + } - var result = await conn.transaction((ctx) async { - return await ctx.query("SELECT id FROM t"); + final result = await conn.transaction((ctx) async { + return await ctx.query('SELECT id FROM t'); }); expect(result, []); }); }); - group("Transaction:Exception recovery", () { - PostgreSQLConnection conn = null; + group('Transaction:Exception recovery', () { + PostgreSQLConnection conn; setUp(() async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await conn.open(); - await conn.execute("CREATE TEMPORARY TABLE t (id INT UNIQUE)"); + await conn.execute('CREATE TEMPORARY TABLE t (id INT UNIQUE)'); }); tearDown(() async { await conn?.close(); }); - test("Is rolled back/executes later query", () async { + test('Is rolled back/executes later query', () async { try { await conn.transaction((c) async { - await c.query("INSERT INTO t (id) VALUES (1)"); - throw 'foo'; + await c.query('INSERT INTO t (id) VALUES (1)'); + throw Exception('foo'); }); expect(true, false); - } on String {} + } on Exception { + // ignore + } - var noRows = await conn.query("SELECT id FROM t"); + final noRows = await conn.query('SELECT id FROM t'); expect(noRows, []); }); - test("Executes pending query", () async { - var orderEnsurer = []; + test('Executes pending query', () async { + final orderEnsurer = []; conn.transaction((c) async { orderEnsurer.add(1); - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); orderEnsurer.add(2); - throw 'foo'; + throw Exception('foo'); }).catchError((e) => null); orderEnsurer.add(11); - var result = await conn.query("SELECT id FROM t"); + final result = await conn.query('SELECT id FROM t'); orderEnsurer.add(12); expect(orderEnsurer, [11, 1, 2, 12]); expect(result, []); }); - test("Executes pending transaction", () async { - var orderEnsurer = []; + test('Executes pending transaction', () async { + final orderEnsurer = []; conn.transaction((c) async { orderEnsurer.add(1); - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); orderEnsurer.add(2); - throw 'foo'; + throw Exception('foo'); }).catchError((e) => null); - var result = await conn.transaction((ctx) async { + final result = await conn.transaction((ctx) async { orderEnsurer.add(11); - return await ctx.query("SELECT id FROM t"); + return await ctx.query('SELECT id FROM t'); }); orderEnsurer.add(12); @@ -330,82 +462,146 @@ void main() { expect(result, []); }); - test("Executes later transaction", () async { + test('Executes later transaction', () async { try { await conn.transaction((c) async { - await c.query("INSERT INTO t (id) VALUES (1)"); - throw 'foo'; + await c.query('INSERT INTO t (id) VALUES (1)'); + throw Exception('foo'); }); expect(true, false); - } on String {} + } on Exception { + // ignore + } - var result = await conn.transaction((ctx) async { - return await ctx.query("SELECT id FROM t"); + final result = await conn.transaction((ctx) async { + return await ctx.query('SELECT id FROM t'); }); expect(result, []); }); + + test( + 'If exception thrown while preparing query, transaction gets rolled back', + () async { + try { + await conn.transaction((c) async { + await c.query('INSERT INTO t (id) VALUES (1)'); + + c.query('INSERT INTO t (id) VALUES (@id:int4)', + substitutionValues: {'id': 'foobar'}).catchError((_) => null); + await c.query('INSERT INTO t (id) VALUES (2)'); + }); + expect(true, false); + } catch (e) { + expect(e is FormatException, true); + } + + final noRows = await conn.query('SELECT id FROM t'); + expect(noRows, []); + }); + + test('Async query failure prevents closure from continuning', () async { + var reached = false; + + try { + await conn.transaction((c) async { + await c.query('INSERT INTO t (id) VALUES (1)'); + await c.query("INSERT INTO t (id) VALUE ('foo') RETURNING id"); + + reached = true; + await c.query('INSERT INTO t (id) VALUES (2)'); + }); + fail('unreachable'); + } on PostgreSQLException { + // ignore + } + + expect(reached, false); + final res = await conn.query('SELECT * FROM t'); + expect(res, []); + }); + + test( + 'When exception thrown in unawaited on future, transaction is rolled back', + () async { + try { + await conn.transaction((c) async { + await c.query('INSERT INTO t (id) VALUES (1)'); + c + .query("INSERT INTO t (id) VALUE ('foo') RETURNING id") + .catchError((_) => null); + await c.query('INSERT INTO t (id) VALUES (2)'); + }); + fail('unreachable'); + } on PostgreSQLException { + // ignore + } + + final res = await conn.query('SELECT * FROM t'); + expect(res, []); + }); }); - group("Transaction:Rollback recovery", () { - PostgreSQLConnection conn = null; + group('Transaction:Rollback recovery', () { + PostgreSQLConnection conn; setUp(() async { - conn = new PostgreSQLConnection("localhost", 5432, "dart_test", username: "dart", password: "dart"); + conn = PostgreSQLConnection('localhost', 5432, 'dart_test', + username: 'dart', password: 'dart'); await conn.open(); - await conn.execute("CREATE TEMPORARY TABLE t (id INT UNIQUE)"); + await conn.execute('CREATE TEMPORARY TABLE t (id INT UNIQUE)'); }); tearDown(() async { await conn?.close(); }); - test("Is rolled back/executes later query", () async { - var result = await conn.transaction((c) async { - await c.query("INSERT INTO t (id) VALUES (1)"); + test('Is rolled back/executes later query', () async { + final result = await conn.transaction((c) async { + await c.query('INSERT INTO t (id) VALUES (1)'); c.cancelTransaction(); - await c.query("INSERT INTO t (id) VALUES (2)"); + await c.query('INSERT INTO t (id) VALUES (2)'); }); expect(result is PostgreSQLRollback, true); - var noRows = await conn.query("SELECT id FROM t"); + final noRows = await conn.query('SELECT id FROM t'); expect(noRows, []); }); - test("Executes pending query", () async { - var orderEnsurer = []; + test('Executes pending query', () async { + final orderEnsurer = []; conn.transaction((c) async { orderEnsurer.add(1); - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); orderEnsurer.add(2); - await c.cancelTransaction(); - await c.query("INSERT INTO t (id) VALUES (2)"); + c.cancelTransaction(); + await c.query('INSERT INTO t (id) VALUES (2)'); }); orderEnsurer.add(11); - var result = await conn.query("SELECT id FROM t"); + final result = await conn.query('SELECT id FROM t'); orderEnsurer.add(12); expect(orderEnsurer, [11, 1, 2, 12]); expect(result, []); }); - test("Executes pending transaction", () async { - var orderEnsurer = []; + test('Executes pending transaction', () async { + final orderEnsurer = []; conn.transaction((c) async { orderEnsurer.add(1); - await c.query("INSERT INTO t (id) VALUES (1)"); + await c.query('INSERT INTO t (id) VALUES (1)'); orderEnsurer.add(2); - await c.cancelTransaction(); - await c.query("INSERT INTO t (id) VALUES (2)"); + c.cancelTransaction(); + await c.query('INSERT INTO t (id) VALUES (2)'); orderEnsurer.add(3); }); - var result = await conn.transaction((ctx) async { + final result = await conn.transaction((ctx) async { orderEnsurer.add(11); - return await ctx.query("SELECT id FROM t"); + return await ctx.query('SELECT id FROM t'); }); orderEnsurer.add(12); @@ -413,18 +609,18 @@ void main() { expect(result, []); }); - test("Executes later transaction", () async { - var result = await conn.transaction((c) async { - await c.query("INSERT INTO t (id) VALUES (1)"); + test('Executes later transaction', () async { + dynamic result = await conn.transaction((c) async { + await c.query('INSERT INTO t (id) VALUES (1)'); c.cancelTransaction(); - await c.query("INSERT INTO t (id) VALUES (2)"); + await c.query('INSERT INTO t (id) VALUES (2)'); }); expect(result is PostgreSQLRollback, true); result = await conn.transaction((ctx) async { - return await ctx.query("SELECT id FROM t"); + return await ctx.query('SELECT id FROM t'); }); expect(result, []); }); }); -} \ No newline at end of file +}