Skip to content

Backport OffsetDateTime changes from 4.0#3090

Merged
xiazcy merged 6 commits into3.8-devfrom
datetime-backport
Apr 11, 2025
Merged

Backport OffsetDateTime changes from 4.0#3090
xiazcy merged 6 commits into3.8-devfrom
datetime-backport

Conversation

@xiazcy
Copy link
Contributor

@xiazcy xiazcy commented Apr 4, 2025

OffsetDateTime is now the default Date type in Gremlin.
Changes were back-ported from Java/Python. For Go/JS/.NET, the OffsetDateTime serializers were added.
Made OffsetDateTime the default serializer for dates in GLVs, the Date type will only deserialize responses.

VOTE +1.

…ateTime is now the default Date type in Gremlin; added OffsetDateTime serializers to Go/JS/.NET. Made OffsetDateTime the default serializer for date types in GLVs (Date type will only deserialize).
OffsetDateTime date = (OffsetDateTime) object;
OffsetDateTime new_date;

switch (dateToken) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: dateToken and value should arguably be converted to a Duration in the constructor for this step so this conversion does not need to happen each time the step is iterated.

offset := readIntSafe(data, i)
// only way to pass offset info, timezone display is fixed to UTC as consequence (offset is calculated properly)
loc := time.FixedZone("UTC", int(offset))
datetime := time.Date(int(year), time.Month(month), int(day), 0, 0, 0, int(ns), loc)
Copy link
Contributor

Choose a reason for hiding this comment

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

int(ns) may be an issue for times which are late in the day. According to the io docs, the "time" value can be from 0 to 86,399,999,999,999. The max size of int is only guaranteed to be 2,147,483,647, although on most 64-bit machines it will be a 64 bit integer in practice.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately the Date constructor requires int, it's more like a caveat of go, not sure if it's avoidable.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we need to break the 64bit ns into hours, minutes, seconds, and nanos to ensure none of them will overflow on systems with 32bit ints. We can't allow those datetimes to overload on deserialization.

final OffsetDateTime dt = DatetimeHelper.parse(removeFirstAndLastCharacters(dtString));
sb.append("new Date(");
sb.append(dt.getTime());
sb.append(dt.toInstant().toEpochMilli());

Choose a reason for hiding this comment

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

This should work, and it seems equivalent to what was there before. As long as you've tested this with various time zones it should be fine.

However, I'd wager that if someone is using the translator to translate a query, I doubt they started with a date value as the millisecond since unix epoch. More likely they started with a string version of the date time.

Perhaps a better approach would be to use the ISO string value as the input to new Date(). The precision should be the same (milliseconds), and it supports time zone offsets or UTC. It's just a more human friendly translation.

cal.add(DTtoCalendar.get(dateToken), value);

return cal.getTime();
if (!(object instanceof OffsetDateTime)) throw new IllegalArgumentException("dateAdd accept only DateTime.");

Choose a reason for hiding this comment

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

Should the error be:

dateAdd accept only OffsetDateTime.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We changed it a general DateTime type in 4.0, but you are right it prob make more sense to be OffsetDateTime here. I will also make it accept Date as well for compatibility reasons.

Choose a reason for hiding this comment

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

Is it intentional for this step to only have second precision?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The original design of the step uses the dateToken enums, which only goes to second precision. I suppose we could add support for additional precisions, but that would be outside the scope of this PR.

Copy link
Contributor

Choose a reason for hiding this comment

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

I too find it strange that date add and date diff do not work with millisecond precision.

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree that the available precision here is odd, although this is really out of scope for this PR and should be taken as a separate discussion. I think there should be come consideration of taking a Duration as input instead of an int and DT enum, although some consideration is needed around how to make Duration's constructable in gremlin-lang.

final long otherDateMs = otherDate == null ? 0 : otherDate.toEpochSecond();

return (((Date) object).getTime() - otherDateMs) / 1000;
return (((OffsetDateTime) object).toEpochSecond() - otherDateMs);

Choose a reason for hiding this comment

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

Did you mean to use .toEpochMilli()?

If you did intend to use seconds instead of milliseconds, then you should change the variable name otherDateMs to be otherDateSeconds or something.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No this step meant to return epoch time in seconds, so I'm just directly using it.

final Date dt = (Date) new GenericLiteralVisitor(new GremlinAntlrToJava()).visitDateLiteral(ctx);
assertTrue(new Date().getTime() - dt.getTime() < 1000);
final OffsetDateTime dt = (OffsetDateTime) new GenericLiteralVisitor(new GremlinAntlrToJava()).visitDateLiteral(ctx);
assertTrue(OffsetDateTime.now(UTC).toEpochSecond() - dt.toEpochSecond() < 1000);

Choose a reason for hiding this comment

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

It looks like we are dropping from millisecond to second precision here.

return script.getBoundKeyOrAssign(withParameters, objectOrWrapper);
} else if (object instanceof OffsetDateTime) {
final Object objectOrWrapper = withParameters ? object : getSyntax((OffsetDateTime) object);
return script.getBoundKeyOrAssign(withParameters, objectOrWrapper);

Choose a reason for hiding this comment

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

Now that this exists, should the else if (object instanceof Date) above be removed?

Copy link
Contributor

Choose a reason for hiding this comment

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

I believe it should stay because both Date and OffsetDateTime types are still supported, just now the default is OffsetDateTime.

import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.OffsetDateTime;
import java.util.Date;
Copy link
Contributor

Choose a reason for hiding this comment

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

This import is now unused

import org.apache.tinkerpop.gremlin.util.DatetimeHelper;

import java.time.OffsetDateTime;
import java.util.Date;
Copy link
Contributor

Choose a reason for hiding this comment

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

This import is now unused

import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
Copy link
Contributor

Choose a reason for hiding this comment

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

This import is now unused

import org.apache.tinkerpop.gremlin.util.DatetimeHelper;

import java.time.OffsetDateTime;
import java.util.Date;
Copy link
Contributor

Choose a reason for hiding this comment

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

This import is now unused

import org.apache.tinkerpop.gremlin.structure.VertexProperty;
import org.apache.tinkerpop.gremlin.util.function.ConstantSupplier;

import java.time.OffsetDateTime;
Copy link
Contributor

Choose a reason for hiding this comment

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

Date import is now unused


import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.Date;
Copy link
Contributor

Choose a reason for hiding this comment

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

Unused

if (object instanceof OffsetDateTime)
return (OffsetDateTime) object;
if (object instanceof Byte || object instanceof Short || object instanceof Integer || object instanceof Long)
// numbers handled as milliseconds since January 1, 1970, 00:00:00 GMT.
Copy link
Contributor

Choose a reason for hiding this comment

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

Comment should be updated to UTC instead of GMT which are slightly different.

final Object object = traverser.get();
if (object == null)
throw new IllegalArgumentException("Can't parse null as Date.");
if (object instanceof Date)
Copy link
Contributor

Choose a reason for hiding this comment

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

Technically since Date types are supported isn't it possible to use the asDate step on a traversal that produces Date? In that case should the Date be converted to OffsetDateTime?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this is part of the change I'll revert along with other changes to make it compatible with Date as type.


import java.time.OffsetDateTime;
import java.util.Collections;
import java.util.Date;
Copy link
Contributor

Choose a reason for hiding this comment

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

Unused

final long otherDateMs = otherDate == null ? 0 : otherDate.toEpochSecond();

return (((Date) object).getTime() - otherDateMs) / 1000;
return (((OffsetDateTime) object).toEpochSecond() - otherDateMs);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion:

return otherDate == null ? 0L : Duration.between(otherDate, (OffsetDateTime) object).getSeconds();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like the simplification, not quite the right logic but I can use it, i.e. if otherDate is null we consider it as 0L and return the epoch seconds of date, instead of returning 0L.

import java.time.Month;
import java.time.OffsetDateTime;
import java.time.Year;
import java.time.YearMonth;
Copy link
Contributor

Choose a reason for hiding this comment

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

Unused imports

// 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);
Copy link
Contributor

Choose a reason for hiding this comment

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

Just wondering why OffsetDateTime was not previously supported as a type for .NET? This PR does not add the type to TInkerPop, only changes the default date type.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not just .NET, it actually wasn't supported in any of the GLVs because it's an extended serialization type.

var s = value.Second;
var ms = value.Millisecond;
// Note there will be precision loss as microsecond and nanosecond access was added after .net 7
var ns = h * 60 * 60 * 1e9 + m * 60 * 1e9 + s * 1e9 + ms * 1e6;
Copy link
Contributor

Choose a reason for hiding this comment

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

Q says obtaining nanoseconds is possible using Ticks:

// Convert the time portion of DateTimeOffset to nanoseconds since midnight
  static long TimeToNanosecondsSinceMidnight(DateTimeOffset dto)
  {
      // Get the time of day as TimeSpan
      TimeSpan timeOfDay = dto.TimeOfDay;
      
      // Convert ticks to nanoseconds (1 tick = 100 nanoseconds in .NET)
      return timeOfDay.Ticks * 100;
  }

…e Date as parameter to dateDiff, update date precision in GLV translators.
assertEquals(new Date(1), __.__(1).asDate().next());
assertEquals(new Date(3), __.__(3L).asDate().next());
assertEquals(new Date(6), __.__(new BigInteger("6")).asDate().next());
assertEquals(testDate, __.__(testDate.getTime()).asDate().next());
Copy link
Contributor

Choose a reason for hiding this comment

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

You should add tests where Date is the input traverser. Same goes for the DateAddStepTest and DateDiffStepTest

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup I can do that for asDate and dateAdd (tests in dateDiff were added already)

import org.apache.tinkerpop.gremlin.util.DatetimeHelper;

import java.math.BigInteger;
import java.time.OffsetDateTime;
Copy link
Contributor

Choose a reason for hiding this comment

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

Unused imports

append(", ").append(dt.getMinute()).
append(", ").append(dt.getSecond()).
append(", ").append(dt.getNano()).
append(", time.FixedZone(\"UTC\", ").append(dt.getOffset().getTotalSeconds()).append(")").
Copy link
Contributor

Choose a reason for hiding this comment

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

Looking at documentation https://pkg.go.dev/time#FixedZone it looks like the zone name should include the offset, ie. UTC-8.

Also can a test be added to GremlinTranslatorTest.java for non zero offset scenario?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can add the tests.

Technically the fixed zone name in go for this is a string only for display purposes...I.e. you can pass in any random string and it just appears at the end of your time display (doesn't affect the date calculation at all). For simplicity I just left the "UTC" string in, but can get the zoneId from OffsetDateTime instead.

}

/**
* Returns the difference between two {@link OffsetDateTime} in epoch time.
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: could use a comment about the supported traversal types which has been changed from Date to ?. ie. explain why wasn't it just changed to OffsetDateTime?

newDate = date.plus(Duration.ofDays(value));
break;
default:
throw new IllegalArgumentException("DT tokens should only be second, minute, hour, or day.");
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this validation be moved to the constructor instead?

public DateDiffStep(final Traversal.Admin traversal, final Traversal<?, Date> dateTraversal) {
public DateDiffStep(final Traversal.Admin traversal, final Traversal<?, ?> dateTraversal) {
super(traversal);
this.dateTraversal = this.integrateChild(dateTraversal.asAdmin());
Copy link
Contributor

@andreachild andreachild Apr 10, 2025

Choose a reason for hiding this comment

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

If the incoming Traversal is of type ? then shouldn't this.dateTraversal be of type ? as well since there's no guarantee the traversal resolves to an OffsetDateTime?

The current typing confuses IntelliJ:

Screenshot 2025-04-10 at 10 21 12 AM

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh yes, that was a miss

date = ((Date) object).toInstant().atOffset(ZoneOffset.UTC);
} else {
throw new IllegalArgumentException(
String.format("DateDiff can only take OffsetDateTime or Date (deprecated) as argument, encountered %s", object.getClass()));
Copy link
Contributor

Choose a reason for hiding this comment

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

This can throw NullPointerException if object is null. Should the null logic handling be made consistent with the null logic handling for dateTraversal that assumes null means zero?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yea this might need some handling, but I'd say out of scope for the PR. Logged in Jira: https://issues.apache.org/jira/browse/TINKERPOP-3152

return nil, err
}
// construct time of day in nanoseconds
h := t.Hour()
Copy link
Contributor

Choose a reason for hiding this comment

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

Q recommends type conversion to int64 for these values to prevent issues with overflow on 32 bit systems.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmmm technically all date methods return/accept int, so that will remain an issue for 32 bit system regardless, but I can make it int64 to make the problem a bit less.

@Cole-Greer
Copy link
Contributor

VOTE +1 pending fix to Go GraphBinary overflow issue and upgrade docs.

@kenhuuu
Copy link
Contributor

kenhuuu commented Apr 10, 2025

VOTE +1

@xiazcy xiazcy merged commit a0c5851 into 3.8-dev Apr 11, 2025
36 checks passed
@xiazcy xiazcy deleted the datetime-backport branch April 11, 2025 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants