diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts index 356cc8ace..e0df375a4 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts @@ -16,7 +16,7 @@ import { import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; -import { stringEscape } from "./utils.js"; +import { stringEscape, unionMemberMatchPriority } from "./utils.js"; export class KotlinJacksonRenderer extends KotlinRenderer { private unionMemberJsonValueGuard(t: Type, _e: Sourcelike): Sourcelike { @@ -37,6 +37,7 @@ export class KotlinJacksonRenderer extends KotlinRenderer { // and enums in the same union (_enumType) => "is TextNode", (_unionType) => mustNotHappen(), + (_transformedStringType) => "is TextNode", ); } @@ -76,7 +77,17 @@ import com.fasterxml.jackson.module.kotlin.*`); this.typeGraph.allNamedTypes(), (c) => c instanceof ClassType && c.getProperties().size === 0, ); - if (hasUnions || this.haveEnums || hasEmptyObjects) { + const usesDateTime = this.haveTransformedStringType("date-time"); + const usesDate = this.haveTransformedStringType("date"); + const usesTime = this.haveTransformedStringType("time"); + if ( + hasUnions || + this.haveEnums || + hasEmptyObjects || + usesDateTime || + usesDate || + usesTime + ) { this.emitGenericConverter(); } @@ -84,6 +95,39 @@ import com.fasterxml.jackson.module.kotlin.*`); // if (hasEmptyObjects) { // converters.push([["convert(JsonNode::class,"], [" { it },"], [" { writeValueAsString(it) })"]]); // } + // We don't use jackson-datatype-jsr310's JavaTimeModule because its + // serializers don't round-trip faithfully (e.g. OffsetTime pads + // "23:20:50.52Z" to "23:20:50.520Z"); the ISO formatters do. + if (usesDateTime) { + converters.push([ + ["convert(OffsetDateTime::class,"], + [" { OffsetDateTime.parse(it.asText()) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(it)}\\"" })', + ], + ]); + } + + if (usesDate) { + converters.push([ + ["convert(LocalDate::class,"], + [" { LocalDate.parse(it.asText()) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_LOCAL_DATE.format(it)}\\"" })', + ], + ]); + } + + if (usesTime) { + converters.push([ + ["convert(OffsetTime::class,"], + [" { OffsetTime.parse(it.asText()) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_TIME.format(it)}\\"" })', + ], + ]); + } + this.forEachEnum("none", (_, name) => { converters.push([ ["convert(", name, "::class,"], @@ -334,19 +378,55 @@ private fun ObjectMapper.convert(k: kotlin.reflect.KClass<*>, fromJson: (Jso " = when (jn) {", ); this.indent(() => { - const table: Sourcelike[][] = []; + // Members whose JSON representations share a node type + // (several transformed string types, or a transformed string + // type and an enum, are all TextNode) must share a single + // guard and be tried in sequence, most specific parse first. + const groups: Array<{ + guard: string; + members: Array<{ name: Name; t: Type }>; + }> = []; this.forEachUnionMember( u, nonNulls, "none", null, (name, t) => { - table.push([ - [this.unionMemberJsonValueGuard(t, "jn")], - [" -> ", name, "(mapper.treeToValue(jn))"], - ]); + const guard = this.sourcelikeToString( + this.unionMemberJsonValueGuard(t, "jn"), + ); + const group = groups.find((g) => g.guard === guard); + if (group === undefined) { + groups.push({ guard, members: [{ name, t }] }); + } else { + group.members.push({ name, t }); + } }, ); + const table: Sourcelike[][] = []; + for (const { guard, members } of groups) { + const ordered = [...members].sort( + (a, b) => + unionMemberMatchPriority(a.t) - + unionMemberMatchPriority(b.t), + ); + let expr: Sourcelike = [ + ordered[ordered.length - 1].name, + "(mapper.treeToValue(jn))", + ]; + for (let i = ordered.length - 2; i >= 0; i--) { + expr = [ + "try { ", + ordered[i].name, + "(mapper.treeToValue(jn)) } catch (e: Exception) { ", + expr, + " }", + ]; + } + + table.push([[guard], [" -> ", expr]]); + } + if (maybeNull !== null) { const name = this.nameForUnionMember(u, maybeNull); table.push([ diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts index ac4668635..c3a0ef88c 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts @@ -17,7 +17,7 @@ import { import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; -import { stringEscape } from "./utils.js"; +import { stringEscape, unionMemberMatchPriority } from "./utils.js"; export class KotlinKlaxonRenderer extends KotlinRenderer { private unionMemberFromJsonValue(t: Type, e: Sourcelike): Sourcelike { @@ -54,6 +54,21 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { ".fromValue(it) }", ], (_unionType) => mustNotHappen(), + (transformedStringType) => { + if (transformedStringType.kind === "date-time") { + return [e, ".string?.let { OffsetDateTime.parse(it) }"]; + } + + if (transformedStringType.kind === "date") { + return [e, ".string?.let { LocalDate.parse(it) }"]; + } + + if (transformedStringType.kind === "time") { + return [e, ".string?.let { OffsetTime.parse(it) }"]; + } + + return [e, ".string"]; + }, ); } @@ -75,6 +90,7 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { // and enums in the same union (_enumType) => "is String", (_unionType) => mustNotHappen(), + (_transformedStringType) => "is String", ); } @@ -150,7 +166,17 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { this.typeGraph.allNamedTypes(), (c) => c instanceof ClassType && c.getProperties().size === 0, ); - if (hasUnions || this.haveEnums || hasEmptyObjects) { + const usesDateTime = this.haveTransformedStringType("date-time"); + const usesDate = this.haveTransformedStringType("date"); + const usesTime = this.haveTransformedStringType("time"); + if ( + hasUnions || + this.haveEnums || + hasEmptyObjects || + usesDateTime || + usesDate || + usesTime + ) { this.emitGenericConverter(); } @@ -160,6 +186,36 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { } const converters: Sourcelike[][] = []; + if (usesDateTime) { + converters.push([ + [".convert(OffsetDateTime::class,"], + [" { OffsetDateTime.parse(it.string!!) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(it)}\\"" })', + ], + ]); + } + + if (usesDate) { + converters.push([ + [".convert(LocalDate::class,"], + [" { LocalDate.parse(it.string!!) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_LOCAL_DATE.format(it)}\\"" })', + ], + ]); + } + + if (usesTime) { + converters.push([ + [".convert(OffsetTime::class,"], + [" { OffsetTime.parse(it.string!!) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_TIME.format(it)}\\"" })', + ], + ]); + } + if (hasEmptyObjects) { converters.push([ [".convert(JsonObject::class,"], @@ -455,25 +511,60 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { " = when (jv.inside) {", ); this.indent(() => { - const table: Sourcelike[][] = []; + // Members whose JSON representations share a value type + // (several transformed string types, or a transformed string + // type and an enum, are all strings) must share a single + // guard and be tried in sequence, most specific parse first. + const groups: Array<{ + guard: string; + members: Array<{ name: Name; t: Type }>; + }> = []; this.forEachUnionMember( u, nonNulls, "none", null, (name, t) => { - table.push([ - [this.unionMemberJsonValueGuard(t, "jv.inside")], - [ - " -> ", - name, - "(", - this.unionMemberFromJsonValue(t, "jv"), - "!!)", - ], - ]); + const guard = this.sourcelikeToString( + this.unionMemberJsonValueGuard(t, "jv.inside"), + ); + const group = groups.find((g) => g.guard === guard); + if (group === undefined) { + groups.push({ guard, members: [{ name, t }] }); + } else { + group.members.push({ name, t }); + } }, ); + const table: Sourcelike[][] = []; + for (const { guard, members } of groups) { + const ordered = [...members].sort( + (a, b) => + unionMemberMatchPriority(a.t) - + unionMemberMatchPriority(b.t), + ); + const last = ordered[ordered.length - 1]; + let expr: Sourcelike = [ + last.name, + "(", + this.unionMemberFromJsonValue(last.t, "jv"), + "!!)", + ]; + for (let i = ordered.length - 2; i >= 0; i--) { + expr = [ + "try { ", + ordered[i].name, + "(", + this.unionMemberFromJsonValue(ordered[i].t, "jv"), + "!!) } catch (e: Exception) { ", + expr, + " }", + ]; + } + + table.push([[guard], [" -> ", expr]]); + } + if (maybeNull !== null) { const name = this.nameForUnionMember(u, maybeNull); table.push([ diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts index ba1f7c843..a39af4d0f 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts @@ -1,3 +1,5 @@ +import { iterableSome } from "collection-utils"; + import { anyTypeIssueAnnotation, nullTypeIssueAnnotation, @@ -19,7 +21,7 @@ import { type EnumType, MapType, type ObjectType, - type PrimitiveType, + PrimitiveType, type Type, type UnionType, } from "../../Type/index.js"; @@ -158,6 +160,15 @@ export class KotlinRenderer extends ConvenienceRenderer { ]; } + protected haveTransformedStringType( + kind: "date-time" | "date" | "time", + ): boolean { + return iterableSome( + this.typeGraph.allTypesUnordered(), + (t) => t instanceof PrimitiveType && t.kind === kind, + ); + } + protected kotlinType( t: Type, withIssues = false, @@ -194,6 +205,21 @@ export class KotlinRenderer extends ConvenienceRenderer { return [this.kotlinType(nullable, withIssues), optional]; return this.nameForNamedType(unionType); }, + (transformedStringType) => { + if (transformedStringType.kind === "date-time") { + return "OffsetDateTime"; + } + + if (transformedStringType.kind === "date") { + return "LocalDate"; + } + + if (transformedStringType.kind === "time") { + return "OffsetTime"; + } + + return "String"; + }, ); } @@ -201,6 +227,12 @@ export class KotlinRenderer extends ConvenienceRenderer { // To be overridden } + // Emitted between the usage header and the package declaration — + // the only place Kotlin allows `@file:`-targeted annotations. + protected emitFileAnnotations(): void { + // To be overridden + } + protected emitHeader(): void { if (this.leadingComments !== undefined) { this.emitComments(this.leadingComments); @@ -209,8 +241,30 @@ export class KotlinRenderer extends ConvenienceRenderer { } this.ensureBlankLine(); + this.emitFileAnnotations(); this.emitLine("package ", this._kotlinOptions.packageName); this.ensureBlankLine(); + + // Check if we need to import java.time classes + const usesDateTime = this.haveTransformedStringType("date-time"); + const usesDate = this.haveTransformedStringType("date"); + const usesTime = this.haveTransformedStringType("time"); + + if (usesDateTime) { + this.emitLine("import java.time.OffsetDateTime"); + } + + if (usesDate) { + this.emitLine("import java.time.LocalDate"); + } + + if (usesTime) { + this.emitLine("import java.time.OffsetTime"); + } + + if (usesDateTime || usesDate || usesTime) { + this.ensureBlankLine(); + } } protected emitTopLevelPrimitive(t: PrimitiveType, name: Name): void { diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts index e358f23aa..99d789ed1 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts @@ -6,11 +6,51 @@ import type { ArrayType, EnumType, MapType, Type } from "../../Type/index.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; import { stringEscape } from "./utils.js"; +// kotlinx.serialization has no built-in serializers for java.time, so we +// emit our own and register them file-wide with `@file:UseSerializers`. +// Like the Jackson converters, they parse with `.parse` and format with the +// ISO formatters so values round-trip faithfully. +const dateTimeSerializers = [ + { + kind: "date-time", + name: "OffsetDateTimeSerializer", + type: "OffsetDateTime", + formatter: "ISO_OFFSET_DATE_TIME", + }, + { + kind: "date", + name: "LocalDateSerializer", + type: "LocalDate", + formatter: "ISO_LOCAL_DATE", + }, + { + kind: "time", + name: "OffsetTimeSerializer", + type: "OffsetTime", + formatter: "ISO_OFFSET_TIME", + }, +] as const; + /** * Currently supports simple classes, enums, and TS string unions (which are also enums). * TODO: Union, Any, Top Level Array, Top Level Map */ export class KotlinXRenderer extends KotlinRenderer { + protected forbiddenNamesForGlobalNamespace(): readonly string[] { + return [ + ...super.forbiddenNamesForGlobalNamespace(), + ...dateTimeSerializers.map((s) => s.name), + ]; + } + + private usedDateTimeSerializers(): Array< + (typeof dateTimeSerializers)[number] + > { + return dateTimeSerializers.filter((s) => + this.haveTransformedStringType(s.kind), + ); + } + protected anySourceType(optional: string): Sourcelike { return ["JsonElement", optional]; } @@ -78,6 +118,18 @@ export class KotlinXRenderer extends KotlinRenderer { this.emitTable(table); } + protected emitFileAnnotations(): void { + const serializers = this.usedDateTimeSerializers(); + if (serializers.length === 0) return; + + this.emitLine( + "@file:UseSerializers(", + serializers.map((s) => `${s.name}::class`).join(", "), + ")", + ); + this.ensureBlankLine(); + } + protected emitHeader(): void { super.emitHeader(); @@ -87,6 +139,21 @@ export class KotlinXRenderer extends KotlinRenderer { this.emitLine("import kotlinx.serialization.encoding.*"); } + protected emitSourceStructure(): void { + super.emitSourceStructure(); + + for (const serializer of this.usedDateTimeSerializers()) { + this.ensureBlankLine(); + this.emitMultiline(`object ${serializer.name} : KSerializer<${serializer.type}> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("${serializer.type}", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): ${serializer.type} = ${serializer.type}.parse(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: ${serializer.type}) { + encoder.encodeString(java.time.format.DateTimeFormatter.${serializer.formatter}.format(value)) + } +}`); + } + } + protected emitClassAnnotations(_c: Type, _className: Name): void { this.emitLine("@Serializable"); } diff --git a/packages/quicktype-core/src/language/Kotlin/language.ts b/packages/quicktype-core/src/language/Kotlin/language.ts index f806d423f..28adca237 100644 --- a/packages/quicktype-core/src/language/Kotlin/language.ts +++ b/packages/quicktype-core/src/language/Kotlin/language.ts @@ -1,4 +1,5 @@ import type { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; +import type { DateTimeRecognizer } from "../../DateTime.js"; import type { RenderContext } from "../../Renderer.js"; import { BooleanOption, @@ -9,12 +10,18 @@ import { import { AcronymStyleOptions, acronymOption } from "../../support/Acronyms.js"; import { assertNever } from "../../support/Support.js"; import { TargetLanguage } from "../../TargetLanguage.js"; +import type { + PrimitiveStringTypeKind, + TransformedStringTypeKind, +} from "../../Type/index.js"; +import type { StringTypeMapping } from "../../Type/TypeBuilderUtils.js"; import type { LanguageName, RendererOptions } from "../../types.js"; import { KotlinJacksonRenderer } from "./KotlinJacksonRenderer.js"; import { KotlinKlaxonRenderer } from "./KotlinKlaxonRenderer.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; import { KotlinXRenderer } from "./KotlinXRenderer.js"; +import { KotlinDateTimeRecognizer } from "./utils.js"; export const kotlinOptions = { justTypes: new BooleanOption("just-types", "Plain types only", false), @@ -57,6 +64,21 @@ export class KotlinTargetLanguage extends TargetLanguage< return true; } + public get stringTypeMapping(): StringTypeMapping { + const mapping: Map = + new Map(); + mapping.set("date", "date"); + mapping.set("time", "time"); + mapping.set("date-time", "date-time"); + return mapping; + } + + // Only infer date/time types from JSON strings that java.time's ISO + // formatters round-trip byte-identically; see KotlinDateTimeRecognizer. + public get dateTimeRecognizer(): DateTimeRecognizer { + return new KotlinDateTimeRecognizer(); + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, diff --git a/packages/quicktype-core/src/language/Kotlin/utils.ts b/packages/quicktype-core/src/language/Kotlin/utils.ts index 0b667cfbe..6c20ebd47 100644 --- a/packages/quicktype-core/src/language/Kotlin/utils.ts +++ b/packages/quicktype-core/src/language/Kotlin/utils.ts @@ -1,3 +1,6 @@ +import { DefaultDateTimeRecognizer } from "../../DateTime.js"; +import type { Type } from "../../Type/index.js"; + import { allLowerWordStyle, allUpperWordStyle, @@ -55,3 +58,85 @@ export function stringEscape(s: string): string { // "$this" is a template string in Kotlin so we have to escape $ return _stringEscape(s).replace(/\$/g, "\\$"); } + +// The generated code round-trips date/time values through java.time's +// ISO_LOCAL_DATE / ISO_OFFSET_TIME / ISO_OFFSET_DATE_TIME, so we only +// recognize strings those formatters reproduce byte-identically (all +// verified against java.time): +// +// - Fractional seconds must not end in "0" — java.time formats the +// shortest fraction (".500" becomes ".5", ".000" disappears) — and +// have at most 9 digits, java.time's nanosecond precision. +// - The UTC offset must be "Z" or a nonzero "±hh:mm": zero offsets +// format back as "Z", and java.time rejects offsets beyond ±18:00. +// - Lowercase "t"/"z" format back in uppercase. +// - February 29 only exists in leap years; java.time refuses to parse +// it in other years (the default recognizer always allows it). +// +// Anything rejected here simply stays a plain string. This only affects +// inference from JSON samples — JSON Schema's "format": "date-time" is +// mapped regardless. +const KOTLIN_TIME = /^(\d\d):(\d\d):(\d\d)(?:\.(\d{1,9}))?(Z|[+-]\d\d:\d\d)$/; + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +export class KotlinDateTimeRecognizer extends DefaultDateTimeRecognizer { + public isDate(str: string): boolean { + if (!super.isDate(str)) return false; + + const [year, month, day] = str.split("-").map(Number); + return month !== 2 || day !== 29 || isLeapYear(year); + } + + public isTime(str: string): boolean { + const matches = KOTLIN_TIME.exec(str); + if (matches === null) return false; + + const hour = +matches[1]; + const minute = +matches[2]; + const second = +matches[3]; + if (hour > 23 || minute > 59 || second > 59) return false; + + const fraction = matches[4]; + if (fraction?.endsWith("0")) return false; + + const offset = matches[5]; + if (offset === "Z") return true; + + const offsetHour = +offset.slice(1, 3); + const offsetMinute = +offset.slice(4, 6); + if (offsetHour === 0 && offsetMinute === 0) return false; + return offsetMinute <= 59 && offsetHour * 60 + offsetMinute <= 18 * 60; + } + + public isDateTime(str: string): boolean { + const dateTime = str.split("T"); + return ( + dateTime.length === 2 && + this.isDate(dateTime[0]) && + this.isTime(dateTime[1]) + ); + } +} + +// When several union members are guarded by the same JSON node/value type +// (e.g. date, time and enum values are all strings), parse attempts must be +// ordered strictest first: transformed string types (strict ISO parsers), +// then enums (known values only), then plain strings (accept anything). +export function unionMemberMatchPriority(t: Type): number { + if (t.kind === "date-time" || t.kind === "date" || t.kind === "time") { + return 0; + } + + if (t.kind === "enum") { + return 1; + } + + if (t.kind === "string") { + return 2; + } + + return 3; +} diff --git a/test/languages.ts b/test/languages.ts index d1a710735..94f9828e3 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1119,7 +1119,7 @@ export const KotlinLanguage: Language = { "76ae1.json", ], allowMissingNull: true, - features: ["enum", "union", "no-defaults"], + features: ["enum", "union", "no-defaults", "date-time"], output: "TopLevel.kt", topLevel: "TopLevel", skipJSON: [ @@ -1209,7 +1209,7 @@ export const KotlinJacksonLanguage: Language = { "76ae1.json", ], allowMissingNull: true, - features: ["enum", "union", "no-defaults"], + features: ["enum", "union", "no-defaults", "date-time"], output: "TopLevel.kt", topLevel: "TopLevel", skipJSON: [ @@ -1301,7 +1301,11 @@ export const KotlinXLanguage: Language = { // No "union": the kotlinx renderer emits unions as sealed classes // without any serializer wiring, so they don't (de)serialize // (documented TODO in KotlinXRenderer.ts). - features: ["enum", "no-defaults"], + // "date-time" is supported via emitted KSerializers, but note that + // date-time.schema itself stays skipped: its union-array properties + // hit the union limitation above. The serializers are exercised by + // the JSON inputs with inferred date-times instead. + features: ["enum", "no-defaults", "date-time"], output: "TopLevel.kt", topLevel: "TopLevel", skipJSON: [ @@ -1386,6 +1390,9 @@ export const KotlinXLanguage: Language = { "class-map-union.schema", "class-with-additional.schema", "date-time.schema", + // The string|date-time property becomes a union once Kotlin maps + // date-time (it was a plain string before). + "date-time-or-string.schema", "description.schema", "direct-union.schema", "enum.schema", // enum.3.json contains an int|string union diff --git a/test/unit/date-time-recognizer.test.ts b/test/unit/date-time-recognizer.test.ts index dee7e5396..5ee1e52b3 100644 --- a/test/unit/date-time-recognizer.test.ts +++ b/test/unit/date-time-recognizer.test.ts @@ -6,6 +6,7 @@ // 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 { KotlinDateTimeRecognizer } from "quicktype-core/dist/language/Kotlin/utils.js"; import { describe, expect, test } from "vitest"; const recognizer = new DefaultDateTimeRecognizer(); @@ -83,3 +84,70 @@ describe("isTime", () => { expect(recognizer.isTime("02:45:60Z")).toBe(false); }); }); + +// The Kotlin recognizer additionally requires that java.time's ISO +// formatters (which the generated Kotlin code round-trips through) +// reproduce the string byte-identically. All rules below were verified +// against java.time's actual parse/format behavior. +describe("KotlinDateTimeRecognizer", () => { + const kotlin = new KotlinDateTimeRecognizer(); + + test("accepts date-times java.time round-trips identically", () => { + expect(kotlin.isDateTime("2018-08-14T02:45:50Z")).toBe(true); + expect(kotlin.isDateTime("2010-12-01T00:00:00Z")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50+05:30")).toBe(true); + expect(kotlin.isDateTime("2015-04-24T01:46:50.342496Z")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50.5Z")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50.123456789Z")).toBe(true); + expect(kotlin.isDateTime("2016-02-29T00:00:00Z")).toBe(true); + }); + + test("rejects fractional seconds with trailing zeros", () => { + // java.time formats the shortest fraction: ".000" disappears, + // ".500" becomes ".5", ".828000" becomes ".828". + expect(kotlin.isDateTime("2010-01-12T00:00:00.000Z")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50.50Z")).toBe(false); + expect(kotlin.isDateTime("2008-09-10T13:21:30.828000Z")).toBe(false); + expect(kotlin.isDateTime("2015-04-24T02:04:02.997570Z")).toBe(false); + }); + + test("rejects fractions beyond nanosecond precision", () => { + // java.time refuses to parse more than 9 fractional digits. + expect(kotlin.isDateTime("2018-08-14T02:45:50.1234567891Z")).toBe( + false, + ); + }); + + test("rejects zero offsets, which format back as Z", () => { + expect(kotlin.isDateTime("2018-08-14T02:45:50+00:00")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50-00:00")).toBe(false); + expect(kotlin.isTime("10:30:00+00:00")).toBe(false); + }); + + test("rejects offsets outside java.time's ±18:00 range", () => { + expect(kotlin.isDateTime("2018-08-14T02:45:50+18:00")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50-18:00")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50+18:01")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50+19:00")).toBe(false); + }); + + test("rejects lowercase t and z, which format back in uppercase", () => { + expect(kotlin.isDateTime("2018-08-14t02:45:50Z")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50z")).toBe(false); + expect(kotlin.isTime("02:45:50z")).toBe(false); + }); + + test("rejects Feb 29 outside leap years, which java.time cannot parse", () => { + expect(kotlin.isDate("2016-02-29")).toBe(true); + expect(kotlin.isDate("2000-02-29")).toBe(true); + expect(kotlin.isDate("2015-02-29")).toBe(false); + expect(kotlin.isDate("1900-02-29")).toBe(false); + expect(kotlin.isDateTime("2015-02-29T00:00:00Z")).toBe(false); + }); + + test("accepts times java.time round-trips identically", () => { + expect(kotlin.isTime("02:45:50Z")).toBe(true); + expect(kotlin.isTime("23:20:50.52Z")).toBe(true); + expect(kotlin.isTime("23:20:50.520Z")).toBe(false); + }); +}); diff --git a/test/unit/kotlinx-datetime-serializers.test.ts b/test/unit/kotlinx-datetime-serializers.test.ts new file mode 100644 index 000000000..083cfc7eb --- /dev/null +++ b/test/unit/kotlinx-datetime-serializers.test.ts @@ -0,0 +1,85 @@ +// Kotlin's language-wide stringTypeMapping maps JSON Schema "date", +// "time", and "date-time" formats to java.time types for all frameworks. +// kotlinx.serialization has no built-in serializers for java.time, so the +// kotlinx renderer must emit custom KSerializer objects and register them +// with a `@file:UseSerializers(...)` annotation — otherwise the generated +// code doesn't compile ("Serializer has not been found for type +// 'OffsetDateTime'"). There is no kotlinx fixture in CI, so this unit test +// covers it. + +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +async function kotlinxOutput( + properties: Record, +): Promise { + const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-06/schema#", + type: "object", + properties, + required: Object.keys(properties), + }); + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: "TopLevel", schema }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "kotlin", + rendererOptions: { framework: "kotlinx" }, + }); + return result.lines.join("\n"); +} + +describe("kotlinx date/time serializers", () => { + test("emits KSerializers and @file:UseSerializers for date/time types", async () => { + const output = await kotlinxOutput({ + date: { type: "string", format: "date" }, + time: { type: "string", format: "time" }, + dateTime: { type: "string", format: "date-time" }, + }); + + expect(output).toContain( + "@file:UseSerializers(OffsetDateTimeSerializer::class, LocalDateSerializer::class, OffsetTimeSerializer::class)", + ); + for (const [serializer, javaType] of [ + ["OffsetDateTimeSerializer", "OffsetDateTime"], + ["LocalDateSerializer", "LocalDate"], + ["OffsetTimeSerializer", "OffsetTime"], + ]) { + expect(output).toContain( + `object ${serializer} : KSerializer<${javaType}>`, + ); + expect(output).toContain(`import java.time.${javaType}`); + } + + // The file annotation must precede the package declaration. + expect(output.indexOf("@file:UseSerializers")).toBeLessThan( + output.indexOf("package "), + ); + }); + + test("emits only the serializers that are used", async () => { + const output = await kotlinxOutput({ + date: { type: "string", format: "date" }, + }); + + expect(output).toContain( + "@file:UseSerializers(LocalDateSerializer::class)", + ); + expect(output).toContain("object LocalDateSerializer"); + expect(output).not.toContain("OffsetDateTimeSerializer"); + expect(output).not.toContain("OffsetTimeSerializer"); + }); + + test("emits no serializer machinery without date/time types", async () => { + const output = await kotlinxOutput({ + name: { type: "string" }, + }); + + expect(output).not.toContain("@file:UseSerializers"); + expect(output).not.toContain("KSerializer"); + expect(output).not.toContain("java.time"); + }); +});