diff --git a/packages/quicktype-core/src/DateTime.ts b/packages/quicktype-core/src/DateTime.ts index 7c2f4b049..301a25256 100644 --- a/packages/quicktype-core/src/DateTime.ts +++ b/packages/quicktype-core/src/DateTime.ts @@ -1,5 +1,12 @@ /* eslint-disable */ +// Adapted from ajv: // https://github.com/epoberezkin/ajv/blob/4d76c6fb813b136b6ec4fe74990bc97233d75dea/lib/compile/formats.js +// +// We deviate from ajv in that we recognize only strict RFC 3339 date-times: +// the date/time separator must be "T" (or "t"), not a space, and times must +// carry a timezone offset ("Z"/"z" or "+hh:mm"/"-hh:mm"). ajv accepted the +// lenient forms, which meant we inferred "date-time" for strings like +// "2013-06-15 21:10:28" that most target languages' date parsers reject. /* The MIT License (MIT) @@ -27,7 +34,8 @@ SOFTWARE. const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/; const DAYS = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; -const TIME = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d:\d\d)?$/i; +// RFC 3339 full-time: the timezone offset is mandatory. +const TIME = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d:\d\d)$/i; export interface DateTimeRecognizer { isDate: (s: string) => boolean; @@ -35,7 +43,8 @@ export interface DateTimeRecognizer { isTime: (s: string) => boolean; } -const DATE_TIME_SEPARATOR = /t|\s/i; +// RFC 3339 date-time: the separator must be "T" or "t", never a space. +const DATE_TIME_SEPARATOR = /t/i; export class DefaultDateTimeRecognizer implements DateTimeRecognizer { isDate(str: string) { diff --git a/test/languages.ts b/test/languages.ts index 638c3a91d..7715bb22c 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -91,7 +91,7 @@ export const CSharpLanguage: Language = { return `dotnet run -p:CheckEolTargetFramework=false -- "${sample}"`; }, diffViaSchema: true, - skipDiffViaSchema: ["34702.json", "437e7.json"], + skipDiffViaSchema: ["34702.json"], allowMissingNull: false, features: [ "enum", @@ -134,7 +134,7 @@ export const CSharpLanguageSystemTextJson: Language = { return `dotnet run -p:CheckEolTargetFramework=false -- "${sample}"`; }, diffViaSchema: true, - skipDiffViaSchema: ["34702.json", "437e7.json"], + skipDiffViaSchema: ["34702.json"], allowMissingNull: false, features: [ "enum", @@ -469,15 +469,6 @@ export const GoLanguage: Language = { // can't differenciate empty array and nothing for optional empty array // (omitempty). "github-events.json", - // files contains datetime filed with contain non rfc3339 format value - "f6a65.json", - "e0ac7.json", - "c3303.json", - "7681c.json", - "437e7.json", - "127a1.json", - "26b49.json", - "0cffa.json", ], skipMiscJSON: false, skipSchema: [ @@ -592,15 +583,9 @@ export const CPlusPlusLanguage: Language = { "bug427.json", "keywords.json", "0a91a.json", - "0cffa.json", - "127a1.json", - "26b49.json", "34702.json", - "7681c.json", "7f568.json", - "c3303.json", "e8b04.json", - "f6a65.json", "fcca3.json", ], allowMissingNull: false, @@ -663,9 +648,7 @@ export const ElmLanguage: Language = { "github-events.json", "nbl-stats.json", "0a91a.json", - "0cffa.json", "0e0c2.json", - "127a1.json", "29f47.json", "2df80.json", "27332.json", @@ -676,13 +659,10 @@ export const ElmLanguage: Language = { "6de06.json", "76ae1.json", "7eb30.json", - "7681c.json", "ae9ca.json", "af2d1.json", "be234.json", - "c3303.json", "e8b04.json", - "f6a65.json", ], allowMissingNull: false, features: ["enum", "union", "no-defaults"], @@ -731,17 +711,12 @@ export const SwiftLanguage: Language = { "keywords.json", "0a358.json", // date-time issues "0a91a.json", - "0cffa.json", // date-time issues - "127a1.json", // date-time issues - "26b49.json", // date-time issues "26c9c.json", // uri/string confusion "32d5c.json", // date-time issues "337ed.json", "34702.json", - "437e7.json", // date-time issues "54d32.json", // date-time issues "5eae5.json", // date-time issues - "7681c.json", // date-time issues "77392.json", // date-time issues "7f568.json", "734ad.json", @@ -750,13 +725,10 @@ export const SwiftLanguage: Language = { "9ac3b.json", // date-time issues "a0496.json", // date-time issues "b4865.json", // date-time issues - "c3303.json", // date-time issues "c8c7e.json", "d23d5.json", // date-time issues - "e0ac7.json", // date-time issues "e53b5.json", "e8b04.json", - "f6a65.json", // date-time issues "fcca3.json", "f82d9.json", "bug863.json", // Unable to resolve reserved keyword use, "description" @@ -872,7 +844,6 @@ export const TypeScriptLanguage: Language = { output: "TopLevel.ts", topLevel: "TopLevel", skipJSON: [ - "7681c.json", // year 0 is out of range ], skipMiscJSON: false, skipSchema: ["keyword-unions.schema"], // can't handle "constructor" property @@ -904,7 +875,6 @@ export const JavaScriptLanguage: Language = { output: "TopLevel.js", topLevel: "TopLevel", skipJSON: [ - "7681c.json", // year 0 is out of range ], skipMiscJSON: false, skipSchema: ["keyword-unions.schema"], // can't handle "constructor" property @@ -962,7 +932,6 @@ export const FlowLanguage: Language = { output: "TopLevel.js", topLevel: "TopLevel", skipJSON: [ - "7681c.json", // year 0 is out of range ], skipMiscJSON: false, skipSchema: [ @@ -1612,7 +1581,6 @@ export const TypeScriptZodLanguage: Language = { "nst-test-suite.json", "keywords.json", "ed095.json", - "7681c.json", "32d5c.json", ], skipMiscJSON: false, diff --git a/test/unit/date-time-recognizer.test.ts b/test/unit/date-time-recognizer.test.ts new file mode 100644 index 000000000..dee7e5396 --- /dev/null +++ b/test/unit/date-time-recognizer.test.ts @@ -0,0 +1,85 @@ +// The default date/time recognizer decides whether a JSON string sample is +// inferred as "date", "time", or "date-time". It must accept only strict +// RFC 3339 forms: "date-time" requires the "T" (or "t") separator — a space +// is not allowed — and times must carry a timezone offset. Before this was +// tightened, strings like "2013-06-15 21:10:28" were inferred as date-times, +// producing generated code whose strict date parsers (Go, Swift, java.time, +// ...) could not read the very samples the types were inferred from. +import { DefaultDateTimeRecognizer } from "quicktype-core/dist/DateTime.js"; +import { describe, expect, test } from "vitest"; + +const recognizer = new DefaultDateTimeRecognizer(); + +describe("isDateTime", () => { + test("accepts RFC 3339 date-times with offset", () => { + expect(recognizer.isDateTime("2018-08-14T02:45:50Z")).toBe(true); + expect(recognizer.isDateTime("2018-08-14T02:45:50+01:00")).toBe(true); + expect(recognizer.isDateTime("2018-08-14T02:45:50-11:30")).toBe(true); + }); + + test("accepts lowercase t and z", () => { + expect(recognizer.isDateTime("2018-08-14t02:45:50z")).toBe(true); + }); + + test("accepts fractional seconds", () => { + expect(recognizer.isDateTime("2015-04-24T01:46:50.342496Z")).toBe(true); + expect(recognizer.isDateTime("2010-01-12T00:00:00.000Z")).toBe(true); + expect(recognizer.isDateTime("2015-07-15T23:25:21.541+02:00")).toBe( + true, + ); + }); + + test("rejects a space separator", () => { + expect(recognizer.isDateTime("2013-06-15 21:10:28")).toBe(false); + expect(recognizer.isDateTime("1970-01-01 00:00:00")).toBe(false); + expect(recognizer.isDateTime("2015-04-24 01:46:50.342496Z")).toBe( + false, + ); + }); + + test("rejects a missing timezone offset", () => { + expect(recognizer.isDateTime("2018-08-14T02:45:50")).toBe(false); + expect(recognizer.isDateTime("2018-08-14T02:45:50.123")).toBe(false); + }); + + test("rejects invalid dates and times", () => { + expect(recognizer.isDateTime("0000-00-00T00:00:00Z")).toBe(false); + expect(recognizer.isDateTime("2018-13-01T00:00:00Z")).toBe(false); + expect(recognizer.isDateTime("2018-08-14T24:00:00Z")).toBe(false); + expect(recognizer.isDateTime("2018-08-14X02:45:50Z")).toBe(false); + }); +}); + +describe("isDate", () => { + test("accepts RFC 3339 full-date", () => { + expect(recognizer.isDate("2018-08-14")).toBe(true); + expect(recognizer.isDate("1970-01-01")).toBe(true); + }); + + test("rejects invalid dates", () => { + expect(recognizer.isDate("0000-00-00")).toBe(false); + expect(recognizer.isDate("2018-13-01")).toBe(false); + expect(recognizer.isDate("2018-02-30")).toBe(false); + expect(recognizer.isDate("2018-8-14")).toBe(false); + }); +}); + +describe("isTime", () => { + test("accepts RFC 3339 full-time (offset required)", () => { + expect(recognizer.isTime("02:45:50Z")).toBe(true); + expect(recognizer.isTime("02:45:50z")).toBe(true); + expect(recognizer.isTime("02:45:50.123+01:00")).toBe(true); + expect(recognizer.isTime("23:59:59-11:30")).toBe(true); + }); + + test("rejects a missing timezone offset", () => { + expect(recognizer.isTime("02:45:50")).toBe(false); + expect(recognizer.isTime("02:45:50.123")).toBe(false); + }); + + test("rejects invalid times", () => { + expect(recognizer.isTime("24:00:00Z")).toBe(false); + expect(recognizer.isTime("02:60:00Z")).toBe(false); + expect(recognizer.isTime("02:45:60Z")).toBe(false); + }); +});