Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

decode timestamp without timezone as local DateTime and decode timestamp with timezone respecting the timezone defined in the connection #342

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`:
Expand Down
2 changes: 1 addition & 1 deletion example/example.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@ void main() async {
print(await subscription.schema);

await conn.close();
}
}
6 changes: 5 additions & 1 deletion lib/postgres.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;

Expand Down
5 changes: 4 additions & 1 deletion lib/src/buffer.dart
Original file line number Diff line number Diff line change
@@ -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<int>` instead, however,
/// it creates a nice explicit type difference from both `String` and `List<int>`,
Expand Down Expand Up @@ -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() {
Expand Down
10 changes: 7 additions & 3 deletions lib/src/message_window.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -36,10 +37,12 @@ Map<int, _ServerMessageFn> _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<ServerMessage>();

MessageFramer(this._encoding);
MessageFramer(this._encoding, this.timeZone);

int? _type;
int _expectedLength = 0;
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions lib/src/messages/client_messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions lib/src/messages/server_messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Copy link
Owner

Choose a reason for hiding this comment

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

Let's not update the timeZone value like this, it is a bit unexpected here. The v3/connection.dart _handleMessage already stores it in PgConnectionImplementation._parameters and we should just read that value from there. I think the settings should be immutable (maybe rename TimeZoneSettings.value into defaultTimeZone), and use the server-provided timeZone if present, otherwise the fallback.

Copy link
Author

Choose a reason for hiding this comment

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

I believe that in this case it should not be immutable because when you use the SQL command "set timezone TO 'GMT';" you are changing the connection configuration, and this has to be reflected in the connection instance, because if after the user executes the SQL command to change the timezone and he reads the timezone property he will want to see the current value, right? I don't know the driver code very well, especially version 3, so I don't know if the driver user can read the timezone of the current connection through some method or property, as I have been very busy I haven't had much time to look at this, I don't know exactly how this could be done in another way.

}
return ParameterStatusMessage._(name, value);
}
}
Expand Down Expand Up @@ -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());
Expand Down
34 changes: 34 additions & 0 deletions lib/src/timezone_settings.dart
Original file line number Diff line number Diff line change
@@ -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;
}
128 changes: 123 additions & 5 deletions lib/src/types/binary_codec.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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'
Expand Down Expand Up @@ -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<V> readListBytes<V>(Uint8List data,
V Function(ByteDataReader reader, int length) valueDecoder) {
if (data.length < 16) {
Expand Down Expand Up @@ -1051,6 +1168,7 @@ class PostgresBinaryDecoder {
bytes: bytes,
isBinary: dinput.isBinary,
encoding: dinput.encoding,
timeZone: dinput.timeZone,
typeRegistry: dinput.typeRegistry)) as T;
}

Expand Down
4 changes: 4 additions & 0 deletions lib/src/types/generic_type.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
});

Expand Down
Loading