diff --git a/README.md b/README.md
index e3b0e30..6bc588d 100644
--- a/README.md
+++ b/README.md
@@ -172,6 +172,22 @@ scalar LocalTime
24-hour clock time string in the format hh:mm:ss.sss or hh:mm:ss if partial seconds is zero and produces java.time.LocalTime objects at runtime. |
+
+
+scalar SecondsSinceEpoch
+ |
+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 java.time.ZonedDateTime objects at runtime (with UTC timezone).
+Using seconds since epoch is preferable to formatted date time strings in several scenarios:
+
+- When you need a universal representation of a point in time that is timezone-agnostic
+- For easier date/time arithmetic and comparison operations
+- When storage space or bandwidth efficiency is important (more compact representation)
+- To avoid complexities with different date formats and timezone conversions
+- For better interoperability with systems that natively work with Unix timestamps
+- When working with time-series data or logging systems where timestamps are commonly used
+
+However, human readability is sacrificed compared to formatted date strings, so consider your use case requirements when choosing between DateTime and SecondsSinceEpoch . |
+
An example declaration in SDL might be:
@@ -181,10 +197,11 @@ type Customer {
birthDay: Date
workStartTime: Time
bornAt: DateTime
+ createdAtTimestamp: SecondsSinceEpoch
}
type Query {
- customers(bornAfter: DateTime): [Customers]
+ customers(bornAfter: DateTime, createdAfter: SecondsSinceEpoch): [Customers]
}
```
@@ -192,9 +209,10 @@ 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
}
}
```
diff --git a/src/main/java/graphql/scalars/ExtendedScalars.java b/src/main/java/graphql/scalars/ExtendedScalars.java
index e3f7d9d..07124fc 100644
--- a/src/main/java/graphql/scalars/ExtendedScalars.java
+++ b/src/main/java/graphql/scalars/ExtendedScalars.java
@@ -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;
@@ -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).
+ *
+ * It accepts integers or strings containing integers as input values and produces
+ * `java.time.ZonedDateTime` objects at runtime (with UTC timezone).
+ *
+ * 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).
+ *
+ * Using seconds since epoch is preferable to formatted date time strings in several scenarios:
+ *
+ * - When you need a universal representation of a point in time that is timezone-agnostic
+ * - For easier date/time arithmetic and comparison operations
+ * - When storage space or bandwidth efficiency is important (more compact representation)
+ * - To avoid complexities with different date formats and timezone conversions
+ * - For better interoperability with systems that natively work with Unix timestamps
+ * - When working with time-series data or logging systems where timestamps are commonly used
+ *
+ *
+ * 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.
*
diff --git a/src/main/java/graphql/scalars/datetime/SecondsSinceEpochScalar.java b/src/main/java/graphql/scalars/datetime/SecondsSinceEpochScalar.java
new file mode 100644
index 0000000..cc46d2b
--- /dev/null
+++ b/src/main/java/graphql/scalars/datetime/SecondsSinceEpochScalar.java
@@ -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 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();
+ }
+}
diff --git a/src/test/groovy/graphql/scalars/datetime/SecondsSinceEpochScalarTest.groovy b/src/test/groovy/graphql/scalars/datetime/SecondsSinceEpochScalarTest.groovy
new file mode 100644
index 0000000..a932273
--- /dev/null
+++ b/src/test/groovy/graphql/scalars/datetime/SecondsSinceEpochScalarTest.groovy
@@ -0,0 +1,135 @@
+package graphql.scalars.datetime
+
+import graphql.language.IntValue
+import graphql.language.StringValue
+import graphql.scalars.ExtendedScalars
+import graphql.scalars.util.AbstractScalarTest
+import graphql.schema.CoercingParseLiteralException
+import graphql.schema.CoercingParseValueException
+import graphql.schema.CoercingSerializeException
+import spock.lang.Unroll
+
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+import java.time.ZonedDateTime
+
+import static graphql.scalars.util.TestKit.mkIntValue
+import static graphql.scalars.util.TestKit.mkLocalDT
+import static graphql.scalars.util.TestKit.mkOffsetDT
+import static graphql.scalars.util.TestKit.mkStringValue
+import static graphql.scalars.util.TestKit.mkZonedDT
+
+class SecondsSinceEpochScalarTest extends AbstractScalarTest {
+
+ def coercing = ExtendedScalars.SecondsSinceEpoch.getCoercing()
+
+ @Unroll
+ def "secondsSinceEpoch parseValue"() {
+ when:
+ def result = coercing.parseValue(input, graphQLContext, locale)
+ then:
+ result.toEpochSecond() == expectedValue
+ where:
+ input | expectedValue
+ "0" | 0L
+ "1" | 1L
+ "1609459200" | 1609459200L // 2021-01-01T00:00:00Z
+ "1640995200" | 1640995200L // 2022-01-01T00:00:00Z
+ 0 | 0L
+ 1 | 1L
+ 1609459200 | 1609459200L // 2021-01-01T00:00:00Z
+ 1640995200 | 1640995200L // 2022-01-01T00:00:00Z
+ }
+
+ @Unroll
+ def "secondsSinceEpoch valueToLiteral"() {
+ when:
+ def result = coercing.valueToLiteral(input, graphQLContext, locale)
+ then:
+ result.isEqualTo(expectedValue)
+ where:
+ input | expectedValue
+ "0" | mkIntValue(0)
+ "1" | mkIntValue(1)
+ "1609459200" | mkIntValue(1609459200)
+ "1640995200" | mkIntValue(1640995200)
+ 0 | mkIntValue(0)
+ 1 | mkIntValue(1)
+ 1609459200 | mkIntValue(1609459200)
+ 1640995200 | mkIntValue(1640995200)
+ Instant.ofEpochSecond(1609459200) | mkIntValue(1609459200)
+ ZonedDateTime.ofInstant(Instant.ofEpochSecond(1609459200), ZoneOffset.UTC) | mkIntValue(1609459200)
+ }
+
+ @Unroll
+ def "secondsSinceEpoch parseValue bad inputs"() {
+ when:
+ coercing.parseValue(input, graphQLContext, locale)
+ then:
+ thrown(expectedValue)
+ where:
+ input | expectedValue
+ "not a number" | CoercingParseValueException
+ "123abc" | CoercingParseValueException
+ "2022-01-01" | CoercingParseValueException
+ "2022-01-01T00:00:00Z" | CoercingParseValueException
+ new Object() | CoercingParseValueException
+ }
+
+ def "secondsSinceEpoch AST literal"() {
+ when:
+ def result = coercing.parseLiteral(input, variables, graphQLContext, locale)
+ then:
+ result.toEpochSecond() == expectedValue
+ where:
+ input | expectedValue
+ new StringValue("0") | 0L
+ new StringValue("1") | 1L
+ new StringValue("1609459200") | 1609459200L // 2021-01-01T00:00:00Z
+ new IntValue(0) | 0L
+ new IntValue(1) | 1L
+ new IntValue(1609459200) | 1609459200L // 2021-01-01T00:00:00Z
+ }
+
+ def "secondsSinceEpoch serialisation"() {
+ when:
+ def result = coercing.serialize(input, graphQLContext, locale)
+ then:
+ result == expectedValue
+ where:
+ input | expectedValue
+ Instant.ofEpochSecond(0) | 0L
+ Instant.ofEpochSecond(1) | 1L
+ Instant.ofEpochSecond(1609459200) | 1609459200L
+ LocalDateTime.ofInstant(Instant.ofEpochSecond(1609459200), ZoneOffset.UTC) | 1609459200L
+ ZonedDateTime.ofInstant(Instant.ofEpochSecond(1609459200), ZoneOffset.UTC) | 1609459200L
+ OffsetDateTime.ofInstant(Instant.ofEpochSecond(1609459200), ZoneOffset.UTC) | 1609459200L
+ }
+
+ def "secondsSinceEpoch serialisation bad inputs"() {
+ when:
+ coercing.serialize(input, graphQLContext, locale)
+ then:
+ thrown(expectedValue)
+ where:
+ input | expectedValue
+ "not a temporal" | CoercingSerializeException
+ new Object() | CoercingSerializeException
+ }
+
+ @Unroll
+ def "secondsSinceEpoch parseLiteral bad inputs"() {
+ when:
+ coercing.parseLiteral(input, variables, graphQLContext, locale)
+ then:
+ thrown(expectedValue)
+ where:
+ input | expectedValue
+ mkStringValue("not a number") | CoercingParseLiteralException
+ mkStringValue("123abc") | CoercingParseLiteralException
+ mkStringValue("2022-01-01") | CoercingParseLiteralException
+ mkStringValue("2022-01-01T00:00:00Z")| CoercingParseLiteralException
+ }
+}