Skip to content

feat: add SecondsSinceEpochScalar #163

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

Open
wants to merge 3 commits into
base: valueToLiteral_support
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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,22 @@ scalar LocalTime
</pre></td>
<td>24-hour clock time string in the format <code>hh:mm:ss.sss</code> or <code>hh:mm:ss</code> if partial seconds is zero and produces <code>java.time.LocalTime</code> objects at runtime.</td>
</tr>
<tr>
<td><pre lang="graphql">
scalar SecondsSinceEpoch
</pre></td>
<td>A scalar that represents a point in time as seconds since the Unix epoch (January 1, 1970, 00:00:00 UTC). It accepts integers or strings containing integers as input values and produces <code>java.time.ZonedDateTime</code> objects at runtime (with UTC timezone).<br><br>
Using seconds since epoch is preferable to formatted date time strings in several scenarios:
<ul>
<li>When you need a universal representation of a point in time that is timezone-agnostic</li>
<li>For easier date/time arithmetic and comparison operations</li>
<li>When storage space or bandwidth efficiency is important (more compact representation)</li>
<li>To avoid complexities with different date formats and timezone conversions</li>
<li>For better interoperability with systems that natively work with Unix timestamps</li>
<li>When working with time-series data or logging systems where timestamps are commonly used</li>
</ul>
However, human readability is sacrificed compared to formatted date strings, so consider your use case requirements when choosing between <code>DateTime</code> and <code>SecondsSinceEpoch</code>.</td>
</tr>
</table>

An example declaration in SDL might be:
Expand All @@ -181,20 +197,22 @@ type Customer {
birthDay: Date
workStartTime: Time
bornAt: DateTime
createdAtTimestamp: SecondsSinceEpoch
}

type Query {
customers(bornAfter: DateTime): [Customers]
customers(bornAfter: DateTime, createdAfter: SecondsSinceEpoch): [Customers]
}
```

And example query might look like:

```graphql
query {
customers(bornAfter: "1996-12-19T16:39:57-08:00") {
customers(bornAfter: "1996-12-19T16:39:57-08:00", createdAfter: 1609459200) {
birthDay
bornAt
createdAtTimestamp
}
}
```
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/graphql/scalars/ExtendedScalars.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import graphql.scalars.datetime.AccurateDurationScalar;
import graphql.scalars.datetime.LocalTimeCoercing;
import graphql.scalars.datetime.NominalDurationScalar;
import graphql.scalars.datetime.SecondsSinceEpochScalar;
import graphql.scalars.datetime.TimeScalar;
import graphql.scalars.datetime.YearMonthScalar;
import graphql.scalars.datetime.YearScalar;
Expand Down Expand Up @@ -138,6 +139,34 @@ public class ExtendedScalars {
*/
public static final GraphQLScalarType NominalDuration = NominalDurationScalar.INSTANCE;

/**
* A scalar that represents a point in time as seconds since the Unix epoch (Unix timestamp).
* <p>
* It accepts integers or strings containing integers as input values and produces
* `java.time.ZonedDateTime` objects at runtime (with UTC timezone).
* <p>
* Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} method accepts various
* {@link java.time.temporal.TemporalAccessor} types and returns a string containing the number of seconds since epoch
* (January 1, 1970, 00:00:00 UTC).
* <p>
* Using seconds since epoch is preferable to formatted date time strings in several scenarios:
* <ul>
* <li>When you need a universal representation of a point in time that is timezone-agnostic</li>
* <li>For easier date/time arithmetic and comparison operations</li>
* <li>When storage space or bandwidth efficiency is important (more compact representation)</li>
* <li>To avoid complexities with different date formats and timezone conversions</li>
* <li>For better interoperability with systems that natively work with Unix timestamps</li>
* <li>When working with time-series data or logging systems where timestamps are commonly used</li>
* </ul>
* <p>
* However, human readability is sacrificed compared to formatted date strings, so consider your use case
* requirements when choosing between {@link #DateTime} and {@link #SecondsSinceEpoch}.
*
* @see java.time.Instant
* @see java.time.ZonedDateTime
*/
public static final GraphQLScalarType SecondsSinceEpoch = SecondsSinceEpochScalar.INSTANCE;

/**
* An object scalar allows you to have a multi level data value without defining it in the graphql schema.
* <p>
Expand Down
176 changes: 176 additions & 0 deletions src/main/java/graphql/scalars/datetime/SecondsSinceEpochScalar.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package graphql.scalars.datetime;

import graphql.GraphQLContext;
import graphql.Internal;
import graphql.execution.CoercedVariables;
import graphql.language.IntValue;
import graphql.language.StringValue;
import graphql.language.Value;
import graphql.schema.Coercing;
import graphql.schema.CoercingParseLiteralException;
import graphql.schema.CoercingParseValueException;
import graphql.schema.CoercingSerializeException;
import graphql.schema.GraphQLScalarType;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalAccessor;
import java.util.Locale;

import static graphql.scalars.util.Kit.typeName;

/**
* Access this via {@link graphql.scalars.ExtendedScalars#SecondsSinceEpoch}
*/
@Internal
public final class SecondsSinceEpochScalar {

public static final GraphQLScalarType INSTANCE;

private SecondsSinceEpochScalar() {
}

private static Temporal convertToTemporal(String value) {
try {
if (value.matches("\\d+")) {
long epochSeconds = Long.parseLong(value);
return convertEpochSecondsToTemporal(epochSeconds);
}
throw new CoercingParseValueException(
"Invalid seconds since epoch value : '" + value + "'. Expected a string containing only digits."
);
} catch (Exception e) {
throw new CoercingParseValueException(
"Invalid seconds since epoch value : '" + value + "'. " + e.getMessage()
);
}
}

private static Temporal convertEpochSecondsToTemporal(long epochSeconds) {
return Instant.ofEpochSecond(epochSeconds).atZone(ZoneOffset.UTC);
}

static {
Coercing<TemporalAccessor, Long> coercing = new Coercing<>() {
@Override
public Long serialize(Object input, GraphQLContext graphQLContext, Locale locale) throws CoercingSerializeException {
try {
if (input instanceof Number) {
Number number = (Number) input;
return number.longValue();
}
if (input instanceof String) {
try {
return Long.parseLong((String) input);
} catch (NumberFormatException e) {
throw new CoercingSerializeException(
"Invalid seconds since epoch value : '" + input + "'. Expected a string containing only digits.",
e
);
}
}
if (input instanceof TemporalAccessor) {
TemporalAccessor temporalAccessor = (TemporalAccessor) input;
if (temporalAccessor instanceof Instant) {
Instant instant = (Instant) temporalAccessor;
return instant.getEpochSecond();
} else if (temporalAccessor instanceof LocalDateTime) {
LocalDateTime localDateTime = (LocalDateTime) temporalAccessor;
return localDateTime.toEpochSecond(ZoneOffset.UTC);
} else if (temporalAccessor instanceof ZonedDateTime) {
ZonedDateTime zonedDateTime = (ZonedDateTime) temporalAccessor;
return zonedDateTime.toEpochSecond();
} else if (temporalAccessor instanceof OffsetDateTime) {
OffsetDateTime offsetDateTime = (OffsetDateTime) temporalAccessor;
return offsetDateTime.toEpochSecond();
} else {
try {
Instant instant = Instant.from(temporalAccessor);
return instant.getEpochSecond();
} catch (Exception e) {
throw new CoercingSerializeException(
"Unable to convert TemporalAccessor to seconds since epoch because of : '" + e.getMessage() + "'."
);
}
}
}
throw new CoercingSerializeException(
"Expected a 'Number', 'String' or 'TemporalAccessor' but was '" + typeName(input) + "'."
);
} catch (CoercingSerializeException e) {
throw e;
} catch (Exception e) {
throw new CoercingSerializeException(
"Unable to convert to seconds since epoch because of : '" + e.getMessage() + "'."
);
}
}

@Override
public TemporalAccessor parseValue(Object input, GraphQLContext graphQLContext, Locale locale) throws CoercingParseValueException {
try {
if (input instanceof Number) {
Number number = (Number) input;
return convertEpochSecondsToTemporal(number.longValue());
}
if (input instanceof String) {
String string = (String) input;
return convertToTemporal(string);
}
throw new CoercingParseValueException(
"Expected a 'Number' or 'String' but was '" + typeName(input) + "'."
);
} catch (CoercingParseValueException e) {
throw e;
} catch (Exception e) {
throw new CoercingParseValueException(
"Unable to parse value to seconds since epoch because of : '" + e.getMessage() + "'."
);
}
}

@Override
public TemporalAccessor parseLiteral(Value<?> input, CoercedVariables variables, GraphQLContext graphQLContext, Locale locale) throws CoercingParseLiteralException {
try {
if (input instanceof StringValue) {
StringValue stringValue = (StringValue) input;
return convertToTemporal(stringValue.getValue());
}
if (input instanceof IntValue) {
IntValue intValue = (IntValue) input;
long epochSeconds = intValue.getValue().longValue();
return convertEpochSecondsToTemporal(epochSeconds);
}
throw new CoercingParseLiteralException(
"Expected AST type 'StringValue' or 'IntValue' but was '" + typeName(input) + "'."
);
} catch (CoercingParseLiteralException e) {
throw e;
} catch (Exception e) {
throw new CoercingParseLiteralException(
"Unable to parse literal to seconds since epoch because of : '" + e.getMessage() + "'."
);
}
}

@Override
public Value<?> valueToLiteral(Object input, GraphQLContext graphQLContext, Locale locale) {
Long value = serialize(input, graphQLContext, locale);
return IntValue.newIntValue(java.math.BigInteger.valueOf(value)).build();
}

};

INSTANCE = GraphQLScalarType.newScalar()
.name("SecondsSinceEpoch")
.description("Scalar that represents a point in time as seconds since the Unix epoch (Unix timestamp). " +
"Accepts integers or strings containing integers as input values. " +
"Returns a Long representing the number of seconds since epoch (January 1, 1970, 00:00:00 UTC).")
.coercing(coercing)
.build();
}
}
Loading