From a3ac4ccb813e0aed918690443364328be44762d2 Mon Sep 17 00:00:00 2001 From: Dirk-Jan Rutten Date: Sat, 5 Nov 2016 21:04:15 +0100 Subject: [PATCH 1/4] Initial proposal for the experimental GraphQLDateTime scalar --- package.json | 3 +- src/index.js | 1 + src/type/__tests__/serialization-test.js | 100 ++++++++++++++++++++++- src/type/index.js | 1 + src/type/scalars.js | 65 +++++++++++++++ 5 files changed, 168 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d4cb67a171..d3f8ac677c 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "prepublish": ". ./resources/prepublish.sh" }, "dependencies": { - "iterall": "1.0.2" + "iterall": "1.0.2", + "moment": "^2.15.2" }, "devDependencies": { "babel-cli": "6.18.0", diff --git a/src/index.js b/src/index.js index 8e59450f21..6c768638da 100644 --- a/src/index.js +++ b/src/index.js @@ -65,6 +65,7 @@ export { GraphQLString, GraphQLBoolean, GraphQLID, + GraphQLDateTime, // Built-in Directives defined by the Spec specifiedDirectives, diff --git a/src/type/__tests__/serialization-test.js b/src/type/__tests__/serialization-test.js index 7cfaa6166e..d0f8b6c917 100644 --- a/src/type/__tests__/serialization-test.js +++ b/src/type/__tests__/serialization-test.js @@ -11,11 +11,13 @@ import { GraphQLInt, GraphQLFloat, GraphQLString, - GraphQLBoolean + GraphQLBoolean, + GraphQLDateTime } from '../'; import { describe, it } from 'mocha'; import { expect } from 'chai'; +import * as Kind from '../../language/kinds'; describe('Type System: Scalar coercion', () => { @@ -177,4 +179,100 @@ describe('Type System: Scalar coercion', () => { GraphQLBoolean.serialize(false) ).to.equal(false); }); + + it('serializes output DateTime', () => { + expect( + GraphQLDateTime.serialize(new Date(Date.UTC(2016, 0, 1))) + ).to.equal('2016-01-01T00:00:00.000Z'); + expect( + GraphQLDateTime.serialize(new Date(Date.UTC(2016, 0, 1, 14, 48, 10, 3))) + ).to.equal('2016-01-01T14:48:10.003Z'); + expect(() => + GraphQLDateTime.serialize('2016-01-01T14:48:10.003Z') + ).to.throw( + 'DateTime cannot be serialized from a non Date ' + + 'type 2016-01-01T14:48:10.003Z' + ); + expect(() => + GraphQLDateTime.serialize(75683393) + ).to.throw( + 'DateTime cannot be serialized from a non Date type 75683393' + ); + expect(() => + GraphQLDateTime.serialize(new Date('wrong date')) + ).to.throw( + 'DateTime cannot represent an invalid date' + ); + }); + + it('parses input DateTime', () => { + + [ + [ '2016', new Date(Date.UTC(2016, 0)) ], + [ '2016-11', new Date(Date.UTC(2016, 10)) ], + [ '2016-11-05', new Date(Date.UTC(2016, 10, 5)) ], + [ '20161105', new Date(Date.UTC(2016, 10, 5)) ], + [ '2016-W44', new Date(Date.UTC(2016, 9, 31)) ], + [ '2016W44', new Date(Date.UTC(2016, 9, 31)) ], + [ '2016-W44-6', new Date(Date.UTC(2016, 10, 5)) ], + [ '2016W446', new Date(Date.UTC(2016, 10, 5)) ], + [ '2016-310', new Date(Date.UTC(2016, 10, 5)) ], + [ '2016310', new Date(Date.UTC(2016, 10, 5)) ], + [ '2016-01-01T10Z', new Date(Date.UTC(2016, 0, 1, 10)) ], + [ '2016-01-01T10:10Z', new Date(Date.UTC(2016, 0, 1, 10, 10)) ], + [ '2016-01-01T1010Z', new Date(Date.UTC(2016, 0, 1, 10, 10)) ], + [ '2016-01-01T10:10:10Z', new Date(Date.UTC(2016, 0, 1, 10, 10, 10)) ], + [ '2016-01-01T101010Z', + new Date(Date.UTC(2016, 0, 1, 10, 10, 10)) ], + [ '2016-01-01T10:10:10.321Z', + new Date(Date.UTC(2016, 0, 1, 10, 10, 10, 321)) ], + [ '2016-01-01T101010.321Z', + new Date(Date.UTC(2016, 0, 1, 10, 10, 10, 321)) ] + ].forEach(([ value, expected ]) => { + expect( + GraphQLDateTime.parseValue(value).getTime() + ).to.equal(expected.getTime()); + }); + + expect(() => + GraphQLDateTime.parseValue(75683393) + ).to.throw( + 'DateTime cannot represent non string type 75683393' + ); + + [ + '01-01-2016', + '201611', + '2016-W44-8', + '2015-02-29', + '2016-01-01T101010.321' + ].forEach(dateString => { + expect(() => + GraphQLDateTime.parseValue(dateString) + ).to.throw( + 'DateTime cannot represent an invalid ISO 8601 date ' + dateString + ); + }); + }); + + it('parses literal DateTime', () => { + + expect( + GraphQLDateTime.parseLiteral({ + kind: Kind.STRING, value: '2016-01-01T101010.321Z' + }).getTime() + ).to.equal( + new Date(Date.UTC(2016, 0, 1, 10, 10, 10, 321)).getTime() + ); + expect( + GraphQLDateTime.parseLiteral({ + kind: Kind.FLOAT, value: 5 + }) + ).to.equal(null); + expect( + GraphQLDateTime.parseLiteral({ + kind: Kind.STRING, value: '2015-02-29' + }) + ).to.equal(null); + }); }); diff --git a/src/type/index.js b/src/type/index.js index 6747751068..0dded64c60 100644 --- a/src/type/index.js +++ b/src/type/index.js @@ -59,6 +59,7 @@ export { GraphQLString, GraphQLBoolean, GraphQLID, + GraphQLDateTime } from './scalars'; export { diff --git a/src/type/scalars.js b/src/type/scalars.js index 5c44d1dae8..8b4ed7aa5c 100644 --- a/src/type/scalars.js +++ b/src/type/scalars.js @@ -10,6 +10,7 @@ import { GraphQLScalarType } from './definition'; import * as Kind from '../language/kinds'; +import moment from 'moment'; // As per the GraphQL Spec, Integers are only treated as valid when a valid // 32-bit signed integer, providing the broadest support across platforms. @@ -19,6 +20,28 @@ import * as Kind from '../language/kinds'; const MAX_INT = 2147483647; const MIN_INT = -2147483648; +// All the allowed ISO 8601 date-time formats used in the +// GraphQLDateTime scalar. +const ISO_8601_FORMAT = [ + 'YYYY', + 'YYYY-MM', + 'YYYY-MM-DD', + 'YYYYMMDD', + 'YYYY-MM-DDTHHZ', + 'YYYY-MM-DDTHH:mmZ', + 'YYYY-MM-DDTHHmmZ', + 'YYYY-MM-DDTHH:mm:ssZ', + 'YYYY-MM-DDTHHmmssZ', + 'YYYY-MM-DDTHH:mm:ss.SSSZ', + 'YYYY-MM-DDTHHmmss.SSSZ', + 'YYYY-[W]WW', + 'YYYY[W]WW', + 'YYYY-[W]WW-E', + 'YYYY[W]WWE', + 'YYYY-DDDD', + 'YYYYDDDD' +]; + function coerceInt(value: mixed): ?number { if (value === '') { throw new TypeError( @@ -121,3 +144,45 @@ export const GraphQLID = new GraphQLScalarType({ null; } }); + +export const GraphQLDateTime = new GraphQLScalarType({ + name: 'DateTime', + description: 'An ISO-8601 encoded UTC date string.', + serialize(value: mixed): string { + if (!(value instanceof Date)) { + throw new TypeError( + 'DateTime cannot be serialized from a non Date type ' + String(value) + ); + } + const momentDate = moment.utc(value); + if (momentDate.isValid()) { + return momentDate.toISOString(); + } + throw new TypeError( + 'DateTime cannot represent an invalid date ' + String(value) + ); + }, + parseValue(value: mixed): Date { + if (!(typeof value === 'string' || value instanceof String)) { + throw new TypeError( + 'DateTime cannot represent non string type ' + String(value) + ); + } + const momentDate = moment.utc(value, ISO_8601_FORMAT, true); + if (momentDate.isValid()) { + return momentDate.toDate(); + } + throw new TypeError( + 'DateTime cannot represent an invalid ISO 8601 date ' + String(value) + ); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + const momentDate = moment.utc(ast.value, ISO_8601_FORMAT, true); + if (momentDate.isValid()) { + return momentDate.toDate(); + } + } + return null; + } +}); From 199ca335050f4ab6fee3e5ac81a609fba2c1709b Mon Sep 17 00:00:00 2001 From: Dirk-Jan Rutten Date: Sat, 12 Nov 2016 21:45:06 +0100 Subject: [PATCH 2/4] Updates to remove moment dependency and improve API --- package.json | 3 +- src/type/__tests__/serialization-test.js | 394 +++++++++++++++++++---- src/type/index.js | 2 +- src/type/scalars.js | 150 ++++++--- 4 files changed, 445 insertions(+), 104 deletions(-) diff --git a/package.json b/package.json index d3f8ac677c..d4cb67a171 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,7 @@ "prepublish": ". ./resources/prepublish.sh" }, "dependencies": { - "iterall": "1.0.2", - "moment": "^2.15.2" + "iterall": "1.0.2" }, "devDependencies": { "babel-cli": "6.18.0", diff --git a/src/type/__tests__/serialization-test.js b/src/type/__tests__/serialization-test.js index d0f8b6c917..ad50169303 100644 --- a/src/type/__tests__/serialization-test.js +++ b/src/type/__tests__/serialization-test.js @@ -12,14 +12,13 @@ import { GraphQLFloat, GraphQLString, GraphQLBoolean, - GraphQLDateTime + GraphQLDateTime, } from '../'; import { describe, it } from 'mocha'; import { expect } from 'chai'; import * as Kind from '../../language/kinds'; - describe('Type System: Scalar coercion', () => { it('serializes output int', () => { expect( @@ -181,71 +180,260 @@ describe('Type System: Scalar coercion', () => { }); it('serializes output DateTime', () => { - expect( - GraphQLDateTime.serialize(new Date(Date.UTC(2016, 0, 1))) - ).to.equal('2016-01-01T00:00:00.000Z'); - expect( - GraphQLDateTime.serialize(new Date(Date.UTC(2016, 0, 1, 14, 48, 10, 3))) - ).to.equal('2016-01-01T14:48:10.003Z'); - expect(() => - GraphQLDateTime.serialize('2016-01-01T14:48:10.003Z') - ).to.throw( - 'DateTime cannot be serialized from a non Date ' + - 'type 2016-01-01T14:48:10.003Z' - ); - expect(() => - GraphQLDateTime.serialize(75683393) - ).to.throw( - 'DateTime cannot be serialized from a non Date type 75683393' - ); + + [ + {}, + [], + null, + undefined, + true, + ].forEach(invalidInput => { + expect(() => + GraphQLDateTime.serialize(invalidInput) + ).to.throw( + 'DateTime cannot be serialized from a non string, ' + + 'non numeric or non Date type ' + invalidInput + ); + }); + + // Serialize from Date + [ + [ new Date(Date.UTC(2016, 0, 1)), '2016-01-01T00:00:00.000Z' ], + [ + new Date(Date.UTC(2016, 0, 1, 14, 48, 10, 3)), + '2016-01-01T14:48:10.003Z' + ], + ].forEach(([ value, expected ]) => { + expect( + GraphQLDateTime.serialize(value) + ).to.equal(expected); + }); + expect(() => - GraphQLDateTime.serialize(new Date('wrong date')) + GraphQLDateTime.serialize(new Date('invalid date')) ).to.throw( - 'DateTime cannot represent an invalid date' + 'DateTime cannot represent an invalid Date instance' ); + + // Serializes from date string + [ + // Years + '2016', + // Years and month + '2016-01', + '2016-11', + // Date + '2016-02-01', + '2016-09-15', + '2016-01-31', + // Date with 30 days in the month + '2016-04-30', + '2016-06-30', + '2016-09-30', + '2016-11-30', + // Date leap year checks + '2016-02-29', + '2000-02-29', + // Datetime with hours and minutes + '2016-02-01T00:00Z', + '2016-02-01T24:00Z', + '2016-02-01T23:59Z', + '2016-02-01T15:32Z', + // Datetime with hours, minutes and seconds + '2016-02-01T00:00:00Z', + '2016-02-01T00:00:15Z', + '2016-02-01T00:00:59Z', + // Datetime with hours, minutes, seconds and milliseconds + '2016-02-01T00:00:00.000Z', + '2016-02-01T00:00:00.999Z', + '2016-02-01T00:00:00.456Z', + ].forEach(value => { + expect( + GraphQLDateTime.serialize(value) + ).to.equal(value); + }); + + [ + // General + 'Invalid date', + // Year and month + '2016-00', + '2016-13', + '2016-1', + '201613', + // Date + '2016-01-00', + '2016-01-32', + '2016-01-1', + '20160101', + // Date leap year checks + '2015-02-29', + '2015-02-30', + '1900-02-29', + '1900-02-30', + '2016-02-30', + '2000-02-30', + // Datetime with hours and minutes + '2016-02-01T24:01Z', + '2016-02-01T00:60Z', + '2016-02-01T0:60Z', + '2016-02-01T00:0Z', + '2015-02-29T00:00Z', + '2016-02-01T0000', + // Datetime with hours, minutes and seconds + '2016-02-01T000059Z', + '2016-02-01T00:00:60Z', + '2016-02-01T00:00:0Z', + '2015-02-29T00:00:00Z', + '2016-02-01T00:00:00', + // Datetime with hours, minutes, seconds and milliseconds + '2016-02-01T00:00:00.1Z', + '2016-02-01T00:00:00.22Z', + '2015-02-29T00:00:00.000Z', + '2016-02-01T00:00:00.223', + ].forEach(dateString => { + expect(() => + GraphQLDateTime.serialize(dateString) + ).to.throw( + 'DateTime cannot represent an invalid ISO 8601' + + ' date string ' + dateString + ); + }); + + // Serializes Unix timestamp + [ + [ 854325678, '1997-01-27T00:41:18.000Z' ], + [ 876535, '1970-01-11T03:28:55.000Z' ], + [ 876535.8, '1970-01-11T03:28:55.800Z' ], + [ 876535.8321, '1970-01-11T03:28:55.832Z' ], + [ -876535.8, '1969-12-21T20:31:04.200Z' ], + // The maximum representable unix timestamp + [ 2147483647, '2038-01-19T03:14:07.000Z' ], + // The minimum representable unit timestamp + [ -2147483648, '1901-12-13T20:45:52.000Z' ], + ].forEach(([ value, expected ]) => { + expect( + GraphQLDateTime.serialize(value) + ).to.equal(expected); + }); + + [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + // assume Unix timestamp are 32-bit + 2147483648, + -2147483649 + ].forEach(value => { + expect(() => + GraphQLDateTime.serialize(value) + ).to.throw( + 'DateTime cannot represent an invalid Unix timestamp ' + value + ); + }); }); it('parses input DateTime', () => { [ + // Years [ '2016', new Date(Date.UTC(2016, 0)) ], + // Years and month + [ '2016-01', new Date(Date.UTC(2016, 0)) ], [ '2016-11', new Date(Date.UTC(2016, 10)) ], - [ '2016-11-05', new Date(Date.UTC(2016, 10, 5)) ], - [ '20161105', new Date(Date.UTC(2016, 10, 5)) ], - [ '2016-W44', new Date(Date.UTC(2016, 9, 31)) ], - [ '2016W44', new Date(Date.UTC(2016, 9, 31)) ], - [ '2016-W44-6', new Date(Date.UTC(2016, 10, 5)) ], - [ '2016W446', new Date(Date.UTC(2016, 10, 5)) ], - [ '2016-310', new Date(Date.UTC(2016, 10, 5)) ], - [ '2016310', new Date(Date.UTC(2016, 10, 5)) ], - [ '2016-01-01T10Z', new Date(Date.UTC(2016, 0, 1, 10)) ], - [ '2016-01-01T10:10Z', new Date(Date.UTC(2016, 0, 1, 10, 10)) ], - [ '2016-01-01T1010Z', new Date(Date.UTC(2016, 0, 1, 10, 10)) ], - [ '2016-01-01T10:10:10Z', new Date(Date.UTC(2016, 0, 1, 10, 10, 10)) ], - [ '2016-01-01T101010Z', - new Date(Date.UTC(2016, 0, 1, 10, 10, 10)) ], - [ '2016-01-01T10:10:10.321Z', - new Date(Date.UTC(2016, 0, 1, 10, 10, 10, 321)) ], - [ '2016-01-01T101010.321Z', - new Date(Date.UTC(2016, 0, 1, 10, 10, 10, 321)) ] + // Date + [ '2016-02-01', new Date(Date.UTC(2016, 1, 1)) ], + [ '2016-09-15', new Date(Date.UTC(2016, 8, 15)) ], + [ '2016-01-31', new Date(Date.UTC(2016, 0, 31)) ], + // Date with 30 days in the month + [ '2016-04-30', new Date(Date.UTC(2016, 3, 30)) ], + [ '2016-06-30', new Date(Date.UTC(2016, 5, 30)) ], + [ '2016-09-30', new Date(Date.UTC(2016, 8, 30)) ], + [ '2016-11-30', new Date(Date.UTC(2016, 10, 30)) ], + // Date leap year checks + [ '2016-02-29', new Date(Date.UTC(2016, 1, 29)) ], + [ '2000-02-29', new Date(Date.UTC(2000, 1, 29)) ], + // Datetime with hours and minutes + [ '2016-02-01T00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0)) ], + [ '2016-02-01T24:00Z', new Date(Date.UTC(2016, 1, 2, 0, 0)) ], + [ '2016-02-01T23:59Z', new Date(Date.UTC(2016, 1, 1, 23, 59)) ], + [ '2016-02-01T15:32Z', new Date(Date.UTC(2016, 1, 1, 15, 32)) ], + // Datetime with hours, minutes and seconds + [ '2016-02-01T00:00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 0)) ], + [ '2016-02-01T00:00:15Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 15)) ], + [ '2016-02-01T00:00:59Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 59)) ], + // Datetime with hours, minutes, seconds and milliseconds + [ + '2016-02-01T00:00:00.000Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 0)) + ], + [ + '2016-02-01T00:00:00.999Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 999)) + ], + [ + '2016-02-01T00:00:00.456Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 456)) + ], ].forEach(([ value, expected ]) => { expect( - GraphQLDateTime.parseValue(value).getTime() - ).to.equal(expected.getTime()); + GraphQLDateTime.parseValue(value).toISOString() + ).to.equal(expected.toISOString()); }); - expect(() => - GraphQLDateTime.parseValue(75683393) - ).to.throw( - 'DateTime cannot represent non string type 75683393' - ); + [ + null, + undefined, + 4566, + {}, + [], + true, + ].forEach(invalidInput => { + expect(() => + GraphQLDateTime.parseValue(invalidInput) + ).to.throw( + 'DateTime cannot represent non string type ' + invalidInput + ); + }); [ - '01-01-2016', - '201611', - '2016-W44-8', + // General + 'Invalid date', + // Year and month + '2016-00', + '2016-13', + '2016-1', + '201613', + // Date + '2016-01-00', + '2016-01-32', + '2016-01-1', + '20160101', + // Date leap year checks '2015-02-29', - '2016-01-01T101010.321' + '2015-02-30', + '1900-02-29', + '1900-02-30', + '2016-02-30', + '2000-02-30', + // Datetime with hours and minutes + '2016-02-01T24:01Z', + '2016-02-01T00:60Z', + '2016-02-01T0:60Z', + '2016-02-01T00:0Z', + '2015-02-29T00:00Z', + '2016-02-01T0000', + // Datetime with hours, minutes and seconds + '2016-02-01T000059Z', + '2016-02-01T00:00:60Z', + '2016-02-01T00:00:0Z', + '2015-02-29T00:00:00Z', + '2016-02-01T00:00:00', + // Datetime with hours, minutes, seconds and milliseconds + '2016-02-01T00:00:00.1Z', + '2016-02-01T00:00:00.22Z', + '2015-02-29T00:00:00.000Z', + '2016-02-01T00:00:00.223', ].forEach(dateString => { expect(() => GraphQLDateTime.parseValue(dateString) @@ -257,22 +445,104 @@ describe('Type System: Scalar coercion', () => { it('parses literal DateTime', () => { - expect( - GraphQLDateTime.parseLiteral({ - kind: Kind.STRING, value: '2016-01-01T101010.321Z' - }).getTime() - ).to.equal( - new Date(Date.UTC(2016, 0, 1, 10, 10, 10, 321)).getTime() - ); + [ + // Years + [ '2016', new Date(Date.UTC(2016, 0)) ], + // Years and month + [ '2016-01', new Date(Date.UTC(2016, 0)) ], + [ '2016-11', new Date(Date.UTC(2016, 10)) ], + // Date + [ '2016-02-01', new Date(Date.UTC(2016, 1, 1)) ], + [ '2016-09-15', new Date(Date.UTC(2016, 8, 15)) ], + [ '2016-01-31', new Date(Date.UTC(2016, 0, 31)) ], + // Date with 30 days in the month + [ '2016-04-30', new Date(Date.UTC(2016, 3, 30)) ], + [ '2016-06-30', new Date(Date.UTC(2016, 5, 30)) ], + [ '2016-09-30', new Date(Date.UTC(2016, 8, 30)) ], + [ '2016-11-30', new Date(Date.UTC(2016, 10, 30)) ], + // Date leap year checks + [ '2016-02-29', new Date(Date.UTC(2016, 1, 29)) ], + [ '2000-02-29', new Date(Date.UTC(2000, 1, 29)) ], + // Datetime with hours and minutes + [ '2016-02-01T00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0)) ], + [ '2016-02-01T24:00Z', new Date(Date.UTC(2016, 1, 2, 0, 0)) ], + [ '2016-02-01T23:59Z', new Date(Date.UTC(2016, 1, 1, 23, 59)) ], + [ '2016-02-01T15:32Z', new Date(Date.UTC(2016, 1, 1, 15, 32)) ], + // Datetime with hours, minutes and seconds + [ '2016-02-01T00:00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 0)) ], + [ '2016-02-01T00:00:15Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 15)) ], + [ '2016-02-01T00:00:59Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 59)) ], + // Datetime with hours, minutes, seconds and milliseconds + [ + '2016-02-01T00:00:00.000Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 0)) + ], + [ + '2016-02-01T00:00:00.999Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 999)) + ], + [ + '2016-02-01T00:00:00.456Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 456)) + ], + ].forEach(([ value, expected ]) => { + expect( + GraphQLDateTime.parseLiteral({ + kind: Kind.STRING, value + }).toISOString() + ).to.equal(expected.toISOString()); + }); + + [ + // General + 'Invalid date', + // Year and month + '2016-00', + '2016-13', + '2016-1', + '201613', + // Date + '2016-01-00', + '2016-01-32', + '2016-01-1', + '20160101', + // Date leap year checks + '2015-02-29', + '2015-02-30', + '1900-02-29', + '1900-02-30', + '2016-02-30', + '2000-02-30', + // Datetime with hours and minutes + '2016-02-01T24:01Z', + '2016-02-01T00:60Z', + '2016-02-01T0:60Z', + '2016-02-01T00:0Z', + '2015-02-29T00:00Z', + '2016-02-01T0000', + // Datetime with hours, minutes and seconds + '2016-02-01T000059Z', + '2016-02-01T00:00:60Z', + '2016-02-01T00:00:0Z', + '2015-02-29T00:00:00Z', + '2016-02-01T00:00:00', + // Datetime with hours, minutes, seconds and milliseconds + '2016-02-01T00:00:00.1Z', + '2016-02-01T00:00:00.22Z', + '2015-02-29T00:00:00.000Z', + '2016-02-01T00:00:00.223', + ].forEach(value => { + expect( + GraphQLDateTime.parseLiteral({ + kind: Kind.STRING, value + }) + ).to.equal(null); + }); + expect( GraphQLDateTime.parseLiteral({ kind: Kind.FLOAT, value: 5 }) ).to.equal(null); - expect( - GraphQLDateTime.parseLiteral({ - kind: Kind.STRING, value: '2015-02-29' - }) - ).to.equal(null); }); }); diff --git a/src/type/index.js b/src/type/index.js index 0dded64c60..e1a9651dfd 100644 --- a/src/type/index.js +++ b/src/type/index.js @@ -59,7 +59,7 @@ export { GraphQLString, GraphQLBoolean, GraphQLID, - GraphQLDateTime + GraphQLDateTime, } from './scalars'; export { diff --git a/src/type/scalars.js b/src/type/scalars.js index 8b4ed7aa5c..b3e0b2d81a 100644 --- a/src/type/scalars.js +++ b/src/type/scalars.js @@ -10,7 +10,6 @@ import { GraphQLScalarType } from './definition'; import * as Kind from '../language/kinds'; -import moment from 'moment'; // As per the GraphQL Spec, Integers are only treated as valid when a valid // 32-bit signed integer, providing the broadest support across platforms. @@ -20,28 +19,6 @@ import moment from 'moment'; const MAX_INT = 2147483647; const MIN_INT = -2147483648; -// All the allowed ISO 8601 date-time formats used in the -// GraphQLDateTime scalar. -const ISO_8601_FORMAT = [ - 'YYYY', - 'YYYY-MM', - 'YYYY-MM-DD', - 'YYYYMMDD', - 'YYYY-MM-DDTHHZ', - 'YYYY-MM-DDTHH:mmZ', - 'YYYY-MM-DDTHHmmZ', - 'YYYY-MM-DDTHH:mm:ssZ', - 'YYYY-MM-DDTHHmmssZ', - 'YYYY-MM-DDTHH:mm:ss.SSSZ', - 'YYYY-MM-DDTHHmmss.SSSZ', - 'YYYY-[W]WW', - 'YYYY[W]WW', - 'YYYY-[W]WW-E', - 'YYYY[W]WWE', - 'YYYY-DDDD', - 'YYYYDDDD' -]; - function coerceInt(value: mixed): ?number { if (value === '') { throw new TypeError( @@ -145,22 +122,119 @@ export const GraphQLID = new GraphQLScalarType({ } }); +/** +* Function that checks whether a date string represents a valid date in +* the ISO 8601 formats: +* - YYYY +* - YYYY-MM +* - YYYY-MM-DD, +* - YYYY-MM-DDThh:mmZ +* - YYYY-MM-DDThh:mm:ssZ +* - YYYY-MM-DDThh:mm:ss.sssZ +*/ +function isValidDate(datestring: string): boolean { + + // An array of regular expression containing the supported ISO 8601 formats + const ISO_8601_REGEX = [ + /^\d{4}$/, // YYYY + /^\d{4}-\d{2}$/, // YYYY-MM + /^\d{4}-\d{2}-\d{2}$/, // YYYY-MM-DD, + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/, // YYYY-MM-DDThh:mmZ + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, // YYYY-MM-DDThh:mm:ssZ + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/,// YYYY-MM-DDThh:mm:ss.sssZ + ]; + + // Validate the structure of the date-string + if (!ISO_8601_REGEX.some(regex => regex.test(datestring))) { + return false; + } + + // Check if it is a correct date using the javascript Date parse() method. + const time = Date.parse(datestring); + if (time !== time) { + return false; + } + + // Perform specific checks for dates. We need + // to make sure that the date string has the correct + // number of days for a given month. This check is required + // because the javascript Date.parse() assumes every month has 31 days. + const regexYYYYMM = /\d{4}-\d{2}-\d{2}/; + if (regexYYYYMM.test(datestring)) { + const year = Number(datestring.substr(0,4)); + const month = Number(datestring.substr(5,2)); + const day = Number(datestring.substr(8,2)); + + switch (month) { + case 2: // February + if (leapYear(year) && day > 29) { + return false; + } else if (!leapYear(year) && day > 28) { + return false; + } + return true; + case 4: // April + case 6: // June + case 9: // September + case 11: // November + if (day > 30) { + return false; + } + break; + default: + return true; + } + } + // Every year that is exactly divisible by four + // is a leap year, except for years that are exactly + // divisible by 100, but these centurial years are + // leap years if they are exactly divisible by 400. + // For example, the years 1700, 1800, and 1900 are not leap years, + // but the years 1600 and 2000 are. + function leapYear(year) { + return ((year % 4 === 0) && (year % 100 !== 0)) || (year % 400 === 0); + } + return true; +} + export const GraphQLDateTime = new GraphQLScalarType({ name: 'DateTime', description: 'An ISO-8601 encoded UTC date string.', serialize(value: mixed): string { - if (!(value instanceof Date)) { + if (value instanceof Date) { + const time = value.getTime(); + if (time === time) { + return value.toISOString(); + } + throw new TypeError('DateTime cannot represent an invalid Date instance'); + } else if (typeof value === 'string' || value instanceof String) { + if (isValidDate(value)) { + return value; + } throw new TypeError( - 'DateTime cannot be serialized from a non Date type ' + String(value) + 'DateTime cannot represent an invalid ISO 8601 date string ' + value + ); + } else if (typeof value === 'number' || value instanceof Number) { + // Serialize from Unix timestamp: the number of + // seconds since 1st Jan 1970. + + // Unix timestamp are 32-bit signed integers + if (value === value && value <= MAX_INT && value >= MIN_INT) { + // Date represents unix time as the number of + // milliseconds since 1st Jan 1970 therefore we + // need to perform a conversion. + const date = new Date(value * 1000); + return date.toISOString(); + } + throw new TypeError( + 'DateTime cannot represent an invalid Unix timestamp ' + value + ); + } else { + throw new TypeError( + 'DateTime cannot be serialized from a non string, ' + + 'non numeric or non Date type ' + String(value) ); } - const momentDate = moment.utc(value); - if (momentDate.isValid()) { - return momentDate.toISOString(); - } - throw new TypeError( - 'DateTime cannot represent an invalid date ' + String(value) - ); }, parseValue(value: mixed): Date { if (!(typeof value === 'string' || value instanceof String)) { @@ -168,19 +242,17 @@ export const GraphQLDateTime = new GraphQLScalarType({ 'DateTime cannot represent non string type ' + String(value) ); } - const momentDate = moment.utc(value, ISO_8601_FORMAT, true); - if (momentDate.isValid()) { - return momentDate.toDate(); + if (isValidDate(value)) { + return new Date(value); } throw new TypeError( - 'DateTime cannot represent an invalid ISO 8601 date ' + String(value) + 'DateTime cannot represent an invalid ISO 8601 date ' + value ); }, parseLiteral(ast) { if (ast.kind === Kind.STRING) { - const momentDate = moment.utc(ast.value, ISO_8601_FORMAT, true); - if (momentDate.isValid()) { - return momentDate.toDate(); + if (isValidDate(ast.value)) { + return new Date(ast.value); } } return null; From a086f84e4cec6e47fd59eadf901007d2eaa25da4 Mon Sep 17 00:00:00 2001 From: Dirk-Jan Rutten Date: Sat, 12 Nov 2016 23:24:27 +0100 Subject: [PATCH 3/4] Made hour range consisted across node versions. --- src/type/__tests__/serialization-test.js | 9 ++++++--- src/type/scalars.js | 22 +++++++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/type/__tests__/serialization-test.js b/src/type/__tests__/serialization-test.js index ad50169303..7843f53598 100644 --- a/src/type/__tests__/serialization-test.js +++ b/src/type/__tests__/serialization-test.js @@ -236,7 +236,7 @@ describe('Type System: Scalar coercion', () => { '2000-02-29', // Datetime with hours and minutes '2016-02-01T00:00Z', - '2016-02-01T24:00Z', + '2016-02-01T23:00Z', '2016-02-01T23:59Z', '2016-02-01T15:32Z', // Datetime with hours, minutes and seconds @@ -280,6 +280,7 @@ describe('Type System: Scalar coercion', () => { '2016-02-01T00:0Z', '2015-02-29T00:00Z', '2016-02-01T0000', + '2016-02-02T24:00Z', // Datetime with hours, minutes and seconds '2016-02-01T000059Z', '2016-02-01T00:00:60Z', @@ -355,7 +356,7 @@ describe('Type System: Scalar coercion', () => { [ '2000-02-29', new Date(Date.UTC(2000, 1, 29)) ], // Datetime with hours and minutes [ '2016-02-01T00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0)) ], - [ '2016-02-01T24:00Z', new Date(Date.UTC(2016, 1, 2, 0, 0)) ], + [ '2016-02-01T23:00Z', new Date(Date.UTC(2016, 1, 1, 23, 0)) ], [ '2016-02-01T23:59Z', new Date(Date.UTC(2016, 1, 1, 23, 59)) ], [ '2016-02-01T15:32Z', new Date(Date.UTC(2016, 1, 1, 15, 32)) ], // Datetime with hours, minutes and seconds @@ -423,6 +424,7 @@ describe('Type System: Scalar coercion', () => { '2016-02-01T00:0Z', '2015-02-29T00:00Z', '2016-02-01T0000', + '2016-02-02T24:00Z', // Datetime with hours, minutes and seconds '2016-02-01T000059Z', '2016-02-01T00:00:60Z', @@ -465,7 +467,7 @@ describe('Type System: Scalar coercion', () => { [ '2000-02-29', new Date(Date.UTC(2000, 1, 29)) ], // Datetime with hours and minutes [ '2016-02-01T00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0)) ], - [ '2016-02-01T24:00Z', new Date(Date.UTC(2016, 1, 2, 0, 0)) ], + [ '2016-02-01T23:00Z', new Date(Date.UTC(2016, 1, 1, 23, 0)) ], [ '2016-02-01T23:59Z', new Date(Date.UTC(2016, 1, 1, 23, 59)) ], [ '2016-02-01T15:32Z', new Date(Date.UTC(2016, 1, 1, 15, 32)) ], // Datetime with hours, minutes and seconds @@ -520,6 +522,7 @@ describe('Type System: Scalar coercion', () => { '2016-02-01T00:0Z', '2015-02-29T00:00Z', '2016-02-01T0000', + '2016-02-02T24:00Z', // Datetime with hours, minutes and seconds '2016-02-01T000059Z', '2016-02-01T00:00:60Z', diff --git a/src/type/scalars.js b/src/type/scalars.js index b3e0b2d81a..449e028608 100644 --- a/src/type/scalars.js +++ b/src/type/scalars.js @@ -155,12 +155,25 @@ function isValidDate(datestring: string): boolean { return false; } - // Perform specific checks for dates. We need + // Perform specific checks for the hours in datetimes + // (i.e. datetimes that incude YYYY-MM-DDThh). We need + // to make sure that the number of hours in a day ranges + // from 0 to 23. This needs to be done because node v6 and above + // support the hour range 0-24 while other node versions only support + // range 0 to 23. We need to keep this consistent across node versions. + if (datestring.length >= 13) { + const hour = Number(datestring.substr(11,2)); + if (hour > 23) { + return false; + } + } + + // Perform specific checks for dates (i.e. that include + // YYYY-MM-DD). We need // to make sure that the date string has the correct // number of days for a given month. This check is required // because the javascript Date.parse() assumes every month has 31 days. - const regexYYYYMM = /\d{4}-\d{2}-\d{2}/; - if (regexYYYYMM.test(datestring)) { + if (datestring.length >= 10) { const year = Number(datestring.substr(0,4)); const month = Number(datestring.substr(5,2)); const day = Number(datestring.substr(8,2)); @@ -181,10 +194,9 @@ function isValidDate(datestring: string): boolean { return false; } break; - default: - return true; } } + // Every year that is exactly divisible by four // is a leap year, except for years that are exactly // divisible by 100, but these centurial years are From a49e7f3825a36cf15e9008a9a455db0984289517 Mon Sep 17 00:00:00 2001 From: Dirk-Jan Rutten Date: Fri, 26 May 2017 12:13:59 +0200 Subject: [PATCH 4/4] Implementation of the DateTime scalar proposed for the GraphQL spec. Implementation of the DateTime scalar proposed in facebook/graphql#315. --- src/type/__tests__/serialization-test.js | 476 ++++++++++------------- src/type/scalars.js | 132 +++---- 2 files changed, 265 insertions(+), 343 deletions(-) diff --git a/src/type/__tests__/serialization-test.js b/src/type/__tests__/serialization-test.js index 7843f53598..8d97fa87a4 100644 --- a/src/type/__tests__/serialization-test.js +++ b/src/type/__tests__/serialization-test.js @@ -19,6 +19,53 @@ import { describe, it } from 'mocha'; import { expect } from 'chai'; import * as Kind from '../../language/kinds'; +const invalidDateTime = [ + 'Invalid date', + // invalid structure + '2016-02-01T00Z', + // omission of seconds + '2016-02-01T00:00Z', + // omission of colon + '2016-02-01T000059Z', + // omission of time-offset + '2016-02-01T00:00:00', + // seconds should be two characters + '2016-02-01T00:00:0Z', + // nonexistent date + '2015-02-29T00:00:00Z', + // hour 24 is not allowed in RFC 3339 + '2016-01-01T24:00:00Z', + // nonexistent date + '2016-04-31T00:00:00Z', + // nonexistent date + '2016-06-31T00:00:00Z', + // nonexistent date + '2016-09-31T00:00:00Z', + // nonexistent date + '2016-11-31T00:00:00Z', + // month ranges from 01-12 + '2016-13-01T00:00:00Z', + // minute ranges from 00-59 + '2016-01-01T00:60:00Z', + // According to RFC 3339 2016-02-01T00:00:60Z is a valid date-time string. + // However, it is considered invalid when parsed by the javascript + // Date class because it ignores leap seconds. + // Therefore, this implementation also ignores leap seconds. + '2016-02-01T00:00:60Z', + // must specify a fractional second + '2015-02-26T00:00:00.Z', + // must add colon in time-offset + '2017-01-07T11:25:00+0100', + // omission of minute in time-offset + '2017-01-07T11:25:00+01', + // omission of minute in time-offset + '2017-01-07T11:25:00+', + // hour ranges from 00-23 + '2017-01-01T00:00:00+24:00', + // minute ranges from 00-59 + '2017-01-01T00:00:00+00:60' +]; + describe('Type System: Scalar coercion', () => { it('serializes output int', () => { expect( @@ -179,7 +226,7 @@ describe('Type System: Scalar coercion', () => { ).to.equal(false); }); - it('serializes output DateTime', () => { + describe('serializes output DateTime', () => { [ {}, @@ -188,120 +235,75 @@ describe('Type System: Scalar coercion', () => { undefined, true, ].forEach(invalidInput => { - expect(() => - GraphQLDateTime.serialize(invalidInput) - ).to.throw( - 'DateTime cannot be serialized from a non string, ' + - 'non numeric or non Date type ' + invalidInput - ); + it(`throws serializing ${invalidInput}`, () => { + expect(() => + GraphQLDateTime.serialize(invalidInput) + ).to.throw( + 'DateTime cannot be serialized from a non string, ' + + 'non numeric or non Date type ' + invalidInput + ); + }); }); - // Serialize from Date [ - [ new Date(Date.UTC(2016, 0, 1)), '2016-01-01T00:00:00.000Z' ], + [ + new Date(Date.UTC(2016, 0, 1)), + '2016-01-01T00:00:00.000Z' + ], [ new Date(Date.UTC(2016, 0, 1, 14, 48, 10, 3)), '2016-01-01T14:48:10.003Z' ], + [ + new Date(Date.UTC(2016, 0, 1, 24, 0)), + '2016-01-02T00:00:00.000Z' + ] ].forEach(([ value, expected ]) => { - expect( - GraphQLDateTime.serialize(value) - ).to.equal(expected); + it(`serializes Date ${value} into date-time string ${expected}`, () => { + expect( + GraphQLDateTime.serialize(value) + ).to.equal(expected); + }); }); - expect(() => - GraphQLDateTime.serialize(new Date('invalid date')) - ).to.throw( - 'DateTime cannot represent an invalid Date instance' - ); + it('throws serializing an invalid Date', () => { + expect(() => + GraphQLDateTime.serialize(new Date('invalid date')) + ).to.throw( + 'DateTime cannot represent an invalid Date instance' + ); + }); - // Serializes from date string [ - // Years - '2016', - // Years and month - '2016-01', - '2016-11', - // Date - '2016-02-01', - '2016-09-15', - '2016-01-31', - // Date with 30 days in the month - '2016-04-30', - '2016-06-30', - '2016-09-30', - '2016-11-30', - // Date leap year checks - '2016-02-29', - '2000-02-29', - // Datetime with hours and minutes - '2016-02-01T00:00Z', - '2016-02-01T23:00Z', - '2016-02-01T23:59Z', - '2016-02-01T15:32Z', - // Datetime with hours, minutes and seconds '2016-02-01T00:00:00Z', - '2016-02-01T00:00:15Z', '2016-02-01T00:00:59Z', - // Datetime with hours, minutes, seconds and milliseconds - '2016-02-01T00:00:00.000Z', - '2016-02-01T00:00:00.999Z', - '2016-02-01T00:00:00.456Z', + '2016-02-01T00:00:00-11:00', + '2017-01-07T11:25:00+01:00', + '2017-01-07T00:00:00+01:00', + '2017-01-07T00:00:00.0Z', + '2017-01-01T00:00:00.0+01:00', + '2016-02-01T00:00:00.450Z', + '2017-01-01T10:23:11.45686664Z', + '2017-01-01T10:23:11.23545654+01:00' ].forEach(value => { - expect( - GraphQLDateTime.serialize(value) - ).to.equal(value); + it(`serializes date-time string ${value}`, () => { + expect( + GraphQLDateTime.serialize(value) + ).to.equal(value); + }); }); - [ - // General - 'Invalid date', - // Year and month - '2016-00', - '2016-13', - '2016-1', - '201613', - // Date - '2016-01-00', - '2016-01-32', - '2016-01-1', - '20160101', - // Date leap year checks - '2015-02-29', - '2015-02-30', - '1900-02-29', - '1900-02-30', - '2016-02-30', - '2000-02-30', - // Datetime with hours and minutes - '2016-02-01T24:01Z', - '2016-02-01T00:60Z', - '2016-02-01T0:60Z', - '2016-02-01T00:0Z', - '2015-02-29T00:00Z', - '2016-02-01T0000', - '2016-02-02T24:00Z', - // Datetime with hours, minutes and seconds - '2016-02-01T000059Z', - '2016-02-01T00:00:60Z', - '2016-02-01T00:00:0Z', - '2015-02-29T00:00:00Z', - '2016-02-01T00:00:00', - // Datetime with hours, minutes, seconds and milliseconds - '2016-02-01T00:00:00.1Z', - '2016-02-01T00:00:00.22Z', - '2015-02-29T00:00:00.000Z', - '2016-02-01T00:00:00.223', - ].forEach(dateString => { - expect(() => - GraphQLDateTime.serialize(dateString) - ).to.throw( - 'DateTime cannot represent an invalid ISO 8601' + - ' date string ' + dateString - ); + invalidDateTime.forEach(dateString => { + it(`throws serializing invalid date-time string ${dateString}`, () => { + expect(() => + GraphQLDateTime.serialize(dateString) + ).to.throw( + 'DateTime cannot represent an invalid ' + + 'date-time string ' + dateString + ); + }); }); - // Serializes Unix timestamp [ [ 854325678, '1997-01-27T00:41:18.000Z' ], [ 876535, '1970-01-11T03:28:55.000Z' ], @@ -313,9 +315,13 @@ describe('Type System: Scalar coercion', () => { // The minimum representable unit timestamp [ -2147483648, '1901-12-13T20:45:52.000Z' ], ].forEach(([ value, expected ]) => { - expect( - GraphQLDateTime.serialize(value) - ).to.equal(expected); + it( + `serializes unix timestamp ${value} into date-time string ${expected}` + , () => { + expect( + GraphQLDateTime.serialize(value) + ).to.equal(expected); + }); }); [ @@ -326,60 +332,61 @@ describe('Type System: Scalar coercion', () => { 2147483648, -2147483649 ].forEach(value => { - expect(() => - GraphQLDateTime.serialize(value) - ).to.throw( - 'DateTime cannot represent an invalid Unix timestamp ' + value - ); + it(`throws serializing invalid unix timestamp ${value}`, () => { + expect(() => + GraphQLDateTime.serialize(value) + ).to.throw( + 'DateTime cannot represent an invalid Unix timestamp ' + value + ); + }); }); }); - it('parses input DateTime', () => { + describe('parses input DateTime', () => { [ - // Years - [ '2016', new Date(Date.UTC(2016, 0)) ], - // Years and month - [ '2016-01', new Date(Date.UTC(2016, 0)) ], - [ '2016-11', new Date(Date.UTC(2016, 10)) ], - // Date - [ '2016-02-01', new Date(Date.UTC(2016, 1, 1)) ], - [ '2016-09-15', new Date(Date.UTC(2016, 8, 15)) ], - [ '2016-01-31', new Date(Date.UTC(2016, 0, 31)) ], - // Date with 30 days in the month - [ '2016-04-30', new Date(Date.UTC(2016, 3, 30)) ], - [ '2016-06-30', new Date(Date.UTC(2016, 5, 30)) ], - [ '2016-09-30', new Date(Date.UTC(2016, 8, 30)) ], - [ '2016-11-30', new Date(Date.UTC(2016, 10, 30)) ], - // Date leap year checks - [ '2016-02-29', new Date(Date.UTC(2016, 1, 29)) ], - [ '2000-02-29', new Date(Date.UTC(2000, 1, 29)) ], - // Datetime with hours and minutes - [ '2016-02-01T00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0)) ], - [ '2016-02-01T23:00Z', new Date(Date.UTC(2016, 1, 1, 23, 0)) ], - [ '2016-02-01T23:59Z', new Date(Date.UTC(2016, 1, 1, 23, 59)) ], - [ '2016-02-01T15:32Z', new Date(Date.UTC(2016, 1, 1, 15, 32)) ], - // Datetime with hours, minutes and seconds - [ '2016-02-01T00:00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 0)) ], + [ + '2016-02-01T00:00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 0)) ], [ '2016-02-01T00:00:15Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 15)) ], [ '2016-02-01T00:00:59Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 59)) ], - // Datetime with hours, minutes, seconds and milliseconds + [ '2016-02-01T00:00:00-11:00', new Date(Date.UTC(2016, 1, 1, 11)) ], + [ '2017-01-07T11:25:00+01:00', new Date(Date.UTC(2017, 0, 7, 10, 25)) ], + [ '2017-01-07T00:00:00+01:00', new Date(Date.UTC(2017, 0, 6, 23)) ], + [ + '2016-02-01T00:00:00.12Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 120)) + ], + [ + '2016-02-01T00:00:00.123456Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 123)) + ], + [ + '2016-02-01T00:00:00.12399Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 123)) + ], [ '2016-02-01T00:00:00.000Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 0)) ], [ - '2016-02-01T00:00:00.999Z', - new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 999)) + '2016-02-01T00:00:00.993Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 993)) ], [ - '2016-02-01T00:00:00.456Z', - new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 456)) + '2017-01-07T11:25:00.450+01:00', + new Date(Date.UTC(2017, 0, 7, 10, 25, 0, 450)) ], + [ + // eslint-disable-next-line no-new-wrappers + new String('2017-01-07T11:25:00.450+01:00'), + new Date(Date.UTC(2017, 0, 7, 10, 25, 0, 450)) + ] ].forEach(([ value, expected ]) => { - expect( - GraphQLDateTime.parseValue(value).toISOString() - ).to.equal(expected.toISOString()); + it(`parses date-time string ${value} into Date ${expected}`, () => { + expect( + GraphQLDateTime.parseValue(value).toISOString() + ).to.equal(expected.toISOString()); + }); }); [ @@ -390,162 +397,87 @@ describe('Type System: Scalar coercion', () => { [], true, ].forEach(invalidInput => { - expect(() => - GraphQLDateTime.parseValue(invalidInput) - ).to.throw( - 'DateTime cannot represent non string type ' + invalidInput - ); + it(`throws parsing ${String(invalidInput)}`, () => { + expect(() => + GraphQLDateTime.parseValue(invalidInput) + ).to.throw( + 'DateTime cannot represent non string type ' + invalidInput + ); + }); }); - [ - // General - 'Invalid date', - // Year and month - '2016-00', - '2016-13', - '2016-1', - '201613', - // Date - '2016-01-00', - '2016-01-32', - '2016-01-1', - '20160101', - // Date leap year checks - '2015-02-29', - '2015-02-30', - '1900-02-29', - '1900-02-30', - '2016-02-30', - '2000-02-30', - // Datetime with hours and minutes - '2016-02-01T24:01Z', - '2016-02-01T00:60Z', - '2016-02-01T0:60Z', - '2016-02-01T00:0Z', - '2015-02-29T00:00Z', - '2016-02-01T0000', - '2016-02-02T24:00Z', - // Datetime with hours, minutes and seconds - '2016-02-01T000059Z', - '2016-02-01T00:00:60Z', - '2016-02-01T00:00:0Z', - '2015-02-29T00:00:00Z', - '2016-02-01T00:00:00', - // Datetime with hours, minutes, seconds and milliseconds - '2016-02-01T00:00:00.1Z', - '2016-02-01T00:00:00.22Z', - '2015-02-29T00:00:00.000Z', - '2016-02-01T00:00:00.223', - ].forEach(dateString => { - expect(() => - GraphQLDateTime.parseValue(dateString) - ).to.throw( - 'DateTime cannot represent an invalid ISO 8601 date ' + dateString - ); + invalidDateTime.forEach(dateString => { + it(`throws parsing invalid date-time string ${dateString}`, () => { + expect(() => + GraphQLDateTime.parseValue(dateString) + ).to.throw( + 'DateTime cannot represent an invalid ' + + 'date-time string ' + dateString + ); + }); }); }); - it('parses literal DateTime', () => { + describe('parses literal DateTime', () => { [ - // Years - [ '2016', new Date(Date.UTC(2016, 0)) ], - // Years and month - [ '2016-01', new Date(Date.UTC(2016, 0)) ], - [ '2016-11', new Date(Date.UTC(2016, 10)) ], - // Date - [ '2016-02-01', new Date(Date.UTC(2016, 1, 1)) ], - [ '2016-09-15', new Date(Date.UTC(2016, 8, 15)) ], - [ '2016-01-31', new Date(Date.UTC(2016, 0, 31)) ], - // Date with 30 days in the month - [ '2016-04-30', new Date(Date.UTC(2016, 3, 30)) ], - [ '2016-06-30', new Date(Date.UTC(2016, 5, 30)) ], - [ '2016-09-30', new Date(Date.UTC(2016, 8, 30)) ], - [ '2016-11-30', new Date(Date.UTC(2016, 10, 30)) ], - // Date leap year checks - [ '2016-02-29', new Date(Date.UTC(2016, 1, 29)) ], - [ '2000-02-29', new Date(Date.UTC(2000, 1, 29)) ], - // Datetime with hours and minutes - [ '2016-02-01T00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0)) ], - [ '2016-02-01T23:00Z', new Date(Date.UTC(2016, 1, 1, 23, 0)) ], - [ '2016-02-01T23:59Z', new Date(Date.UTC(2016, 1, 1, 23, 59)) ], - [ '2016-02-01T15:32Z', new Date(Date.UTC(2016, 1, 1, 15, 32)) ], - // Datetime with hours, minutes and seconds - [ '2016-02-01T00:00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 0)) ], - [ '2016-02-01T00:00:15Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 15)) ], + [ + '2016-02-01T00:00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 0)) ], [ '2016-02-01T00:00:59Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 59)) ], - // Datetime with hours, minutes, seconds and milliseconds + [ '2016-02-01T00:00:00-11:00', new Date(Date.UTC(2016, 1, 1, 11)) ], + [ '2017-01-07T11:25:00+01:00', new Date(Date.UTC(2017, 0, 7, 10, 25)) ], + [ '2017-01-07T00:00:00+01:00', new Date(Date.UTC(2017, 0, 6, 23)) ], [ - '2016-02-01T00:00:00.000Z', - new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 0)) + '2016-02-01T00:00:00.12Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 120)) ], [ - '2016-02-01T00:00:00.999Z', - new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 999)) + // rounds down the fractional seconds to 3 decimal places. + '2016-02-01T00:00:00.123456Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 123)) ], [ - '2016-02-01T00:00:00.456Z', - new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 456)) + // rounds down the fractional seconds to 3 decimal places. + '2016-02-01T00:00:00.12399Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 123)) ], + [ + '2017-01-07T11:25:00.450+01:00', + new Date(Date.UTC(2017, 0, 7, 10, 25, 0, 450)) + ] ].forEach(([ value, expected ]) => { - expect( - GraphQLDateTime.parseLiteral({ - kind: Kind.STRING, value - }).toISOString() - ).to.equal(expected.toISOString()); + const literal = { + kind: Kind.STRING, + value + }; + + it( + `parses literal ${JSON.stringify(literal)} into Date ${expected}`, + () => { + const parsed = GraphQLDateTime.parseLiteral({ + kind: Kind.STRING, value + }); + expect(parsed.getTime()).to.equal(expected.getTime()); + }); }); - [ - // General - 'Invalid date', - // Year and month - '2016-00', - '2016-13', - '2016-1', - '201613', - // Date - '2016-01-00', - '2016-01-32', - '2016-01-1', - '20160101', - // Date leap year checks - '2015-02-29', - '2015-02-30', - '1900-02-29', - '1900-02-30', - '2016-02-30', - '2000-02-30', - // Datetime with hours and minutes - '2016-02-01T24:01Z', - '2016-02-01T00:60Z', - '2016-02-01T0:60Z', - '2016-02-01T00:0Z', - '2015-02-29T00:00Z', - '2016-02-01T0000', - '2016-02-02T24:00Z', - // Datetime with hours, minutes and seconds - '2016-02-01T000059Z', - '2016-02-01T00:00:60Z', - '2016-02-01T00:00:0Z', - '2015-02-29T00:00:00Z', - '2016-02-01T00:00:00', - // Datetime with hours, minutes, seconds and milliseconds - '2016-02-01T00:00:00.1Z', - '2016-02-01T00:00:00.22Z', - '2015-02-29T00:00:00.000Z', - '2016-02-01T00:00:00.223', - ].forEach(value => { + invalidDateTime.forEach(value => { + const literal = { + kind: Kind.STRING, value + }; + it(`returns null for invalid literal ${JSON.stringify(literal)}`, () => { + expect( + GraphQLDateTime.parseLiteral(literal) + ).to.equal(null); + }); + }); + + it('returns null for invalid kind', () => { expect( GraphQLDateTime.parseLiteral({ - kind: Kind.STRING, value + kind: Kind.FLOAT, value: 5 }) ).to.equal(null); }); - - expect( - GraphQLDateTime.parseLiteral({ - kind: Kind.FLOAT, value: 5 - }) - ).to.equal(null); }); }); diff --git a/src/type/scalars.js b/src/type/scalars.js index 449e028608..56b25bf617 100644 --- a/src/type/scalars.js +++ b/src/type/scalars.js @@ -123,95 +123,85 @@ export const GraphQLID = new GraphQLScalarType({ }); /** -* Function that checks whether a date string represents a valid date in -* the ISO 8601 formats: -* - YYYY -* - YYYY-MM -* - YYYY-MM-DD, -* - YYYY-MM-DDThh:mmZ -* - YYYY-MM-DDThh:mm:ssZ -* - YYYY-MM-DDThh:mm:ss.sssZ -*/ -function isValidDate(datestring: string): boolean { - - // An array of regular expression containing the supported ISO 8601 formats - const ISO_8601_REGEX = [ - /^\d{4}$/, // YYYY - /^\d{4}-\d{2}$/, // YYYY-MM - /^\d{4}-\d{2}-\d{2}$/, // YYYY-MM-DD, - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/, // YYYY-MM-DDThh:mmZ - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, // YYYY-MM-DDThh:mm:ssZ - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/,// YYYY-MM-DDThh:mm:ss.sssZ - ]; + * Function that validates whether a date-time string + * is valid according to the RFC 3339 specification. + */ +function isValidDate(dateTime: string): boolean { + /* eslint-disable max-len*/ + const RFC_3339_REGEX = /^(\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60))(\.\d{1,})?(([Z])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))$/; - // Validate the structure of the date-string - if (!ISO_8601_REGEX.some(regex => regex.test(datestring))) { + if (!RFC_3339_REGEX.test(dateTime)) { return false; } - - // Check if it is a correct date using the javascript Date parse() method. - const time = Date.parse(datestring); + // Check if it is a valid Date. + // Note, according to RFC 3339 2016-02-01T00:00:60Z is a valid date-time string. + // However, it is considered invalid when parsed by the javascript + // Date class because it ignores leap seconds. + // Therefore, this implementation also ignores leap seconds. + const time = Date.parse(dateTime); if (time !== time) { return false; } - // Perform specific checks for the hours in datetimes - // (i.e. datetimes that incude YYYY-MM-DDThh). We need - // to make sure that the number of hours in a day ranges - // from 0 to 23. This needs to be done because node v6 and above - // support the hour range 0-24 while other node versions only support - // range 0 to 23. We need to keep this consistent across node versions. - if (datestring.length >= 13) { - const hour = Number(datestring.substr(11,2)); - if (hour > 23) { - return false; - } - } - - // Perform specific checks for dates (i.e. that include - // YYYY-MM-DD). We need - // to make sure that the date string has the correct - // number of days for a given month. This check is required - // because the javascript Date.parse() assumes every month has 31 days. - if (datestring.length >= 10) { - const year = Number(datestring.substr(0,4)); - const month = Number(datestring.substr(5,2)); - const day = Number(datestring.substr(8,2)); - - switch (month) { - case 2: // February - if (leapYear(year) && day > 29) { - return false; - } else if (!leapYear(year) && day > 28) { - return false; - } - return true; - case 4: // April - case 6: // June - case 9: // September - case 11: // November - if (day > 30) { - return false; - } - break; - } - } - + // Check whether a certain year is a leap year. + // // Every year that is exactly divisible by four // is a leap year, except for years that are exactly // divisible by 100, but these centurial years are // leap years if they are exactly divisible by 400. // For example, the years 1700, 1800, and 1900 are not leap years, // but the years 1600 and 2000 are. - function leapYear(year) { + const leapYear = year => { return ((year % 4 === 0) && (year % 100 !== 0)) || (year % 400 === 0); + }; + + const year = Number(dateTime.substr(0, 4)); + const month = Number(dateTime.substr(5, 2)); + const day = Number(dateTime.substr(8, 2)); + + // Month Number Month/Year Maximum value of date-mday + // ------------ ---------- -------------------------- + // 01 January 31 + // 02 February, normal 28 + // 02 February, leap year 29 + // 03 March 31 + // 04 April 30 + // 05 May 31 + // 06 June 30 + // 07 July 31 + // 08 August 31 + // 09 September 30 + // 10 October 31 + // 11 November 30 + // 12 December 31 + switch (month) { + case 2: // February + if (leapYear(year) && day > 29) { + return false; + } else if (!leapYear(year) && day > 28) { + return false; + } + return true; + case 4: // April + case 6: // June + case 9: // September + case 11: // November + if (day > 30) { + return false; + } + break; } return true; } export const GraphQLDateTime = new GraphQLScalarType({ name: 'DateTime', - description: 'An ISO-8601 encoded UTC date string.', + description: + 'The `DateTime` scalar represents a timestamp, ' + + 'represented as a string serialized date-time conforming to the '+ + 'RFC 3339(https://www.ietf.org/rfc/rfc3339.txt) profile of the ' + + 'ISO 8601 standard for representation of dates and times using the ' + + 'Gregorian calendar.', serialize(value: mixed): string { if (value instanceof Date) { const time = value.getTime(); @@ -224,7 +214,7 @@ export const GraphQLDateTime = new GraphQLScalarType({ return value; } throw new TypeError( - 'DateTime cannot represent an invalid ISO 8601 date string ' + value + 'DateTime cannot represent an invalid date-time string ' + value ); } else if (typeof value === 'number' || value instanceof Number) { // Serialize from Unix timestamp: the number of @@ -258,7 +248,7 @@ export const GraphQLDateTime = new GraphQLScalarType({ return new Date(value); } throw new TypeError( - 'DateTime cannot represent an invalid ISO 8601 date ' + value + 'DateTime cannot represent an invalid date-time string ' + value ); }, parseLiteral(ast) {