diff --git a/CHANGELOG.md b/CHANGELOG.md index 092ea70a..151f8df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.3.0 + +- timeZone option in ConnectionSettings is now a TimeZoneSettings type instead of String +- add more flexibility on how timestamp and timestaptz types are decoded by adding flags to the TimeZoneSettings +- opcional decode timestamp without timezone as local DateTime and decode timestamp with timezone respecting the timezone defined in the connection + ## 3.2.1 - Added or fixed decoders support for `QueryMode.simple`: diff --git a/example/example.dart b/example/example.dart index 6ab502be..92d70e37 100644 --- a/example/example.dart +++ b/example/example.dart @@ -83,4 +83,4 @@ void main() async { print(await subscription.schema); await conn.close(); -} +} \ No newline at end of file diff --git a/lib/postgres.dart b/lib/postgres.dart index afb2d0c7..95b2acec 100644 --- a/lib/postgres.dart +++ b/lib/postgres.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import 'package:postgres/src/timezone_settings.dart'; import 'package:stream_channel/stream_channel.dart'; import 'src/replication.dart'; @@ -16,6 +17,9 @@ import 'src/v3/query_description.dart'; export 'src/exceptions.dart'; export 'src/pool/pool_api.dart'; export 'src/replication.dart'; + +export 'src/timezone_settings.dart'; + export 'src/types.dart'; export 'src/types/geo_types.dart'; export 'src/types/range_types.dart'; @@ -440,7 +444,7 @@ enum SslMode { class ConnectionSettings extends SessionSettings { final String? applicationName; - final String? timeZone; + final TimeZoneSettings? timeZone; final Encoding? encoding; final SslMode? sslMode; diff --git a/lib/src/buffer.dart b/lib/src/buffer.dart index a776b114..9ead185b 100644 --- a/lib/src/buffer.dart +++ b/lib/src/buffer.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:buffer/buffer.dart'; +import 'package:postgres/src/timezone_settings.dart'; /// This class doesn't add much over using `List` instead, however, /// it creates a nice explicit type difference from both `String` and `List`, @@ -43,9 +44,11 @@ const _emptyString = ''; class PgByteDataReader extends ByteDataReader { final Encoding encoding; - + final TimeZoneSettings timeZone; + PgByteDataReader({ required this.encoding, + required this.timeZone, }); String readNullTerminatedString() { diff --git a/lib/src/message_window.dart b/lib/src/message_window.dart index 0c96f234..98c1c15e 100644 --- a/lib/src/message_window.dart +++ b/lib/src/message_window.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:buffer/buffer.dart'; import 'package:charcode/ascii.dart'; +import 'package:postgres/src/timezone_settings.dart'; import 'buffer.dart'; import 'messages/server_messages.dart'; @@ -36,10 +37,12 @@ Map _messageTypeMap = { class MessageFramer { final Encoding _encoding; - late final _reader = PgByteDataReader(encoding: _encoding); + TimeZoneSettings timeZone; + late final _reader = + PgByteDataReader(encoding: _encoding, timeZone: timeZone); final messageQueue = Queue(); - MessageFramer(this._encoding); + MessageFramer(this._encoding, this.timeZone); int? _type; int _expectedLength = 0; @@ -116,7 +119,8 @@ ServerMessage _parseCopyDataMessage(PgByteDataReader reader, int length) { if (code == ReplicationMessageId.primaryKeepAlive) { return PrimaryKeepAliveMessage.parse(reader); } else if (code == ReplicationMessageId.xLogData) { - return XLogDataMessage.parse(reader.read(length - 1), reader.encoding); + return XLogDataMessage.parse( + reader.read(length - 1), reader.encoding, reader.timeZone); } else { final bb = BytesBuffer(); bb.addByte(code); diff --git a/lib/src/messages/client_messages.dart b/lib/src/messages/client_messages.dart index f99a4214..32b2d338 100644 --- a/lib/src/messages/client_messages.dart +++ b/lib/src/messages/client_messages.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:charcode/ascii.dart'; +import 'package:postgres/src/timezone_settings.dart'; import 'package:postgres/src/types/generic_type.dart'; import '../buffer.dart'; @@ -49,12 +50,12 @@ class StartupMessage extends ClientMessage { StartupMessage({ required String database, - required String timeZone, + required TimeZoneSettings timeZone, String? username, String? applicationName, ReplicationMode replication = ReplicationMode.none, }) : _databaseName = database, - _timeZone = timeZone, + _timeZone = timeZone.value, _username = username, _applicationName = applicationName, _replication = replication.value; diff --git a/lib/src/messages/server_messages.dart b/lib/src/messages/server_messages.dart index 77edda7a..6fd923ae 100644 --- a/lib/src/messages/server_messages.dart +++ b/lib/src/messages/server_messages.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:meta/meta.dart'; +import 'package:postgres/src/timezone_settings.dart'; + import '../buffer.dart'; import '../time_converters.dart'; @@ -69,6 +71,9 @@ class ParameterStatusMessage extends ServerMessage { factory ParameterStatusMessage.parse(PgByteDataReader reader) { final name = reader.readNullTerminatedString(); final value = reader.readNullTerminatedString(); + if (name.toLowerCase() == 'timezone') { + reader.timeZone.value = value; + } return ParameterStatusMessage._(name, value); } } @@ -366,8 +371,8 @@ class XLogDataMessage implements ReplicationMessage, ServerMessage { /// If [XLogDataMessage.data] is a [LogicalReplicationMessage], then the method /// will return a [XLogDataLogicalMessage] with that message. Otherwise, it'll /// return [XLogDataMessage] with raw data. - static XLogDataMessage parse(Uint8List bytes, Encoding encoding) { - final reader = PgByteDataReader(encoding: encoding)..add(bytes); + static XLogDataMessage parse(Uint8List bytes, Encoding encoding, TimeZoneSettings timeZone) { + final reader = PgByteDataReader(encoding: encoding, timeZone: timeZone)..add(bytes); final walStart = LSN(reader.readUint64()); final walEnd = LSN(reader.readUint64()); final time = dateTimeFromMicrosecondsSinceY2k(reader.readUint64()); diff --git a/lib/src/timezone_settings.dart b/lib/src/timezone_settings.dart new file mode 100644 index 00000000..f0842a60 --- /dev/null +++ b/lib/src/timezone_settings.dart @@ -0,0 +1,34 @@ +/// A class to configure time zone settings for decoding timestamps and dates. +class TimeZoneSettings { + /// The default time zone value. + /// + /// The [value] represents the name of the time zone location. Default is 'UTC'. + String value = 'UTC'; + + /// Creates a new instance of [TimeZoneSettings]. + /// + /// [value] is the name of the time zone location. + /// + /// The optional named parameters: + /// - [forceDecodeTimestamptzAsUTC]: if true, decodes timestamps with timezone (timestamptz) as UTC. If false, decodes them using the timezone defined in the connection. + /// - [forceDecodeTimestampAsUTC]: if true, decodes timestamps without timezone (timestamp) as UTC. If false, decodes them as local datetime. + /// - [forceDecodeDateAsUTC]: if true, decodes dates as UTC. If false, decodes them as local datetime. + TimeZoneSettings( + this.value, { + this.forceDecodeTimestamptzAsUTC = true, + this.forceDecodeTimestampAsUTC = true, + this.forceDecodeDateAsUTC = true, + }); + + /// If true, decodes the timestamp with timezone (timestamptz) as UTC. + /// If false, decodes the timestamp with timezone using the timezone defined in the connection. + bool forceDecodeTimestamptzAsUTC = true; + + /// If true, decodes the timestamp without timezone (timestamp) as UTC. + /// If false, decodes the timestamp without timezone as local datetime. + bool forceDecodeTimestampAsUTC = true; + + /// If true, decodes the date as UTC. + /// If false, decodes the date as local datetime. + bool forceDecodeDateAsUTC = true; +} diff --git a/lib/src/types/binary_codec.dart b/lib/src/types/binary_codec.dart index 7390dba5..73bc9837 100644 --- a/lib/src/types/binary_codec.dart +++ b/lib/src/types/binary_codec.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:buffer/buffer.dart'; +import 'package:pg_timezone/pg_timezone.dart' as tz; +import 'package:pg_timezone/timezone.dart' as tzenv; import 'package:postgres/src/types/generic_type.dart'; import '../buffer.dart'; @@ -760,6 +762,7 @@ class PostgresBinaryDecoder { Object? convert(DecodeInput dinput) { final encoding = dinput.encoding; + final input = dinput.bytes; late final buffer = ByteData.view(input.buffer, input.offsetInBytes, input.lengthInBytes); @@ -784,10 +787,104 @@ class PostgresBinaryDecoder { return buffer.getFloat64(0); case TypeOid.time: return Time.fromMicroseconds(buffer.getInt64(0)); + case TypeOid.date: + final value = buffer.getInt32(0); + //infinity || -infinity + if (value == 2147483647 || value == -2147483648) { + return null; + } + if (dinput.timeZone.forceDecodeDateAsUTC) { + return DateTime.utc(2000).add(Duration(days: value)); + } + + final baseDt = _getPostgreSQLEpochBaseDate(); + return baseDt.add(Duration(days: value)); case TypeOid.timestampWithoutTimezone: + final value = buffer.getInt64(0); + //infinity || -infinity + if (value == 9223372036854775807 || value == -9223372036854775808) { + return null; + } + + if (dinput.timeZone.forceDecodeTimestampAsUTC) { + return DateTime.utc(2000).add(Duration(microseconds: value)); + } + + final baseDt = _getPostgreSQLEpochBaseDate(); + return baseDt.add(Duration(microseconds: value)); + case TypeOid.timestampWithTimezone: - return DateTime.utc(2000) - .add(Duration(microseconds: buffer.getInt64(0))); + final value = buffer.getInt64(0); + + //infinity || -infinity + if (value == 9223372036854775807 || value == -9223372036854775808) { + return null; + } + + var datetime = DateTime.utc(2000).add(Duration(microseconds: value)); + if (dinput.timeZone.value.toLowerCase() == 'utc') { + return datetime; + } + if (dinput.timeZone.forceDecodeTimestamptzAsUTC) { + return datetime; + } + + final pgTimeZone = dinput.timeZone.value.toLowerCase(); + final tzLocations = tz.timeZoneDatabase.locations.entries + .where((e) { + return e.key.toLowerCase() == pgTimeZone || + e.value.currentTimeZone.abbreviation.toLowerCase() == + pgTimeZone; + }) + .map((e) => e.value) + .toList(); + + if (tzLocations.isEmpty) { + throw tz.LocationNotFoundException( + 'Location with the name "$pgTimeZone" doesn\'t exist'); + } + final tzLocation = tzLocations.first; + //define location for TZDateTime.toLocal() + tzenv.setLocalLocation(tzLocation); + + final offsetInMilliseconds = tzLocation.currentTimeZone.offset; + // Conversion of milliseconds to hours + final double offset = offsetInMilliseconds / (1000 * 60 * 60); + + if (offset < 0) { + final subtr = Duration( + hours: offset.abs().truncate(), + minutes: ((offset.abs() % 1) * 60).round()); + datetime = datetime.subtract(subtr); + final specificDate = tz.TZDateTime( + tzLocation, + datetime.year, + datetime.month, + datetime.day, + datetime.hour, + datetime.minute, + datetime.second, + datetime.millisecond, + datetime.microsecond); + return specificDate; + } else if (offset > 0) { + final addr = Duration( + hours: offset.truncate(), minutes: ((offset % 1) * 60).round()); + datetime = datetime.add(addr); + final specificDate = tz.TZDateTime( + tzLocation, + datetime.year, + datetime.month, + datetime.day, + datetime.hour, + datetime.minute, + datetime.second, + datetime.millisecond, + datetime.microsecond); + return specificDate; + } + + return datetime; case TypeOid.interval: return Interval( @@ -799,9 +896,6 @@ class PostgresBinaryDecoder { case TypeOid.numeric: return _decodeNumeric(input); - case TypeOid.date: - return DateTime.utc(2000).add(Duration(days: buffer.getInt32(0))); - case TypeOid.jsonb: { // Removes version which is first character and currently always '1' @@ -948,6 +1042,29 @@ class PostgresBinaryDecoder { ); } + /// Returns a base DateTime object representing the PostgreSQL epoch + /// (January 1, 2000), adjusted to the current system's timezone offset. + /// + /// This method ensures that the base DateTime object is consistent across + /// different system environments (e.g., Windows, Linux) by adjusting the + /// base DateTime's timezone offset to match the current system's timezone + /// offset. This adjustment is necessary due to potential differences in + /// how different operating systems handle timezone transitions. + /// Returns: + /// - A `DateTime` object representing January 1, 2000, adjusted to the + /// current system's timezone offset. + DateTime _getPostgreSQLEpochBaseDate() { + // https://github.com/dart-lang/sdk/issues/56312 + // ignore past timestamp transitions and use only current timestamp in local datetime + final nowDt = DateTime.now(); + var baseDt = DateTime(2000); + if (baseDt.timeZoneOffset != nowDt.timeZoneOffset) { + final difference = baseDt.timeZoneOffset - nowDt.timeZoneOffset; + baseDt = baseDt.add(difference); + } + return baseDt; + } + List readListBytes(Uint8List data, V Function(ByteDataReader reader, int length) valueDecoder) { if (data.length < 16) { @@ -1051,6 +1168,7 @@ class PostgresBinaryDecoder { bytes: bytes, isBinary: dinput.isBinary, encoding: dinput.encoding, + timeZone: dinput.timeZone, typeRegistry: dinput.typeRegistry)) as T; } diff --git a/lib/src/types/generic_type.dart b/lib/src/types/generic_type.dart index c8d46699..03535207 100644 --- a/lib/src/types/generic_type.dart +++ b/lib/src/types/generic_type.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:postgres/src/timezone_settings.dart'; + import '../types.dart'; import 'binary_codec.dart'; import 'text_codec.dart'; @@ -36,11 +38,13 @@ class DecodeInput { final bool isBinary; final Encoding encoding; final TypeRegistry typeRegistry; + final TimeZoneSettings timeZone; DecodeInput({ required this.bytes, required this.isBinary, required this.encoding, + required this.timeZone, required this.typeRegistry, }); diff --git a/lib/src/types/text_search.dart b/lib/src/types/text_search.dart index 4907207b..c42fdcc1 100644 --- a/lib/src/types/text_search.dart +++ b/lib/src/types/text_search.dart @@ -105,7 +105,7 @@ class TsVectorType extends Type { TsVector? decode(DecodeInput input) { if (input.isBinary) { - final reader = PgByteDataReader(encoding: input.encoding) + final reader = PgByteDataReader(encoding: input.encoding, timeZone: input.timeZone) ..add(input.bytes); final count = reader.readUint32(); final lexemes = []; @@ -192,7 +192,7 @@ class TsQueryType extends Type { TsQuery decode(DecodeInput input) { if (input.isBinary) { - final reader = PgByteDataReader(encoding: input.encoding) + final reader = PgByteDataReader(encoding: input.encoding,timeZone: input.timeZone) ..add(input.bytes); final count = reader.readUint32(); final items = []; diff --git a/lib/src/types/type_registry.dart b/lib/src/types/type_registry.dart index 55a1edb6..f4a8aa75 100644 --- a/lib/src/types/type_registry.dart +++ b/lib/src/types/type_registry.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import 'package:postgres/src/timezone_settings.dart'; import '../exceptions.dart'; import '../types.dart'; @@ -259,6 +260,7 @@ extension TypeRegistryExt on TypeRegistry { required int typeOid, required bool isBinary, required Encoding encoding, + required TimeZoneSettings timeZone, }) { if (bytes == null) { return null; @@ -270,6 +272,7 @@ extension TypeRegistryExt on TypeRegistry { bytes: bytes, isBinary: isBinary, encoding: encoding, + timeZone: timeZone, typeRegistry: this, )); case TsVectorType(): @@ -277,6 +280,7 @@ extension TypeRegistryExt on TypeRegistry { bytes: bytes, isBinary: isBinary, encoding: encoding, + timeZone: timeZone, typeRegistry: this, )); case TsQueryType(): @@ -284,6 +288,7 @@ extension TypeRegistryExt on TypeRegistry { bytes: bytes, isBinary: isBinary, encoding: encoding, + timeZone: timeZone, typeRegistry: this, )); case UnknownType(): @@ -292,6 +297,7 @@ extension TypeRegistryExt on TypeRegistry { bytes: bytes, isBinary: isBinary, encoding: encoding, + ); } return UndecodedBytes( diff --git a/lib/src/v3/connection.dart b/lib/src/v3/connection.dart index 580c2626..312a5644 100644 --- a/lib/src/v3/connection.dart +++ b/lib/src/v3/connection.dart @@ -44,6 +44,7 @@ abstract class _PgSessionBase implements Session { PgConnectionImplementation get _connection; ResolvedSessionSettings get _settings; Encoding get encoding => _connection._settings.encoding; + TimeZoneSettings get timeZone => _connection._settings.timeZone; void _closeSession() { if (!_sessionClosed) { @@ -321,7 +322,7 @@ class PgConnectionImplementation extends _PgSessionBase implements Connection { return ( StreamChannel>(adaptedStream, outgoingSocket) - .transform(messageTransformer(settings.encoding)), + .transform(messageTransformer(settings.encoding,settings.timeZone)), secure, ); } @@ -754,6 +755,7 @@ class _PgResultStreamSubscription typeOid: field.typeOid, isBinary: field.isBinaryEncoding, encoding: session.encoding, + timeZone: session.timeZone, ); } diff --git a/lib/src/v3/protocol.dart b/lib/src/v3/protocol.dart index e436ed7d..d373c932 100644 --- a/lib/src/v3/protocol.dart +++ b/lib/src/v3/protocol.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:async/async.dart'; +import 'package:postgres/src/timezone_settings.dart'; import 'package:stream_channel/stream_channel.dart'; import '../buffer.dart'; @@ -34,9 +35,9 @@ class AggregatedClientMessage extends ClientMessage { } StreamChannelTransformer> messageTransformer( - Encoding encoding) { + Encoding encoding, TimeZoneSettings timeZone) { return StreamChannelTransformer( - _readMessages(encoding), + _readMessages(encoding, timeZone), StreamSinkTransformer.fromHandlers( handleData: (message, out) { if (message is! ClientMessage) { @@ -53,10 +54,11 @@ StreamChannelTransformer> messageTransformer( ); } -StreamTransformer _readMessages(Encoding encoding) { +StreamTransformer _readMessages( + Encoding encoding, TimeZoneSettings timeZone) { return StreamTransformer.fromBind((rawStream) { return Stream.multi((listener) { - final framer = MessageFramer(encoding); + final framer = MessageFramer(encoding, timeZone); var paused = false; diff --git a/lib/src/v3/resolved_settings.dart b/lib/src/v3/resolved_settings.dart index bb750b10..68d5df2b 100644 --- a/lib/src/v3/resolved_settings.dart +++ b/lib/src/v3/resolved_settings.dart @@ -42,7 +42,7 @@ class ResolvedConnectionSettings extends ResolvedSessionSettings @override final String? applicationName; @override - final String timeZone; + final TimeZoneSettings timeZone; @override final Encoding encoding; @override @@ -62,7 +62,7 @@ class ResolvedConnectionSettings extends ResolvedSessionSettings ConnectionSettings? super.settings, ConnectionSettings? super.fallback) : applicationName = settings?.applicationName ?? fallback?.applicationName, - timeZone = settings?.timeZone ?? fallback?.timeZone ?? 'UTC', + timeZone = settings?.timeZone ?? fallback?.timeZone ?? TimeZoneSettings('UTC'), encoding = settings?.encoding ?? fallback?.encoding ?? utf8, sslMode = settings?.sslMode ?? fallback?.sslMode ?? SslMode.require, securityContext = settings?.securityContext, diff --git a/pubspec.yaml b/pubspec.yaml index 96cfc406..f9aba70e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: postgres description: PostgreSQL database driver. Supports statement reuse and binary protocol and connection pooling. -version: 3.2.1 +version: 3.3.0 homepage: https://github.com/isoos/postgresql-dart topics: - sql @@ -21,6 +21,7 @@ dependencies: charcode: ^1.3.1 meta: ^1.8.0 pool: ^1.5.1 + pg_timezone: ^1.0.0 dev_dependencies: lints: ^4.0.0 diff --git a/test/framer_test.dart b/test/framer_test.dart index cc400108..8d4d8b10 100644 --- a/test/framer_test.dart +++ b/test/framer_test.dart @@ -6,12 +6,13 @@ import 'package:postgres/src/message_window.dart'; import 'package:postgres/src/messages/logical_replication_messages.dart'; import 'package:postgres/src/messages/server_messages.dart'; import 'package:postgres/src/messages/shared_messages.dart'; +import 'package:postgres/src/timezone_settings.dart'; import 'package:test/test.dart'; void main() { late MessageFramer framer; setUp(() { - framer = MessageFramer(utf8); + framer = MessageFramer(utf8, TimeZoneSettings('UTC')); }); tearDown(() {