Skip to content

Commit df78c80

Browse files
authored
Merge pull request #163 from clutcher/seconds_since_epoch_scalar
feat: add SecondsSinceEpochScalar
2 parents 2097a0e + 4470f60 commit df78c80

File tree

4 files changed

+360
-2
lines changed

4 files changed

+360
-2
lines changed

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,22 @@ scalar LocalTime
172172
</pre></td>
173173
<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>
174174
</tr>
175+
<tr>
176+
<td><pre lang="graphql">
177+
scalar SecondsSinceEpoch
178+
</pre></td>
179+
<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>
180+
Using seconds since epoch is preferable to formatted date time strings in several scenarios:
181+
<ul>
182+
<li>When you need a universal representation of a point in time that is timezone-agnostic</li>
183+
<li>For easier date/time arithmetic and comparison operations</li>
184+
<li>When storage space or bandwidth efficiency is important (more compact representation)</li>
185+
<li>To avoid complexities with different date formats and timezone conversions</li>
186+
<li>For better interoperability with systems that natively work with Unix timestamps</li>
187+
<li>When working with time-series data or logging systems where timestamps are commonly used</li>
188+
</ul>
189+
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>
190+
</tr>
175191
</table>
176192

177193
An example declaration in SDL might be:
@@ -181,20 +197,22 @@ type Customer {
181197
birthDay: Date
182198
workStartTime: Time
183199
bornAt: DateTime
200+
createdAtTimestamp: SecondsSinceEpoch
184201
}
185202

186203
type Query {
187-
customers(bornAfter: DateTime): [Customers]
204+
customers(bornAfter: DateTime, createdAfter: SecondsSinceEpoch): [Customers]
188205
}
189206
```
190207

191208
And example query might look like:
192209

193210
```graphql
194211
query {
195-
customers(bornAfter: "1996-12-19T16:39:57-08:00") {
212+
customers(bornAfter: "1996-12-19T16:39:57-08:00", createdAfter: 1609459200) {
196213
birthDay
197214
bornAt
215+
createdAtTimestamp
198216
}
199217
}
200218
```

src/main/java/graphql/scalars/ExtendedScalars.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import graphql.scalars.datetime.AccurateDurationScalar;
1111
import graphql.scalars.datetime.LocalTimeCoercing;
1212
import graphql.scalars.datetime.NominalDurationScalar;
13+
import graphql.scalars.datetime.SecondsSinceEpochScalar;
1314
import graphql.scalars.datetime.TimeScalar;
1415
import graphql.scalars.datetime.YearMonthScalar;
1516
import graphql.scalars.datetime.YearScalar;
@@ -138,6 +139,34 @@ public class ExtendedScalars {
138139
*/
139140
public static final GraphQLScalarType NominalDuration = NominalDurationScalar.INSTANCE;
140141

142+
/**
143+
* A scalar that represents a point in time as seconds since the Unix epoch (Unix timestamp).
144+
* <p>
145+
* It accepts integers or strings containing integers as input values and produces
146+
* `java.time.ZonedDateTime` objects at runtime (with UTC timezone).
147+
* <p>
148+
* Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} method accepts various
149+
* {@link java.time.temporal.TemporalAccessor} types and returns the number of seconds since epoch
150+
* (January 1, 1970, 00:00:00 UTC).
151+
* <p>
152+
* Using seconds since epoch is preferable to formatted date time strings in several scenarios:
153+
* <ul>
154+
* <li>When you need a universal representation of a point in time that is timezone-agnostic</li>
155+
* <li>For easier date/time arithmetic and comparison operations</li>
156+
* <li>When storage space or bandwidth efficiency is important (more compact representation)</li>
157+
* <li>To avoid complexities with different date formats and timezone conversions</li>
158+
* <li>For better interoperability with systems that natively work with Unix timestamps</li>
159+
* <li>When working with time-series data or logging systems where timestamps are commonly used</li>
160+
* </ul>
161+
* <p>
162+
* However, human readability is sacrificed compared to formatted date strings, so consider your use case
163+
* requirements when choosing between {@link #DateTime} and {@link #SecondsSinceEpoch}.
164+
*
165+
* @see java.time.Instant
166+
* @see java.time.ZonedDateTime
167+
*/
168+
public static final GraphQLScalarType SecondsSinceEpoch = SecondsSinceEpochScalar.INSTANCE;
169+
141170
/**
142171
* An object scalar allows you to have a multi level data value without defining it in the graphql schema.
143172
* <p>
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package graphql.scalars.datetime;
2+
3+
import graphql.GraphQLContext;
4+
import graphql.Internal;
5+
import graphql.execution.CoercedVariables;
6+
import graphql.language.IntValue;
7+
import graphql.language.StringValue;
8+
import graphql.language.Value;
9+
import graphql.schema.Coercing;
10+
import graphql.schema.CoercingParseLiteralException;
11+
import graphql.schema.CoercingParseValueException;
12+
import graphql.schema.CoercingSerializeException;
13+
import graphql.schema.GraphQLScalarType;
14+
15+
import java.time.Instant;
16+
import java.time.LocalDateTime;
17+
import java.time.OffsetDateTime;
18+
import java.time.ZoneOffset;
19+
import java.time.ZonedDateTime;
20+
import java.time.temporal.Temporal;
21+
import java.time.temporal.TemporalAccessor;
22+
import java.util.Locale;
23+
24+
import static graphql.scalars.util.Kit.typeName;
25+
26+
/**
27+
* Access this via {@link graphql.scalars.ExtendedScalars#SecondsSinceEpoch}
28+
*/
29+
@Internal
30+
public final class SecondsSinceEpochScalar {
31+
32+
public static final GraphQLScalarType INSTANCE;
33+
34+
private SecondsSinceEpochScalar() {
35+
}
36+
37+
private static Temporal convertToTemporal(String value) {
38+
try {
39+
if (value.matches("\\d+")) {
40+
long epochSeconds = Long.parseLong(value);
41+
return convertEpochSecondsToTemporal(epochSeconds);
42+
}
43+
throw new CoercingParseValueException(
44+
"Invalid seconds since epoch value : '" + value + "'. Expected a string containing only digits."
45+
);
46+
} catch (Exception e) {
47+
throw new CoercingParseValueException(
48+
"Invalid seconds since epoch value : '" + value + "'. " + e.getMessage()
49+
);
50+
}
51+
}
52+
53+
private static Temporal convertEpochSecondsToTemporal(long epochSeconds) {
54+
return Instant.ofEpochSecond(epochSeconds).atZone(ZoneOffset.UTC);
55+
}
56+
57+
static {
58+
Coercing<TemporalAccessor, Long> coercing = new Coercing<>() {
59+
@Override
60+
public Long serialize(Object input, GraphQLContext graphQLContext, Locale locale) throws CoercingSerializeException {
61+
try {
62+
if (input instanceof Number) {
63+
Number number = (Number) input;
64+
return number.longValue();
65+
}
66+
if (input instanceof String) {
67+
try {
68+
return Long.parseLong((String) input);
69+
} catch (NumberFormatException e) {
70+
throw new CoercingSerializeException(
71+
"Invalid seconds since epoch value : '" + input + "'. Expected a string containing only digits.",
72+
e
73+
);
74+
}
75+
}
76+
if (input instanceof TemporalAccessor) {
77+
TemporalAccessor temporalAccessor = (TemporalAccessor) input;
78+
if (temporalAccessor instanceof Instant) {
79+
Instant instant = (Instant) temporalAccessor;
80+
return instant.getEpochSecond();
81+
} else if (temporalAccessor instanceof LocalDateTime) {
82+
LocalDateTime localDateTime = (LocalDateTime) temporalAccessor;
83+
return localDateTime.toEpochSecond(ZoneOffset.UTC);
84+
} else if (temporalAccessor instanceof ZonedDateTime) {
85+
ZonedDateTime zonedDateTime = (ZonedDateTime) temporalAccessor;
86+
return zonedDateTime.toEpochSecond();
87+
} else if (temporalAccessor instanceof OffsetDateTime) {
88+
OffsetDateTime offsetDateTime = (OffsetDateTime) temporalAccessor;
89+
return offsetDateTime.toEpochSecond();
90+
} else {
91+
try {
92+
Instant instant = Instant.from(temporalAccessor);
93+
return instant.getEpochSecond();
94+
} catch (Exception e) {
95+
throw new CoercingSerializeException(
96+
"Unable to convert TemporalAccessor to seconds since epoch because of : '" + e.getMessage() + "'."
97+
);
98+
}
99+
}
100+
}
101+
throw new CoercingSerializeException(
102+
"Expected a 'Number', 'String' or 'TemporalAccessor' but was '" + typeName(input) + "'."
103+
);
104+
} catch (CoercingSerializeException e) {
105+
throw e;
106+
} catch (Exception e) {
107+
throw new CoercingSerializeException(
108+
"Unable to convert to seconds since epoch because of : '" + e.getMessage() + "'."
109+
);
110+
}
111+
}
112+
113+
@Override
114+
public TemporalAccessor parseValue(Object input, GraphQLContext graphQLContext, Locale locale) throws CoercingParseValueException {
115+
try {
116+
if (input instanceof Number) {
117+
Number number = (Number) input;
118+
return convertEpochSecondsToTemporal(number.longValue());
119+
}
120+
if (input instanceof String) {
121+
String string = (String) input;
122+
return convertToTemporal(string);
123+
}
124+
throw new CoercingParseValueException(
125+
"Expected a 'Number' or 'String' but was '" + typeName(input) + "'."
126+
);
127+
} catch (CoercingParseValueException e) {
128+
throw e;
129+
} catch (Exception e) {
130+
throw new CoercingParseValueException(
131+
"Unable to parse value to seconds since epoch because of : '" + e.getMessage() + "'."
132+
);
133+
}
134+
}
135+
136+
@Override
137+
public TemporalAccessor parseLiteral(Value<?> input, CoercedVariables variables, GraphQLContext graphQLContext, Locale locale) throws CoercingParseLiteralException {
138+
try {
139+
if (input instanceof StringValue) {
140+
StringValue stringValue = (StringValue) input;
141+
return convertToTemporal(stringValue.getValue());
142+
}
143+
if (input instanceof IntValue) {
144+
IntValue intValue = (IntValue) input;
145+
long epochSeconds = intValue.getValue().longValue();
146+
return convertEpochSecondsToTemporal(epochSeconds);
147+
}
148+
throw new CoercingParseLiteralException(
149+
"Expected AST type 'StringValue' or 'IntValue' but was '" + typeName(input) + "'."
150+
);
151+
} catch (CoercingParseLiteralException e) {
152+
throw e;
153+
} catch (Exception e) {
154+
throw new CoercingParseLiteralException(
155+
"Unable to parse literal to seconds since epoch because of : '" + e.getMessage() + "'."
156+
);
157+
}
158+
}
159+
160+
@Override
161+
public Value<?> valueToLiteral(Object input, GraphQLContext graphQLContext, Locale locale) {
162+
Long value = serialize(input, graphQLContext, locale);
163+
return IntValue.newIntValue(java.math.BigInteger.valueOf(value)).build();
164+
}
165+
166+
};
167+
168+
INSTANCE = GraphQLScalarType.newScalar()
169+
.name("SecondsSinceEpoch")
170+
.description("Scalar that represents a point in time as seconds since the Unix epoch (Unix timestamp). " +
171+
"Accepts integers or strings containing integers as input values. " +
172+
"Returns a Long representing the number of seconds since epoch (January 1, 1970, 00:00:00 UTC).")
173+
.coercing(coercing)
174+
.build();
175+
}
176+
}

0 commit comments

Comments
 (0)