From dd9d00c4553bdbfc5fa95a113af7c013648ce88e Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Thu, 21 Mar 2019 13:05:08 +0100 Subject: [PATCH 1/2] fix(gatsby): add some quickchecks to isDate --- packages/gatsby/src/schema/types/date.js | 62 ++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/gatsby/src/schema/types/date.js b/packages/gatsby/src/schema/types/date.js index b83c425670bb8..3905a43a97930 100644 --- a/packages/gatsby/src/schema/types/date.js +++ b/packages/gatsby/src/schema/types/date.js @@ -96,14 +96,70 @@ const GraphQLDate = new GraphQLScalarType({ }, }) -// Check if this is a date. -// All the allowed ISO 8601 date-time formats used. +const momentFormattingTokens = /(\[[^[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g +const momentFormattingRegexes = { + YYYY: `\\d{4}`, + MM: `\\d{2}`, + DD: `\\d{2}`, + HH: `\\d{2}`, + mm: `\\d{2}`, + ss: `\\d{2}`, + SSS: `\\d{3}`, + SSSSSS: `\\d{6}`, + "[W]": `W`, + ".": `\\.`, +} +const ISO_8601_FORMAT_AS_REGEX = ISO_8601_FORMAT.map(format => + // convert ISO string to a map of momentTokens ([YYYY, MM, DD]) + [...format.match(momentFormattingTokens)] + .map(token => + // see if the token (YYYY or ss) is found, else we just return the value + momentFormattingRegexes[token] ? momentFormattingRegexes[token] : token + ) + .join(``) +).join(`|`) + +// calculate all lengths of the formats, if a string is longer or smaller it can't be valid +const ISO_8601_FORMAT_LENGTHS = [ + ...new Set(ISO_8601_FORMAT.map(str => str.length)), +] +// lets imagine these formats: YYYY-MM-DDTHH & YYYY-MM-DD HHmmss.SSSSSS Z +// this regex looks like (/^(\d{4}-\d{2}-\d{2}T\d{2}|\d{4}-\d{2}-\d{2} \d{2}\d{2}\d{2}.\d{6} Z)$) +const quickDateValidateRegex = new RegExp(`^(${ISO_8601_FORMAT_AS_REGEX})$`) + +const looksLikeDateStartRegex = /^\d{4}/ +// this regex makes sure the last characters are looking like a string and not a guid +const looksLikeDateEndRegex = /((\d|-|T|W| |:)\d{2}|[:-]\d|\d:\dZ|\d{2}Z| Z)$/ + +/** + * isDate does 5 quickchecks & fallsback to momentjs to check if it's a valid date + * 1) is it a number? + * 2) does the length of the value comply with any of our formats + * 3) does the str starts with 4 digites (YYYY) + * 4) does the str ends with something that looks like a date + * 5) Small regex to see if it matches any of the formats + * 6) check momentjs + * + * @param {string|number} value + * @return {boolean} + */ function isDate(value) { // quick check if value does not look like a date - if (typeof value === `number` || !/^\d{4}/.test(value)) { + if ( + typeof value === `number` || + !ISO_8601_FORMAT_LENGTHS.includes(value.length) || + !looksLikeDateStartRegex.test(value) || + !looksLikeDateEndRegex.test(value) + ) { return false } + // If it looks like a date we parse the date with a regex to see if we can handle it. + // momentjs just does regex validation itself if you don't do any operations on it. + if (quickDateValidateRegex.test(value)) { + return true + } + const momentDate = moment.utc(value, ISO_8601_FORMAT, true) return momentDate.isValid() } From c233fd3a7d8d0b8a87b5f584bfde35c44d11bba3 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 22 Mar 2019 09:28:31 +0100 Subject: [PATCH 2/2] update tests --- .../gatsby/src/schema/infer/example-value.js | 4 +- .../gatsby/src/schema/types/__tests__/date.js | 128 +++++++++++++++++- packages/gatsby/src/schema/types/date.js | 46 +++++-- 3 files changed, 164 insertions(+), 14 deletions(-) diff --git a/packages/gatsby/src/schema/infer/example-value.js b/packages/gatsby/src/schema/infer/example-value.js index 44acc75d6d9ba..08c53e6d44032 100644 --- a/packages/gatsby/src/schema/infer/example-value.js +++ b/packages/gatsby/src/schema/infer/example-value.js @@ -1,6 +1,6 @@ const _ = require(`lodash`) const is32BitInteger = require(`./is-32-bit-integer`) -const { isDate } = require(`../types/date`) +const { looksLikeADate } = require(`../types/date`) const getExampleValue = ({ nodes, @@ -168,7 +168,7 @@ const getType = value => { case `number`: return `number` case `string`: - return isDate(value) ? `date` : `string` + return looksLikeADate(value) ? `date` : `string` case `boolean`: return `boolean` case `object`: diff --git a/packages/gatsby/src/schema/types/__tests__/date.js b/packages/gatsby/src/schema/types/__tests__/date.js index 313d5e60fdedf..936e8056dc2e7 100644 --- a/packages/gatsby/src/schema/types/__tests__/date.js +++ b/packages/gatsby/src/schema/types/__tests__/date.js @@ -1,6 +1,6 @@ const { store } = require(`../../../redux`) const { build } = require(`../..`) -const { isDate } = require(`../date`) +const { isDate, looksLikeADate } = require(`../date`) require(`../../../db/__tests__/fixtures/ensure-loki`)() // Timestamps grabbed from https://github.com/moment/moment/blob/2e2a5b35439665d4b0200143d808a7c26d6cd30f/src/test/moment/is_valid.js @@ -152,6 +152,132 @@ describe(`isDate`, () => { }) }) +describe(`looksLikeADate`, () => { + it.each([ + `1970`, + `2019`, + `1970-01`, + `2019-01`, + `1970-01-01`, + `2010-01-01`, + `2010-01-30`, + `19700101`, + `20100101`, + `20100130`, + `2010-01-30T23+00:00`, + `2010-01-30T23:59+00:00`, + `2010-01-30T23:59:59+00:00`, + `2010-01-30T23:59:59.999+00:00`, + `2010-01-30T23:59:59.999-07:00`, + `2010-01-30T00:00:00.000+07:00`, + `2010-01-30T23:59:59.999-07`, + `2010-01-30T00:00:00.000+07`, + `2010-01-30T23Z`, + `2010-01-30T23:59Z`, + `2010-01-30T23:59:59Z`, + `2010-01-30T23:59:59.999Z`, + `2010-01-30T00:00:00.000Z`, + `1970-01-01T00:00:00.000001Z`, + `2012-04-01T00:00:00-05:00`, + `2012-11-12T00:00:00+01:00`, + ])(`should return true for valid ISO 8601: %s`, dateString => { + expect(looksLikeADate(dateString)).toBeTruthy() + }) + + it.each([ + `2010-01-30 23+00:00`, + `2010-01-30 23:59+00:00`, + `2010-01-30 23:59:59+00:00`, + `2010-01-30 23:59:59.999+00:00`, + `2010-01-30 23:59:59.999-07:00`, + `2010-01-30 00:00:00.000+07:00`, + `2010-01-30 23:59:59.999-07`, + `2010-01-30 00:00:00.000+07`, + `1970-01-01 00:00:00.000Z`, + `2012-04-01 00:00:00-05:00`, + `2012-11-12 00:00:00+01:00`, + `1970-01-01 00:00:00.0000001 Z`, + `1970-01-01 00:00:00.000 Z`, + `1970-01-01 00:00:00 Z`, + `1970-01-01 000000 Z`, + `1970-01-01 00:00 Z`, + `1970-01-01 00 Z`, + ])(`should return true for ISO 8601 (no T, extra space): %s`, dateString => { + expect(looksLikeADate(dateString)).toBeTruthy() + }) + + it.each([`1970-W31`, `2006-W01`, `1970W31`, `2009-W53-7`, `2009W537`])( + `should return true for ISO 8601 week dates: %s`, + dateString => { + expect(looksLikeADate(dateString)).toBeTruthy() + } + ) + + it.each([`1970-334`, `1970334`, `2090-001`, `2090001`])( + `should return true for ISO 8601 ordinal dates: %s`, + dateString => { + expect(looksLikeADate(dateString)).toBeTruthy() + } + ) + + it.skip.each([ + `2018-08-31T23:25:16.019345+02:00`, + `2018-08-31T23:25:16.019345Z`, + ])(`should return true for microsecond precision: %s`, dateString => { + expect(looksLikeADate(dateString)).toBeTruthy() + }) + + it.skip.each([ + `2018-08-31T23:25:16.019345123+02:00`, + `2018-08-31T23:25:16.019345123Z`, + ])(`should return true for nanosecond precision: %s`, dateString => { + expect(looksLikeADate(dateString)).toBeTruthy() + }) + + it.skip.each([`2018-08-31T23:25:16.012345678901+02:00`])( + `should return false for precision beyond 9 digits: %s`, + dateString => { + expect(looksLikeADate(dateString)).toBeFalsy() + } + ) + + it.each([ + `2010-00-00`, + `2010-01-00`, + `2010-01-40`, + `2010-01-01T24:01`, // 24:00:00 is actually valid + `2010-01-40T24:01+00:00`, + `2010-01-01T23:60`, + `2010-01-01T23:59:60`, + `2010-01-40T23:60+00:00`, + `2010-01-40T23:59:60+00:00`, + ])(`should return true for some valid ISO 8601: %s`, dateString => { + expect(looksLikeADate(dateString)).toBeTruthy() + }) + + it.each([ + `2010-01-40T23:59:59.9999`, + `2010-01-40T23:59:59.9999+00:00`, + `2010-01-40T23:59:59,9999+00:00`, + `2010-00-00T+00:00`, + `2010-01-00T+00:00`, + `2010-01-40T+00:00`, + `2012-04-01T00:00:00-5:00`, // should be -05:00 + `2012-04-01T00:00:00+1:00`, // should be +01:00 + undefined, + `undefined`, + null, + `null`, + [], + {}, + ``, + ` `, + `2012-04-01T00:basketball`, + ])(`should return false for invalid ISO 8601: %s`, dateString => { + expect(looksLikeADate(dateString)).toBeFalsy() + }) +}) + const nodes = [ { id: `id1`, diff --git a/packages/gatsby/src/schema/types/date.js b/packages/gatsby/src/schema/types/date.js index 3905a43a97930..e17ec8a9be4c2 100644 --- a/packages/gatsby/src/schema/types/date.js +++ b/packages/gatsby/src/schema/types/date.js @@ -101,13 +101,18 @@ const momentFormattingRegexes = { YYYY: `\\d{4}`, MM: `\\d{2}`, DD: `\\d{2}`, + DDDD: `\\d{4}`, HH: `\\d{2}`, mm: `\\d{2}`, ss: `\\d{2}`, SSS: `\\d{3}`, SSSSSS: `\\d{6}`, + E: `\\d`, + W: `\\d`, + WW: `\\d{2}`, "[W]": `W`, ".": `\\.`, + Z: `(Z|[+-]\\d\\d(?::?\\d\\d)?)`, } const ISO_8601_FORMAT_AS_REGEX = ISO_8601_FORMAT.map(format => // convert ISO string to a map of momentTokens ([YYYY, MM, DD]) @@ -121,18 +126,29 @@ const ISO_8601_FORMAT_AS_REGEX = ISO_8601_FORMAT.map(format => // calculate all lengths of the formats, if a string is longer or smaller it can't be valid const ISO_8601_FORMAT_LENGTHS = [ - ...new Set(ISO_8601_FORMAT.map(str => str.length)), + ...new Set( + ISO_8601_FORMAT.reduce((acc, val) => { + if (!val.endsWith(`Z`)) { + return acc.concat(val.length) + } + + // we add count of +01 & +01:00 + return acc.concat([val.length, val.length + 3, val.length + 5]) + }, []) + ), ] + // lets imagine these formats: YYYY-MM-DDTHH & YYYY-MM-DD HHmmss.SSSSSS Z // this regex looks like (/^(\d{4}-\d{2}-\d{2}T\d{2}|\d{4}-\d{2}-\d{2} \d{2}\d{2}\d{2}.\d{6} Z)$) const quickDateValidateRegex = new RegExp(`^(${ISO_8601_FORMAT_AS_REGEX})$`) const looksLikeDateStartRegex = /^\d{4}/ -// this regex makes sure the last characters are looking like a string and not a guid -const looksLikeDateEndRegex = /((\d|-|T|W| |:)\d{2}|[:-]\d|\d:\dZ|\d{2}Z| Z)$/ +// this regex makes sure the last characters are a number or the letter Z +const looksLikeDateEndRegex = /(\d|Z)$/ /** - * isDate does 5 quickchecks & fallsback to momentjs to check if it's a valid date + * looksLikeADate isn't a 100% valid check if it is a real date but at least it's something that looks like a date. + * It won't catch values like 2010-02-30 * 1) is it a number? * 2) does the length of the value comply with any of our formats * 3) does the str starts with 4 digites (YYYY) @@ -140,14 +156,14 @@ const looksLikeDateEndRegex = /((\d|-|T|W| |:)\d{2}|[:-]\d|\d:\dZ|\d{2}Z| Z)$/ * 5) Small regex to see if it matches any of the formats * 6) check momentjs * - * @param {string|number} value + * @param {*} value * @return {boolean} */ -function isDate(value) { +function looksLikeADate(value) { // quick check if value does not look like a date if ( - typeof value === `number` || - !ISO_8601_FORMAT_LENGTHS.includes(value.length) || + !value || + (value.length && !ISO_8601_FORMAT_LENGTHS.includes(value.length)) || !looksLikeDateStartRegex.test(value) || !looksLikeDateEndRegex.test(value) ) { @@ -156,12 +172,20 @@ function isDate(value) { // If it looks like a date we parse the date with a regex to see if we can handle it. // momentjs just does regex validation itself if you don't do any operations on it. - if (quickDateValidateRegex.test(value)) { + if (typeof value === `string` && quickDateValidateRegex.test(value)) { return true } + return isDate(value) +} + +/** + * @param {*} value + * @return {boolean} + */ +function isDate(value) { const momentDate = moment.utc(value, ISO_8601_FORMAT, true) - return momentDate.isValid() + return typeof value !== `number` && momentDate.isValid() } const formatDate = ({ @@ -231,4 +255,4 @@ const dateResolver = { }, } -module.exports = { GraphQLDate, dateResolver, isDate } +module.exports = { GraphQLDate, dateResolver, isDate, looksLikeADate }