From b66dd02b656e78f67f97d364b25464378476a91b Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 12:23:11 -0400 Subject: [PATCH] feat(csharp): option to suppress DateOnly/TimeOnly converters (System.Text.Json) The System.Text.Json C# renderer unconditionally emits DateOnlyConverter and TimeOnlyConverter helper classes and registers them in Converter.Settings. DateOnly and TimeOnly only exist on .NET 6+, so the generated code does not compile on .NET Standard or older targets (issue #2629). Add a System.Text.Json-only boolean option, --[no-]dateonly-timeonly-converters (on by default), that suppresses both the converter classes and their registration. Nothing else in the generated code references them, so suppressed output still compiles and round-trips; default output is byte-for-byte identical. The option lives in systemTextJsonCSharpOptions rather than the shared cSharpOptions, so only the System.Text.Json renderer's typed options include it; CSharpTargetLanguage.getOptions() now returns the System.Text.Json superset so the CLI accepts the flag. Covered by a pinned csharp-SystemTextJson quick-test (unions.json with the converters suppressed must compile and round-trip) and a unit test asserting the converter classes' presence by default and absence under the flag. Reimplementation of #2842 by youcefnb (findyoucef); supersedes it. Fixes #2629. Co-authored-by: youcefnb Co-Authored-By: Claude Fable 5 --- .../CSharp/SystemTextJsonCSharpRenderer.ts | 26 ++++++-- .../src/language/CSharp/language.ts | 14 ++++- test/languages.ts | 3 + ...sharp-dateonly-timeonly-converters.test.ts | 59 +++++++++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 test/unit/csharp-dateonly-timeonly-converters.test.ts diff --git a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts index f3eccc8df4..230077e5b0 100644 --- a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts @@ -512,8 +512,11 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { } } - this.emitLine("new DateOnlyConverter(),"); - this.emitLine("new TimeOnlyConverter(),"); + if (this._options.dateTimeOnlyConverters) { + this.emitLine("new DateOnlyConverter(),"); + this.emitLine("new TimeOnlyConverter(),"); + } + this.emitLine("IsoDateTimeOffsetConverter.Singleton"); // this.emitLine("new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }"); }); @@ -1305,7 +1308,16 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { this.forEachTransformation("leading-and-interposing", (n, t) => this.emitTransformation(n, t), ); - this.emitMultiline(` + if (this._options.dateTimeOnlyConverters) { + this.emitDateTimeOnlyConverters(); + } + + this.emitIsoDateTimeOffsetConverter(); + } + } + + private emitDateTimeOnlyConverters(): void { + this.emitMultiline(` public class DateOnlyConverter : JsonConverter { private readonly string serializationFormat; @@ -1345,9 +1357,12 @@ public class TimeOnlyConverter : JsonConverter public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options) => writer.WriteStringValue(value.ToString(serializationFormat)); -} +}`); + } -internal class IsoDateTimeOffsetConverter : JsonConverter + private emitIsoDateTimeOffsetConverter(): void { + this.ensureBlankLine(); + this.emitMultiline(`internal class IsoDateTimeOffsetConverter : JsonConverter { public override bool CanConvert(Type t) => t == typeof(DateTimeOffset); @@ -1415,7 +1430,6 @@ internal class IsoDateTimeOffsetConverter : JsonConverter public static readonly IsoDateTimeOffsetConverter Singleton = new IsoDateTimeOffsetConverter(); }`); - } } protected needNamespace(): boolean { diff --git a/packages/quicktype-core/src/language/CSharp/language.ts b/packages/quicktype-core/src/language/CSharp/language.ts index 5501853064..a894415acb 100644 --- a/packages/quicktype-core/src/language/CSharp/language.ts +++ b/packages/quicktype-core/src/language/CSharp/language.ts @@ -133,7 +133,15 @@ export const cSharpOptions = { export const newtonsoftCSharpOptions = { ...cSharpOptions }; -export const systemTextJsonCSharpOptions = { ...cSharpOptions }; +export const systemTextJsonCSharpOptions = { + ...cSharpOptions, + dateTimeOnlyConverters: new BooleanOption( + "dateonly-timeonly-converters", + "Emit DateOnly/TimeOnly converters (requires .NET 6 or later)", + true, + "secondary", + ), +}; export const cSharpLanguageConfig = { displayName: "C#", @@ -148,8 +156,8 @@ export class CSharpTargetLanguage extends TargetLanguage< super(cSharpLanguageConfig); } - public getOptions(): typeof cSharpOptions { - return cSharpOptions; + public getOptions(): typeof systemTextJsonCSharpOptions { + return systemTextJsonCSharpOptions; } public get stringTypeMapping(): StringTypeMapping { diff --git a/test/languages.ts b/test/languages.ts index 6bcfd43611..291fd6ec38 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -180,6 +180,9 @@ export const CSharpLanguageSystemTextJson: Language = { { density: "dense" }, { "number-type": "decimal" }, { "any-type": "dynamic" }, + // Suppressing the DateOnly/TimeOnly converters (for pre-.NET 6 + // targets) must still produce compiling, round-tripping code. + ["unions.json", { "dateonly-timeonly-converters": "false" }], ], sourceFiles: ["src/language/CSharp/index.ts"], }; diff --git a/test/unit/csharp-dateonly-timeonly-converters.test.ts b/test/unit/csharp-dateonly-timeonly-converters.test.ts new file mode 100644 index 0000000000..8824348605 --- /dev/null +++ b/test/unit/csharp-dateonly-timeonly-converters.test.ts @@ -0,0 +1,59 @@ +import { + InputData, + JSONSchemaInput, + type RendererOptions, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function renderCSharp( + rendererOptions: RendererOptions = {}, +): Promise { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "integer" }, + }, + required: ["name", "count"], + }; + + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify(schema), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "csharp", + rendererOptions: { framework: "SystemTextJson", ...rendererOptions }, + }); + return result.lines.join("\n"); +} + +describe("C# System.Text.Json DateOnly/TimeOnly converters", () => { + test("emits the converters by default", async () => { + const output = await renderCSharp(); + + expect(output).toContain("class DateOnlyConverter"); + expect(output).toContain("class TimeOnlyConverter"); + expect(output).toContain("new DateOnlyConverter(),"); + expect(output).toContain("new TimeOnlyConverter(),"); + }); + + test("omits the converters with dateonly-timeonly-converters=false", async () => { + const output = await renderCSharp({ + "dateonly-timeonly-converters": "false", + }); + + expect(output).not.toContain("DateOnlyConverter"); + expect(output).not.toContain("TimeOnlyConverter"); + // The DateTimeOffset converter is unaffected. + expect(output).toContain("class IsoDateTimeOffsetConverter"); + expect(output).toContain("IsoDateTimeOffsetConverter.Singleton"); + }); +});