From a54635e2f6d326bc15e8e5082cf5b68de430b948 Mon Sep 17 00:00:00 2001 From: xiazcy Date: Sun, 27 Jul 2025 21:26:59 -0700 Subject: [PATCH] Backport OffsetDateTime serializers in GLVs to enable deserialization from server. Date remains the default serializer for GLV native date types. --- CHANGELOG.asciidoc | 1 + .../Structure/IO/GraphBinary/DataType.cs | 1 + .../IO/GraphBinary/TypeSerializerRegistry.cs | 1 + .../Types/OffsetDateTimeSerializer.cs | 80 ++++++++++ .../Structure/IO/GraphSON/GraphSONReader.cs | 1 + .../IO/GraphSON/OffsetDateTimeDeserializer.cs | 37 +++++ .../IO/GraphSON/OffsetDateTimeSerializer.cs | 37 +++++ .../IO/GraphSON/GraphSONReaderTests.cs | 13 ++ gremlin-go/driver/graphBinary.go | 72 +++++++++ gremlin-go/driver/graphBinary_test.go | 33 ++++ gremlin-go/driver/serializer.go | 8 +- .../lib/structure/io/binary/GraphBinary.js | 1 + .../internals/OffsetDateTimeSerializer.js | 151 ++++++++++++++++++ .../lib/structure/io/graph-serializer.js | 1 + .../lib/structure/io/type-serializers.js | 8 + .../unit/graphbinary/AnySerializer-test.js | 9 ++ .../test/unit/graphson-test.js | 6 + .../structure/io/graphbinaryV1.py | 39 ++++- .../structure/io/graphsonV2d0.py | 17 ++ .../structure/io/graphsonV3d0.py | 17 ++ .../tests/structure/io/test_graphbinaryV1.py | 27 +++- .../tests/structure/io/test_graphsonV2d0.py | 24 ++- .../tests/structure/io/test_graphsonV3d0.py | 24 ++- 23 files changed, 593 insertions(+), 15 deletions(-) create mode 100644 gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/Types/OffsetDateTimeSerializer.cs create mode 100644 gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/OffsetDateTimeDeserializer.cs create mode 100644 gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/OffsetDateTimeSerializer.cs create mode 100644 gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/binary/internals/OffsetDateTimeSerializer.js diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 069d52abff5..0d01792b1cf 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -55,6 +55,7 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima * Support hot reloading of SSL certificates. * Increase default `max_content_length`/`max_msg_size` in `gremlin-python` from 4MB to 10MB. * Added the `PopContaining` interface designed to get label and `Pop` combinations held in a `PopInstruction` object. +* Backport `OffsetDateTime` serializers from 4.0.0-beta for deserialization. Note `Date` remains the default serializer for GLV native date types. * Fixed bug preventing a vertex from being dropped and then re-added in the same `TinkerTransaction` [[release-3-7-3]] diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/DataType.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/DataType.cs index 6ccad029377..161db1b1af9 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/DataType.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/DataType.cs @@ -78,6 +78,7 @@ public class DataType : IEquatable // TODO: Support metrics and traversal metrics public static readonly DataType Char = new DataType(0x80); public static readonly DataType Duration = new DataType(0x81); + public static readonly DataType OffsetDateTime = new DataType(0x88); #pragma warning restore 1591 /// diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/TypeSerializerRegistry.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/TypeSerializerRegistry.cs index e7f1997771e..c51a1aceafe 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/TypeSerializerRegistry.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/TypeSerializerRegistry.cs @@ -133,6 +133,7 @@ public class TypeSerializerRegistry {DataType.BulkSet, new BulkSetSerializer>()}, {DataType.Char, new CharSerializer()}, {DataType.Duration, new DurationSerializer()}, + {DataType.OffsetDateTime, new OffsetDateTimeSerializer()}, }; private readonly Dictionary _serializerByCustomTypeName = diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/Types/OffsetDateTimeSerializer.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/Types/OffsetDateTimeSerializer.cs new file mode 100644 index 00000000000..a8b1fa6c1f3 --- /dev/null +++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary/Types/OffsetDateTimeSerializer.cs @@ -0,0 +1,80 @@ +#region License + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#endregion + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Gremlin.Net.Structure.IO.GraphBinary.Types +{ + /// + /// A serializer for the GraphBinary type OffsetDateTime, represented as + /// in .NET. + /// + public class OffsetDateTimeSerializer : SimpleTypeSerializer + { + + /// + /// Initializes a new instance of the class. + /// + public OffsetDateTimeSerializer() : base(DataType.OffsetDateTime) + { + } + + /// + protected override async Task WriteValueAsync(DateTimeOffset value, Stream stream, GraphBinaryWriter writer, + CancellationToken cancellationToken = default) + { + await stream.WriteIntAsync(value.Year, cancellationToken).ConfigureAwait(false); + await stream.WriteByteAsync(Convert.ToByte(value.Month), cancellationToken).ConfigureAwait(false); + await stream.WriteByteAsync(Convert.ToByte(value.Day), cancellationToken).ConfigureAwait(false); + // Note that nanosecond precisions were added after .NET 7 + // Get the time of day as TimeSpan + var timeOfDay = value.TimeOfDay; + // Convert ticks to nanoseconds (1 tick = 100 nanoseconds) + var ns = timeOfDay.Ticks * 100; + await stream.WriteLongAsync(Convert.ToInt64(ns), cancellationToken).ConfigureAwait(false); + + var offset = value.Offset; + var os = offset.Hours * 60 * 60 + offset.Minutes * 60 + offset.Seconds; + await stream.WriteIntAsync(os, cancellationToken).ConfigureAwait(false); + } + + /// + protected override async Task ReadValueAsync(Stream stream, GraphBinaryReader reader, + CancellationToken cancellationToken = default) + { + var year = await stream.ReadIntAsync(cancellationToken).ConfigureAwait(false); + var month = await stream.ReadByteAsync(cancellationToken).ConfigureAwait(false); + var day = await stream.ReadByteAsync(cancellationToken).ConfigureAwait(false); + var ns = await stream.ReadLongAsync(cancellationToken).ConfigureAwait(false); + var timeDelta = TimeSpan.FromMilliseconds(ns / 1e6); + + var os = await stream.ReadIntAsync(cancellationToken).ConfigureAwait(false); + var offset = TimeSpan.FromSeconds(os); + + return new DateTimeOffset(year, Convert.ToInt32(month), Convert.ToInt32(day), 0, 0, 0, offset).Add(timeDelta); + } + } +} \ No newline at end of file diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/GraphSONReader.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/GraphSONReader.cs index 209c0561f41..779c5594a1d 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/GraphSONReader.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/GraphSONReader.cs @@ -58,6 +58,7 @@ public abstract class GraphSONReader { "g:T", new TDeserializer() }, //Extended + { "gx:OffsetDateTime", new OffsetDateTimeDeserializer() }, { "gx:BigDecimal", new DecimalConverter() }, { "gx:Duration", new DurationDeserializer() }, { "gx:BigInteger", new BigIntegerDeserializer() }, diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/OffsetDateTimeDeserializer.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/OffsetDateTimeDeserializer.cs new file mode 100644 index 00000000000..9840fce0ad3 --- /dev/null +++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/OffsetDateTimeDeserializer.cs @@ -0,0 +1,37 @@ +#region License + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +#endregion + +using System; +using System.IO; +using System.Text.Json; + +namespace Gremlin.Net.Structure.IO.GraphSON +{ + internal class OffsetDateTimeDeserializer : IGraphSONDeserializer + { + public dynamic Objectify(JsonElement graphsonObject, GraphSONReader reader) + { + return DateTimeOffset.Parse(graphsonObject.GetString() ?? + throw new IOException("Read null but expected a OffsetDateTime value")); + } + } +} \ No newline at end of file diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/OffsetDateTimeSerializer.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/OffsetDateTimeSerializer.cs new file mode 100644 index 00000000000..05de9c41648 --- /dev/null +++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphSON/OffsetDateTimeSerializer.cs @@ -0,0 +1,37 @@ +#region License + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#endregion + +using System; +using System.Collections.Generic; + +namespace Gremlin.Net.Structure.IO.GraphSON +{ + internal class OffsetDateTimeSerializer : IGraphSONSerializer + { + public Dictionary Dictify(dynamic objectData, GraphSONWriter writer) + { + DateTimeOffset value = objectData; + return GraphSONUtil.ToTypedValue("OffsetDateTime", value.ToString("O"), "gx"); + } + } +} \ No newline at end of file diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/IO/GraphSON/GraphSONReaderTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/IO/GraphSON/GraphSONReaderTests.cs index 591e3b4378f..8921e682182 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/IO/GraphSON/GraphSONReaderTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/IO/GraphSON/GraphSONReaderTests.cs @@ -108,6 +108,19 @@ public void ShouldDeserializeDateToDateTimeOffset(int version) Assert.Equal(expectedDateTimeOffset, deserializedValue); } + [Theory, MemberData(nameof(Versions))] + public void ShouldDeserializeOffsetDateTimeToDateTimeOffset(int version) + { + const string graphSon = "{\"@type\":\"gx:OffsetDateTime\",\"@value\":\"2016-10-04T12:17:22.5520000+00:00\"}"; + var reader = CreateStandardGraphSONReader(version); + + var jsonElement = JsonSerializer.Deserialize(graphSon); + var deserializedValue = reader.ToObject(jsonElement); + + var expectedDateTimeOffset = TestUtils.FromJavaTime(1475583442552); + Assert.Equal(expectedDateTimeOffset, deserializedValue); + } + [Theory, MemberData(nameof(Versions))] public void ShouldDeserializeDictionary(int version) { diff --git a/gremlin-go/driver/graphBinary.go b/gremlin-go/driver/graphBinary.go index 39c6d2f0293..0baa9ea773d 100644 --- a/gremlin-go/driver/graphBinary.go +++ b/gremlin-go/driver/graphBinary.go @@ -85,6 +85,7 @@ const ( metricsType dataType = 0x2c traversalMetricsType dataType = 0x2d durationType dataType = 0x81 + offsetDateTimeType dataType = 0x88 nullType dataType = 0xFE ) @@ -509,6 +510,40 @@ func timeWriter(value interface{}, buffer *bytes.Buffer, _ *graphBinaryTypeSeria return buffer.Bytes(), nil } +// Datetime remains serialized as Date by default, real use is the ability to deserialize OffsetDateTime +func offsetDateTimeWriter(value interface{}, buffer *bytes.Buffer, _ *graphBinaryTypeSerializer) ([]byte, error) { + t := value.(time.Time) + err := binary.Write(buffer, binary.BigEndian, int32(t.Year())) + if err != nil { + return nil, err + } + + err = binary.Write(buffer, binary.BigEndian, byte(t.Month())) + if err != nil { + return nil, err + } + err = binary.Write(buffer, binary.BigEndian, byte(t.Day())) + if err != nil { + return nil, err + } + // construct time of day in nanoseconds + h := int64(t.Hour()) + m := int64(t.Minute()) + s := int64(t.Second()) + ns := (h * 60 * 60 * 1e9) + (m * 60 * 1e9) + (s * 1e9) + int64(t.Nanosecond()) + err = binary.Write(buffer, binary.BigEndian, ns) + if err != nil { + return nil, err + } + _, os := t.Zone() + err = binary.Write(buffer, binary.BigEndian, int32(os)) + if err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + func durationWriter(value interface{}, buffer *bytes.Buffer, _ *graphBinaryTypeSerializer) ([]byte, error) { t := value.(time.Duration) sec := int64(t / time.Second) @@ -1044,6 +1079,43 @@ func timeReader(data *[]byte, i *int) (interface{}, error) { return time.UnixMilli(readLongSafe(data, i)), nil } +func offsetDateTimeReader(data *[]byte, i *int) (interface{}, error) { + year := readIntSafe(data, i) + month := readByteSafe(data, i) + day := readByteSafe(data, i) + totalNS := readLongSafe(data, i) + // calculate hour, minute, second, and ns from totalNS (int64) to prevent int overflow in the nanoseconds arg + ns := totalNS % 1e9 + totalS := totalNS / 1e9 + s := totalS % 60 + totalM := totalS / 60 + m := totalM % 60 + h := totalM / 60 + + offset := readIntSafe(data, i) + datetime := time.Date(int(year), time.Month(month), int(day), int(h), int(m), int(s), int(ns), GetTimezoneFromOffset(int(offset))) + return datetime, nil +} + +// GetTimezoneFromOffset is a helper function to convert an offset in seconds to a time.Location +func GetTimezoneFromOffset(offsetSeconds int) *time.Location { + // calculate hours and minutes from seconds + hours := offsetSeconds / 3600 + minutes := (offsetSeconds % 3600) / 60 + + // format the timezone name in the format that go expects + // for example: "UTC+01:00" or "UTC-05:30" + sign := "+" + if hours < 0 { + sign = "-" + hours = -hours + minutes = -minutes + } + tzName := fmt.Sprintf("UTC%s%02d:%02d", sign, hours, minutes) + + return time.FixedZone(tzName, offsetSeconds) +} + func durationReader(data *[]byte, i *int) (interface{}, error) { return time.Duration(readLongSafe(data, i)*int64(time.Second) + int64(readIntSafe(data, i))), nil } diff --git a/gremlin-go/driver/graphBinary_test.go b/gremlin-go/driver/graphBinary_test.go index d8c66d2816b..1cf73442704 100644 --- a/gremlin-go/driver/graphBinary_test.go +++ b/gremlin-go/driver/graphBinary_test.go @@ -312,6 +312,39 @@ func TestGraphBinaryV1(t *testing.T) { assert.Nil(t, err) assert.Equal(t, source, res) }) + t.Run("read-write local datetime", func(t *testing.T) { + pos := 0 + var buffer bytes.Buffer + source := time.Date(2022, 5, 10, 9, 51, 0, 0, time.Local) + buf, err := offsetDateTimeWriter(source, &buffer, nil) + assert.Nil(t, err) + res, err := offsetDateTimeReader(&buf, &pos) + assert.Nil(t, err) + // ISO format + assert.Equal(t, source.Format(time.RFC3339Nano), res.(time.Time).Format(time.RFC3339Nano)) + }) + t.Run("read-write UTC datetime", func(t *testing.T) { + pos := 0 + var buffer bytes.Buffer + source := time.Date(2022, 5, 10, 9, 51, 0, 0, time.UTC) + buf, err := offsetDateTimeWriter(source, &buffer, nil) + assert.Nil(t, err) + res, err := offsetDateTimeReader(&buf, &pos) + assert.Nil(t, err) + // ISO format + assert.Equal(t, source.Format(time.RFC3339Nano), res.(time.Time).Format(time.RFC3339Nano)) + }) + t.Run("read-write HST datetime", func(t *testing.T) { + pos := 0 + var buffer bytes.Buffer + source := time.Date(2022, 5, 10, 9, 51, 34, 123456789, GetTimezoneFromOffset(-36000)) + buf, err := offsetDateTimeWriter(source, &buffer, nil) + assert.Nil(t, err) + res, err := offsetDateTimeReader(&buf, &pos) + assert.Nil(t, err) + // ISO format + assert.Equal(t, source.Format(time.RFC3339Nano), res.(time.Time).Format(time.RFC3339Nano)) + }) }) t.Run("error handle tests", func(t *testing.T) { diff --git a/gremlin-go/driver/serializer.go b/gremlin-go/driver/serializer.go index 04a7e468b5c..b7aa4a9750b 100644 --- a/gremlin-go/driver/serializer.go +++ b/gremlin-go/driver/serializer.go @@ -265,6 +265,7 @@ func initSerializers() { setType: setWriter, dateType: timeWriter, durationType: durationWriter, + offsetDateTimeType: offsetDateTimeWriter, cardinalityType: enumWriter, columnType: enumWriter, directionType: enumWriter, @@ -310,9 +311,10 @@ func initDeserializers() { classType: readClass, // Date Time - dateType: timeReader, - timestampType: timeReader, - durationType: durationReader, + dateType: timeReader, + timestampType: timeReader, + offsetDateTimeType: offsetDateTimeReader, + durationType: durationReader, // Graph traverserType: traverserReader, diff --git a/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/binary/GraphBinary.js b/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/binary/GraphBinary.js index c5bf3c57202..17424548376 100644 --- a/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/binary/GraphBinary.js +++ b/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/binary/GraphBinary.js @@ -72,6 +72,7 @@ ioc.longSerializer = new (require('./internals/LongSerializer'))(io ioc.longSerializerNg = new (require('./internals/LongSerializerNg'))(ioc); ioc.stringSerializer = new (require('./internals/StringSerializer'))(ioc, ioc.DataType.STRING); ioc.dateSerializer = new (require('./internals/DateSerializer'))(ioc, ioc.DataType.DATE); +ioc.offsetDateTimeSerializer = new (require('./internals/OffsetDateTimeSerializer'))(ioc, ioc.DataType.OFFSETDATETIME); ioc.timestampSerializer = new (require('./internals/DateSerializer'))(ioc, ioc.DataType.TIMESTAMP); ioc.classSerializer = new (require('./internals/StringSerializer'))(ioc, ioc.DataType.CLASS); ioc.doubleSerializer = new (require('./internals/DoubleSerializer'))(ioc); diff --git a/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/binary/internals/OffsetDateTimeSerializer.js b/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/binary/internals/OffsetDateTimeSerializer.js new file mode 100644 index 00000000000..30045ffd0ff --- /dev/null +++ b/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/binary/internals/OffsetDateTimeSerializer.js @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +'use strict'; + +const { Buffer } = require('buffer'); + +module.exports = class OffsetDateTimeSerializer { + constructor(ioc, ID) { + this.ioc = ioc; + this.ID = ID; + this.ioc.serializers[ID] = this; + } + + canBeUsedFor(value) { + return value instanceof Date; + } + + serialize(item, fullyQualifiedFormat = true) { + if (item === undefined || item === null) { + if (fullyQualifiedFormat) { + return Buffer.from([this.ID, 0x01]); + } + return Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + } + + const bufs = []; + if (fullyQualifiedFormat) { + bufs.push(Buffer.from([this.ID, 0x00])); + } + + // NOTE: js Date will always display time in UTC, but regular date/hour getters will return in local system time. + // To avoid inconsistency we will always serialize the UTC representation of the Date object with offset of 0. + + // {year} + let v = Buffer.alloc(4); + v.writeInt32BE(item.getUTCFullYear()); + bufs.push(v); + + // {month} + v = Buffer.alloc(1); + v.writeUInt8(item.getUTCMonth() + 1); // Java Core DateTime serializer uses 1 - 12 for months, JS uses indices + bufs.push(v); + + // {day} - in UTC + v = Buffer.alloc(1); + v.writeUInt8(item.getUTCDate()); + bufs.push(v); + + // {nanoseconds} + const h = item.getUTCHours(); // in UTC + const m = item.getUTCMinutes(); + const s = item.getUTCSeconds(); + const ms = item.getUTCMilliseconds(); + const ns = h * 60 * 60 * 1e9 + m * 60 * 1e9 + s * 1e9 + ms * 1e6; + v = Buffer.alloc(8); + v.writeBigInt64BE(BigInt(ns)); + bufs.push(v); + + // {zone offset} - UTC is always used for serialization, as such offset will be 0 + v = Buffer.alloc(4); + v.writeInt32BE(0); + bufs.push(v); + + return Buffer.concat(bufs); + } + + deserialize(buffer, fullyQualifiedFormat = true) { + let len = 0; + let cursor = buffer; + + try { + if (buffer === undefined || buffer === null || !(buffer instanceof Buffer)) { + throw new Error('buffer is missing'); + } + if (buffer.length < 1) { + throw new Error('buffer is empty'); + } + + if (fullyQualifiedFormat) { + const type_code = cursor.readUInt8(); + len++; + if (type_code !== this.ID) { + throw new Error('unexpected {type_code}'); + } + cursor = cursor.slice(1); + + if (cursor.length < 1) { + throw new Error('{value_flag} is missing'); + } + const value_flag = cursor.readUInt8(); + len++; + if (value_flag === 1) { + return { v: null, len }; + } + if (value_flag !== 0) { + throw new Error('unexpected {value_flag}'); + } + cursor = cursor.slice(1); + } + + if (cursor.length < 8) { + throw new Error('unexpected {value} length'); + } + len += 18; + + const year = cursor.readInt32BE(); + cursor = cursor.slice(4); + const month = cursor.readUInt8() - 1; + cursor = cursor.slice(1); + const date = cursor.readUInt8(); + cursor = cursor.slice(1); + const ns = cursor.readBigInt64BE(); + cursor = cursor.slice(8); + const offset = cursor.readInt32BE(); + cursor.slice(4); + + // calculate hour, minute, second, and ms from ns as JS Date doesn't have ns precision + const totalMS = ns / BigInt(1e6); + const ms = Number(totalMS) % 1e3; + const totalS = Math.trunc(Number(totalMS) / 1e3) - offset; // js Date doesn't have a way to set offset properly, account offset here to use UTC + const s = totalS % 60; + const totalM = Math.trunc(totalS / 60); + const m = totalM % 60; + const h = Math.trunc(totalM / 60); + + // use UTC time calculated with offset above + const v = new Date(Date.UTC(year, month, date, h, m, s, ms)); + + return { v, len }; + } catch (err) { + throw this.ioc.utils.des_error({ serializer: this, args: arguments, cursor, err }); + } + } +}; diff --git a/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/graph-serializer.js b/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/graph-serializer.js index 71b1ba2dcc9..5c92789df34 100644 --- a/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/graph-serializer.js +++ b/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/graph-serializer.js @@ -253,6 +253,7 @@ const graphSON2Deserializers = { 'g:Float': typeSerializers.NumberSerializer, 'g:Double': typeSerializers.NumberSerializer, 'g:Date': typeSerializers.DateSerializer, + 'gx:OffsetDateTime': typeSerializers.OffsetDateTimeSerializer, 'g:Direction': typeSerializers.DirectionSerializer, 'g:Vertex': typeSerializers.VertexSerializer, 'g:Edge': typeSerializers.EdgeSerializer, diff --git a/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/type-serializers.js b/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/type-serializers.js index 3eefbaad82e..0791c6d5cf2 100644 --- a/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/type-serializers.js +++ b/gremlin-javascript/src/main/javascript/gremlin-javascript/lib/structure/io/type-serializers.js @@ -103,6 +103,13 @@ class DateSerializer extends TypeSerializer { } } +class OffsetDateTimeSerializer extends TypeSerializer { + // only deserialize gx:OffsetDateTime objects + deserialize(obj) { + return new Date(obj[valueKey]); + } +} + class LongSerializer extends TypeSerializer { serialize(item) { return { @@ -480,6 +487,7 @@ module.exports = { BulkSetSerializer, BytecodeSerializer, DateSerializer, + OffsetDateTimeSerializer, DirectionSerializer, EdgeSerializer, EnumSerializer, diff --git a/gremlin-javascript/src/main/javascript/gremlin-javascript/test/unit/graphbinary/AnySerializer-test.js b/gremlin-javascript/src/main/javascript/gremlin-javascript/test/unit/graphbinary/AnySerializer-test.js index b827ebb9d52..967bccca4fa 100644 --- a/gremlin-javascript/src/main/javascript/gremlin-javascript/test/unit/graphbinary/AnySerializer-test.js +++ b/gremlin-javascript/src/main/javascript/gremlin-javascript/test/unit/graphbinary/AnySerializer-test.js @@ -374,6 +374,15 @@ describe('GraphBinary.AnySerializer', () => { { v:null, b:[0x04,0x01] }, { v:new Date('1969-12-31T23:59:59.999Z'), b:[0x04,0x00, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF] }, + // OFFSETDATETIME + { v:null, b:[0x88,0x01] }, + { v:new Date('2023-08-02T01:30:00-10:00'), + b:[0x88,0x00, + 0x00,0x00,0x07,0xe7, + 0x08,0x02, + 0x00,0x00,0x04,0xe9,0x49,0x14,0xf0,0x00, + 0xFF,0xFF,0x73,0x60] }, + // TIMESTAMP { v:null, b:[0x05,0x01] }, { v:new Date('1969-12-31T23:59:59.999Z'), b:[0x05,0x00, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF] }, diff --git a/gremlin-javascript/src/main/javascript/gremlin-javascript/test/unit/graphson-test.js b/gremlin-javascript/src/main/javascript/gremlin-javascript/test/unit/graphson-test.js index cc7febc4d61..6b899adb9ff 100644 --- a/gremlin-javascript/src/main/javascript/gremlin-javascript/test/unit/graphson-test.js +++ b/gremlin-javascript/src/main/javascript/gremlin-javascript/test/unit/graphson-test.js @@ -89,6 +89,12 @@ describe('GraphSONReader', function () { const result = reader.read(obj); assert.ok(result instanceof Date); }); + it('should parse OffsetDateTime', function() { + const obj = { "@type" : "gx:OffsetDateTime", "@value" : "2016-12-14T21:14:36.295Z" }; + const reader = new GraphSONReader(); + const result = reader.read(obj); + assert.ok(result instanceof Date); + }); it('should parse vertices from GraphSON', function () { const obj = { "@type":"g:Vertex", "@value":{"id":{"@type":"g:Int32","@value":1},"label":"person", diff --git a/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV1.py b/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV1.py index 20228bcd1c8..a38f4cc3878 100644 --- a/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV1.py +++ b/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV1.py @@ -106,7 +106,7 @@ class DataType(Enum): localdatetime = 0x85 # todo localtime = 0x86 # todo monthday = 0x87 # todo - offsetdatetime = 0x88 # todo + offsetdatetime = 0x88 offsettime = 0x89 # todo period = 0x8a # todo year = 0x8b # todo @@ -343,6 +343,43 @@ def objectify(cls, buff, reader, nullable=True): nullable) +class OffsetDateTimeDeserializer(_GraphBinaryTypeIO): + # datetime remains serialized as Date by default, real use is the ability to deserialize OffsetDateTime + graphbinary_type = DataType.offsetdatetime + + @classmethod + def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True): + if obj.tzinfo is None: + return DateIO.dictify(obj, writer, to_extend, as_value, nullable) + cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend) + IntIO.dictify(obj.year, writer, to_extend, True, False) + ByteIO.dictify(obj.month, writer, to_extend, True, False) + ByteIO.dictify(obj.day, writer, to_extend, True, False) + # construct time of day in nanoseconds + h = obj.time().hour + m = obj.time().minute + s = obj.time().second + ms = obj.time().microsecond + ns = round((h*60*60*1e9) + (m*60*1e9) + (s*1e9) + (ms*1e3)) + LongIO.dictify(ns, writer, to_extend, True, False) + os = round(obj.utcoffset().total_seconds()) + IntIO.dictify(os, writer, to_extend, True, False) + return to_extend + + @classmethod + def objectify(cls, buff, reader, nullable=True): + return cls.is_null(buff, reader, cls._read_dt, nullable) + + @classmethod + def _read_dt(cls, b, r): + year = r.to_object(b, DataType.int, False) + month = r.to_object(b, DataType.byte, False) + day = r.to_object(b, DataType.byte, False) + ns = r.to_object(b, DataType.long, False) + offset = r.to_object(b, DataType.int, False) + tz = datetime.timezone(timedelta(seconds=offset)) + return datetime.datetime(year, month, day, tzinfo=tz) + timedelta(microseconds=ns/1000) + # Based on current implementation, this class must always be declared before FloatIO. # Seems pretty fragile for future maintainers. Maybe look into this. class TimestampIO(_GraphBinaryTypeIO): diff --git a/gremlin-python/src/main/python/gremlin_python/structure/io/graphsonV2d0.py b/gremlin-python/src/main/python/gremlin_python/structure/io/graphsonV2d0.py index d4138b6c9d3..fa053279a11 100644 --- a/gremlin-python/src/main/python/gremlin_python/structure/io/graphsonV2d0.py +++ b/gremlin-python/src/main/python/gremlin_python/structure/io/graphsonV2d0.py @@ -360,6 +360,23 @@ def objectify(cls, ts, reader): return datetime.datetime.utcfromtimestamp(ts / 1000.0) + class OffsetDateTimeIO(_GraphSONTypeIO): + graphson_type = "gx:OffsetDateTime" + graphson_base_type = "OffsetDateTime" + + @classmethod + def dictify(cls, obj, writer): + if obj.tzinfo is None: + return DateIO.dictify(obj, writer) + return GraphSONUtil.typed_value(cls.graphson_base_type, obj.isoformat(), "gx") + + @classmethod + def objectify(cls, dt, reader): + # specially handling as python isoformat does not support zulu until 3.11 + dt_iso = dt[:-1] + '+00:00' if dt.endswith('Z') else dt + return datetime.datetime.fromisoformat(dt_iso) + + # Based on current implementation, this class must always be declared before FloatIO. # Seems pretty fragile for future maintainers. Maybe look into this. class TimestampIO(_GraphSONTypeIO): diff --git a/gremlin-python/src/main/python/gremlin_python/structure/io/graphsonV3d0.py b/gremlin-python/src/main/python/gremlin_python/structure/io/graphsonV3d0.py index 692e4d27b07..87425ba2af4 100644 --- a/gremlin-python/src/main/python/gremlin_python/structure/io/graphsonV3d0.py +++ b/gremlin-python/src/main/python/gremlin_python/structure/io/graphsonV3d0.py @@ -367,6 +367,23 @@ def objectify(cls, ts, reader): return datetime.datetime.utcfromtimestamp(ts / 1000.0) +class OffsetDateTimeIO(_GraphSONTypeIO): + graphson_type = "gx:OffsetDateTime" + graphson_base_type = "OffsetDateTime" + + @classmethod + def dictify(cls, obj, writer): + if obj.tzinfo is None: + return DateIO.dictify(obj, writer) + return GraphSONUtil.typed_value(cls.graphson_base_type, obj.isoformat(), "gx") + + @classmethod + def objectify(cls, dt, reader): + # specially handling as python isoformat does not support zulu until 3.11 + dt_iso = dt[:-1] + '+00:00' if dt.endswith('Z') else dt + return datetime.datetime.fromisoformat(dt_iso) + + # Based on current implementation, this class must always be declared before FloatIO. # Seems pretty fragile for future maintainers. Maybe look into this. class TimestampIO(_GraphSONTypeIO): diff --git a/gremlin-python/src/main/python/tests/structure/io/test_graphbinaryV1.py b/gremlin-python/src/main/python/tests/structure/io/test_graphbinaryV1.py index 32201b78a9f..e5f9172645c 100644 --- a/gremlin-python/src/main/python/tests/structure/io/test_graphbinaryV1.py +++ b/gremlin-python/src/main/python/tests/structure/io/test_graphbinaryV1.py @@ -17,11 +17,11 @@ under the License. """ -import datetime import uuid import math -from gremlin_python.statics import timestamp, long, bigint, BigDecimal, SingleByte, SingleChar, ByteBufferType +from datetime import datetime, timedelta, timezone +from gremlin_python.statics import long, bigint, BigDecimal, SingleByte, SingleChar, ByteBufferType, timestamp from gremlin_python.structure.graph import Vertex, Edge, Property, VertexProperty, Path from gremlin_python.structure.io.graphbinaryV1 import GraphBinaryWriter, GraphBinaryReader from gremlin_python.process.traversal import Barrier, Binding, Bytecode, Merge, Direction @@ -31,7 +31,7 @@ class TestGraphBinaryReader(object): graphbinary_reader = GraphBinaryReader() -class TestGraphSONWriter(object): +class TestGraphBinaryWriter(object): graphbinary_writer = GraphBinaryWriter() graphbinary_reader = GraphBinaryReader() @@ -83,7 +83,7 @@ def test_bigdecimal(self): assert x.unscaled_value == output.unscaled_value def test_date(self): - x = datetime.datetime(2016, 12, 14, 16, 14, 36, 295000) + x = datetime(2016, 12, 14, 16, 14, 36, 295000) output = self.graphbinary_reader.read_object(self.graphbinary_writer.write_object(x)) assert x == output @@ -92,6 +92,23 @@ def test_timestamp(self): output = self.graphbinary_reader.read_object(self.graphbinary_writer.write_object(x)) assert x == output + def test_offsetdatetime(self): + tz = timezone(timedelta(seconds=36000)) + ms = 12345678912 + x = datetime(2022, 5, 20, tzinfo=tz) + timedelta(microseconds=ms) + output = self.graphbinary_reader.read_object(bytearray(b'\x88\x00\x00\x00\x07\xe6\x05\x14\x00\x00\x0b:s\xceZ\x00\x00\x00\x8c\xa0')) + assert x == output + + def test_offsetdatetime_format(self): + x = datetime.strptime('2022-05-20T03:25:45.678912Z', '%Y-%m-%dT%H:%M:%S.%f%z') + output = self.graphbinary_reader.read_object(bytearray(b'\x88\x00\x00\x00\x07\xe6\x05\x14\x00\x00\x0b:s\xceZ\x00\x00\x00\x00\x00')) + assert x == output + + def test_offsetdatetime_epoch(self): + x = datetime.fromtimestamp(1690934400).astimezone(timezone.utc) + output = self.graphbinary_reader.read_object(bytearray(b'\x88\x00\x00\x00\x07\xe7\x08\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')) + assert x == output + def test_string(self): x = "serialize this!" output = self.graphbinary_reader.read_object(self.graphbinary_writer.write_object(x)) @@ -233,6 +250,6 @@ def test_char(self): assert x == output def test_duration(self): - x = datetime.timedelta(seconds=1000, microseconds=1000) + x = timedelta(seconds=1000, microseconds=1000) output = self.graphbinary_reader.read_object(self.graphbinary_writer.write_object(x)) assert x == output diff --git a/gremlin-python/src/main/python/tests/structure/io/test_graphsonV2d0.py b/gremlin-python/src/main/python/tests/structure/io/test_graphsonV2d0.py index 48603500137..c4531804913 100644 --- a/gremlin-python/src/main/python/tests/structure/io/test_graphsonV2d0.py +++ b/gremlin-python/src/main/python/tests/structure/io/test_graphsonV2d0.py @@ -249,10 +249,28 @@ def test_datetime(self): expected = datetime.datetime(2016, 12, 14, 16, 14, 36, 295000) pts = calendar.timegm(expected.utctimetuple()) + expected.microsecond / 1e6 ts = int(round(pts * 1000)) - dt = self.graphson_reader.read_object(json.dumps({"@type": "g:Date", "@value": ts})) - assert isinstance(dt, datetime.datetime) + output = self.graphson_reader.read_object(json.dumps({"@type": "g:Date", "@value": ts})) + assert isinstance(output, datetime.datetime) # TINKERPOP-1848 - assert dt == expected + assert expected == output + + def test_offsetdatetime(self): + tz = datetime.timezone(datetime.timedelta(seconds=36000)) + ms = 12345678912 + expected = datetime.datetime(2022, 5, 20, tzinfo=tz) + datetime.timedelta(microseconds=ms) + output = self.graphson_reader.read_object(json.dumps({"@type": "gx:OffsetDateTime", "@value": expected.isoformat()})) + assert isinstance(output, datetime.datetime) + assert expected == output + + def test_offsetdatetime_zulu(self): + tz = datetime.timezone.utc + ms = 12345678912 + expected = datetime.datetime(2022, 5, 20, tzinfo=tz) + datetime.timedelta(microseconds=ms) + # simulate zulu format + expected_zulu = expected.isoformat()[:-6] + 'Z' + output = self.graphson_reader.read_object(json.dumps({"@type": "gx:OffsetDateTime", "@value": expected_zulu})) + assert isinstance(output, datetime.datetime) + assert expected == output def test_timestamp(self): dt = self.graphson_reader.read_object(json.dumps({"@type": "g:Timestamp", "@value": 1481750076295})) diff --git a/gremlin-python/src/main/python/tests/structure/io/test_graphsonV3d0.py b/gremlin-python/src/main/python/tests/structure/io/test_graphsonV3d0.py index a6a65a84e43..6025a0c6747 100644 --- a/gremlin-python/src/main/python/tests/structure/io/test_graphsonV3d0.py +++ b/gremlin-python/src/main/python/tests/structure/io/test_graphsonV3d0.py @@ -295,10 +295,28 @@ def test_datetime(self): expected = datetime.datetime(2016, 12, 14, 16, 14, 36, 295000) pts = calendar.timegm(expected.utctimetuple()) + expected.microsecond / 1e6 ts = int(round(pts * 1000)) - dt = self.graphson_reader.read_object(json.dumps({"@type": "g:Date", "@value": ts})) - assert isinstance(dt, datetime.datetime) + output = self.graphson_reader.read_object(json.dumps({"@type": "g:Date", "@value": ts})) + assert isinstance(output, datetime.datetime) # TINKERPOP-1848 - assert dt == expected + assert expected == output + + def test_offsetdatetime(self): + tz = datetime.timezone(datetime.timedelta(seconds=36000)) + ms = 12345678912 + expected = datetime.datetime(2022, 5, 20, tzinfo=tz) + datetime.timedelta(microseconds=ms) + output = self.graphson_reader.read_object(json.dumps({"@type": "gx:OffsetDateTime", "@value": expected.isoformat()})) + assert isinstance(output, datetime.datetime) + assert expected == output + + def test_offsetdatetime_zulu(self): + tz = datetime.timezone.utc + ms = 12345678912 + expected = datetime.datetime(2022, 5, 20, tzinfo=tz) + datetime.timedelta(microseconds=ms) + # simulate zulu format + expected_zulu = expected.isoformat()[:-6] + 'Z' + output = self.graphson_reader.read_object(json.dumps({"@type": "gx:OffsetDateTime", "@value": expected_zulu})) + assert isinstance(output, datetime.datetime) + assert expected == output def test_timestamp(self): dt = self.graphson_reader.read_object(json.dumps({"@type": "g:Timestamp", "@value": 1481750076295}))