Skip to content
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

fix: restore serialization of DateTime scalar to pre-1.2.0 (values must be serialized to string, not to instance of Date) + clarify that timestamps are ECMAScript (milliseconds), not unix timestamps (seconds) #1641

Closed
wants to merge 3 commits into from
Closed
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
21 changes: 13 additions & 8 deletions src/scalars/iso-date/DateTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,37 @@
import { GraphQLScalarType, Kind } from 'graphql';
import type { GraphQLScalarTypeConfig } from 'graphql'; // eslint-disable-line
import { validateJSDate, validateDateTime } from './validator.js';
import { parseDateTime } from './formatter.js';
import {
serializeDateTime,
serializeDateTimeString,
serializeTimestamp,
parseDateTime,
} from './formatter.js';
import { createGraphQLError } from '../../error.js';

export const GraphQLDateTimeConfig: GraphQLScalarTypeConfig<Date, Date> = /*#__PURE__*/ {
export const GraphQLDateTimeConfig: GraphQLScalarTypeConfig<Date, string> = /*#__PURE__*/ {
name: 'DateTime',
description:
'A date-time string at UTC, such as 2007-12-03T10:15:30Z, ' +
'A date-time string at UTC, such as 2007-12-03T10:15:30.123Z, ' +
'compliant with the `date-time` format outlined in section 5.6 of ' +
'the RFC 3339 profile of the ISO 8601 standard for representation ' +
'of dates and times using the Gregorian calendar.',
serialize(value) {
if (value instanceof Date) {
if (validateJSDate(value)) {
return value;
return serializeDateTime(value);
}
throw createGraphQLError('DateTime cannot represent an invalid Date instance');
} else if (typeof value === 'string') {
if (validateDateTime(value)) {
return parseDateTime(value);
return serializeDateTimeString(value);
}
throw createGraphQLError(`DateTime cannot represent an invalid date-time-string ${value}.`);
} else if (typeof value === 'number') {
try {
return new Date(value);
return serializeTimestamp(value);
} catch (e) {
throw createGraphQLError('DateTime cannot represent an invalid Unix timestamp ' + value);
throw createGraphQLError('DateTime cannot represent an invalid timestamp ' + value);
}
} else {
throw createGraphQLError(
Expand Down Expand Up @@ -88,7 +93,7 @@ export const GraphQLDateTimeConfig: GraphQLScalarTypeConfig<Date, Date> = /*#__P
*
* Output:
* This scalar serializes javascript Dates,
* RFC 3339 date-time strings and unix timestamps
* RFC 3339 date-time strings and ECMAScript timestamps (number of milliseconds)
* to RFC 3339 UTC date-time strings.
*/
export const GraphQLDateTime: GraphQLScalarType = /*#__PURE__*/ new GraphQLScalarType(GraphQLDateTimeConfig);
20 changes: 16 additions & 4 deletions src/scalars/iso-date/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,18 @@ export const parseDateTime = (dateTime: string): Date => {
return new Date(dateTime);
};

// Serializes a Date into an RFC 3339 compliant date-time-string
// in the format YYYY-MM-DDThh:mm:ss.sssZ.
export const serializeDateTime = (dateTime: Date): string => {
return dateTime.toISOString();
};

// Serializes an RFC 3339 compliant date-time-string by shifting
// it to UTC.
export const serializeDateTimeString = (dateTime: string): Date => {
export const serializeDateTimeString = (dateTime: string): string => {
// If already formatted to UTC then return the time string
if (dateTime.indexOf('Z') !== -1) {
return new Date(dateTime);
return dateTime;
} else {
// These are time-strings with timezone information,
// these need to be shifted to UTC.
Expand All @@ -112,15 +118,21 @@ export const serializeDateTimeString = (dateTime: string): Date => {
// The date-time-string has no fractional part,
// so we remove it from the dateTimeUTC variable.
dateTimeUTC = dateTimeUTC.replace(regexFracSec, '');
return new Date(dateTimeUTC);
return dateTimeUTC;
} else {
// These are datetime-string with fractional seconds.
// Make sure that we inject the fractional
// second part back in. The `dateTimeUTC` variable
// has millisecond precision, we may want more or less
// depending on the string that was passed.
dateTimeUTC = dateTimeUTC.replace(regexFracSec, fractionalPart[0]);
return new Date(dateTimeUTC);
return dateTimeUTC;
}
}
};

// Serializes ECMAScript timestamp (number of milliseconds) to an RFC 3339 compliant date-time-string
// in the format YYYY-MM-DDThh:mm:ss.sssZ
export const serializeTimestamp = (timestamp: number): string => {
return new Date(timestamp).toISOString();
};
23 changes: 14 additions & 9 deletions src/scalars/iso-date/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,22 @@ export const validateDateTime = (dateTimeString: string): boolean => {
};

// Function that checks whether a given number is a valid
// Unix timestamp.
// ECMAScript timestamp.
//
// Unix timestamps are signed 32-bit integers. They are interpreted
// as the number of seconds since 00:00:00 UTC on 1 January 1970.
// ECMAScript are interpreted as the number of milliseconds
// since 00:00:00 UTC on 1 January 1970.
//
export const validateUnixTimestamp = (timestamp: number): boolean => {
const MAX_INT = 2147483647;
const MIN_INT = -2147483648;
return (
timestamp === timestamp && timestamp <= MAX_INT && timestamp >= MIN_INT
); // eslint-disable-line
// It is defined in ECMA-262 that a maximum of ±100,000,000 days relative to
// January 1, 1970 UTC (that is, April 20, 271821 BCE ~ September 13, 275760 CE)
// can be represented by the standard Date object
// (equivalent to ±8,640,000,000,000,000 milliseconds).
//
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#the_ecmascript_epoch_and_timestamps
//
export const validateTimestamp = (timestamp: number): boolean => {
const MAX = 8640000000000000;
const MIN = -8640000000000000;
return timestamp === timestamp && timestamp <= MAX && timestamp >= MIN; // eslint-disable-line
};

// Function that checks whether a javascript Date instance
Expand Down
23 changes: 15 additions & 8 deletions tests/iso-date/DateTime.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const schema = new GraphQLSchema({
type: GraphQLDateTime,
resolve: () => '2016-02-01T00:00:00-11:00',
},
validUnixTimestamp: {
validTimestamp: {
type: GraphQLDateTime,
resolve: () => 854325678000,
},
Expand All @@ -38,6 +38,10 @@ const schema = new GraphQLSchema({
type: GraphQLDateTime,
resolve: () => new Date('wrong'),
},
invalidTimestamp: {
type: GraphQLDateTime,
resolve: () => Number.POSITIVE_INFINITY,
},
invalidType: {
type: GraphQLDateTime,
resolve: () => [],
Expand All @@ -61,7 +65,7 @@ it('executes a query that includes a DateTime', async () => {
validDate
validUTCDateString
validDateString
validUnixTimestamp
validTimestamp
input(date: $date)
inputNull: input
}
Expand All @@ -73,11 +77,11 @@ it('executes a query that includes a DateTime', async () => {

expect(response).toEqual({
data: {
validDate: new Date('2016-05-02T10:31:42.200Z'),
validUTCDateString: new Date('1991-12-24T00:00:00Z'),
validDateString: new Date('2016-02-01T11:00:00Z'),
input: new Date('2017-10-01T00:00:00.000Z'),
validUnixTimestamp: new Date('1997-01-27T00:41:18.000Z'),
validDate: '2016-05-02T10:31:42.200Z',
validUTCDateString: '1991-12-24T00:00:00Z',
validDateString: '2016-02-01T11:00:00Z',
input: '2017-10-01T00:00:00.000Z',
validTimestamp: '1997-01-27T00:41:18.000Z',
inputNull: null,
},
});
Expand All @@ -96,7 +100,7 @@ it('shifts an input date-time to UTC', async () => {

expect(response).toEqual({
data: {
input: new Date('2016-02-01T11:00:00.000Z'),
input: '2016-02-01T11:00:00.000Z',
},
});
});
Expand Down Expand Up @@ -141,6 +145,7 @@ it('errors if an invalid date-time is returned from the resolver', async () => {
{
invalidDateString
invalidDate
invalidTimestamp
invalidType
}
`;
Expand All @@ -152,11 +157,13 @@ it('errors if an invalid date-time is returned from the resolver', async () => {
"data": {
"invalidDate": null,
"invalidDateString": null,
"invalidTimestamp": null,
"invalidType": null,
},
"errors": [
[GraphQLError: DateTime cannot represent an invalid date-time-string 2017-01-001T00:00:00Z.],
[GraphQLError: DateTime cannot represent an invalid Date instance],
[GraphQLError: DateTime cannot represent an invalid timestamp Infinity],
[GraphQLError: DateTime cannot be serialized from a non string, non numeric or non Date type []],
],
}
Expand Down
33 changes: 22 additions & 11 deletions tests/iso-date/DateTime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('GraphQLDateTime', () => {
[new Date(Date.UTC(2016, 0, 1, 14, 48, 10, 30)), '2016-01-01T14:48:10.030Z'],
].forEach(([value, expected]) => {
it(`serializes javascript Date ${stringify(value)} into ${stringify(expected)}`, () => {
expect(GraphQLDateTime.serialize(value).toJSON()).toEqual(expected);
expect(GraphQLDateTime.serialize(value)).toEqual(expected);
});
});

Expand All @@ -68,13 +68,17 @@ describe('GraphQLDateTime', () => {
});

[
['2016-02-01T00:00:15Z', '2016-02-01T00:00:15Z'],
['2016-02-01T00:00:15.000Z', '2016-02-01T00:00:15.000Z'],
['2016-02-01T00:00:00.234Z', '2016-02-01T00:00:00.234Z'],
['2016-02-01T00:00:00-11:00', '2016-02-01T11:00:00.000Z'],
['2017-01-07T00:00:00.1+01:20', '2017-01-06T22:40:00.100Z'],
['2016-02-01T00:00:00.23498Z', '2016-02-01T00:00:00.23498Z'],
['2016-02-01T00:00:00-11:00', '2016-02-01T11:00:00Z'],
['2016-02-01T00:00:00+11:00', '2016-01-31T13:00:00Z'],
['2016-02-02T00:00:00.4567+01:30', '2016-02-01T22:30:00.4567Z'],
['2017-01-07T00:00:00.1+01:20', '2017-01-06T22:40:00.1Z'],
].forEach(([input, output]) => {
it(`serializes date-time-string ${input} into UTC date-time-string ${output}`, () => {
expect(GraphQLDateTime.serialize(input).toJSON()).toEqual(output);
expect(GraphQLDateTime.serialize(input)).toEqual(output);
});
});

Expand All @@ -84,21 +88,28 @@ describe('GraphQLDateTime', () => {
});
});

// Serializes Unix timestamp
// Serializes ECMAScript timestamp
[
[854325678000, '1997-01-27T00:41:18.000Z'],
[854325678123, '1997-01-27T00:41:18.123Z'],
[876535000, '1970-01-11T03:28:55.000Z'],
// The maximum representable unix timestamp
[2147483647000, '2038-01-19T03:14:07.000Z'],
// The minimum representable unit timestamp
[-2147483648000, '1901-12-13T20:45:52.000Z'],
// The maximum representable ECMAScript timestamp
[8640000000000000, '+275760-09-13T00:00:00.000Z'],
// The minimum representable ECMAScript timestamp
[-8640000000000000, '-271821-04-20T00:00:00.000Z'],
].forEach(([value, expected]) => {
it(`serializes unix timestamp ${stringify(value)} into date-string ${expected}`, () => {
expect(GraphQLDateTime.serialize(value).toJSON()).toEqual(expected);
it(`serializes timestamp ${stringify(value)} into date-time-string ${expected}`, () => {
expect(GraphQLDateTime.serialize(value)).toEqual(expected);
});
});
});

[Number.NaN, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY].forEach(value => {
it(`throws an error serializing the invalid timestamp ${stringify(value)}`, () => {
expect(() => GraphQLDateTime.serialize(value)).toThrowErrorMatchingSnapshot();
});
});

describe('value parsing', () => {
validDates.forEach(([value, expected]) => {
it(`parses date-string ${stringify(value)} into javascript Date ${stringify(expected)}`, () => {
Expand Down
Loading