diff --git a/packages/quicktype-core/src/Run.ts b/packages/quicktype-core/src/Run.ts index 898640aff..596d1f8fc 100644 --- a/packages/quicktype-core/src/Run.ts +++ b/packages/quicktype-core/src/Run.ts @@ -266,6 +266,7 @@ class Run implements RunContext { this._options.inferMaps, this._options.inferEnums, this._options.fixedTopLevels, + this._options.combineClasses, ), ); @@ -282,6 +283,7 @@ class Run implements RunContext { this._options.inferMaps, this._options.inferEnums, this._options.fixedTopLevels, + this._options.combineClasses, ), ); diff --git a/packages/quicktype-core/src/input/Inference.ts b/packages/quicktype-core/src/input/Inference.ts index 4efca3c4f..f189b3a9e 100644 --- a/packages/quicktype-core/src/input/Inference.ts +++ b/packages/quicktype-core/src/input/Inference.ts @@ -3,6 +3,7 @@ import { inferTransformedStringTypeKindForString, } from "../attributes/StringTypes.js"; import { + TypeAttributeKind, type TypeAttributes, emptyTypeAttributes, } from "../attributes/TypeAttributes.js"; @@ -110,6 +111,27 @@ function canBeEnumCase(_s: string): boolean { return true; } +class UniqueInferenceClassTypeAttributeKind extends TypeAttributeKind { + public constructor() { + super("uniqueInferenceClass"); + } + + public combine(_attrs: true[]): true { + return true; + } + + public makeInferred(_attr: true): true { + return true; + } + + public requiresUniqueIdentity(_attr: true): boolean { + return true; + } +} + +const uniqueInferenceClassTypeAttributeKind = + new UniqueInferenceClassTypeAttributeKind(); + export type Accumulator = UnionAccumulator; export class TypeInference { @@ -120,6 +142,7 @@ export class TypeInference { private readonly _typeBuilder: TypeBuilder, private readonly _inferMaps: boolean, private readonly _inferEnums: boolean, + private readonly _combineClasses: boolean, ) {} private addValuesToAccumulator( @@ -435,8 +458,22 @@ export class TypeInference { ); } + if (this._combineClasses) { + return this._typeBuilder.getClassType( + typeAttributes, + properties, + forwardingRef, + ); + } + + // Graph rewrites otherwise deduplicate non-fixed classes again. + const uniqueAttributes = + uniqueInferenceClassTypeAttributeKind.setDefaultInAttributes( + typeAttributes, + () => true, + ); return this._typeBuilder.getClassType( - typeAttributes, + uniqueAttributes, properties, forwardingRef, ); diff --git a/packages/quicktype-core/src/input/Inputs.ts b/packages/quicktype-core/src/input/Inputs.ts index 65c31448d..445819487 100644 --- a/packages/quicktype-core/src/input/Inputs.ts +++ b/packages/quicktype-core/src/input/Inputs.ts @@ -33,6 +33,7 @@ export interface Input { inferMaps: boolean, inferEnums: boolean, fixedTopLevels: boolean, + combineClasses: boolean, ) => Promise; addTypesSync: ( @@ -41,6 +42,7 @@ export interface Input { inferMaps: boolean, inferEnums: boolean, fixedTopLevels: boolean, + combineClasses: boolean, ) => void; readonly kind: string; @@ -161,6 +163,7 @@ export class JSONInput implements Input> { inferMaps: boolean, inferEnums: boolean, fixedTopLevels: boolean, + combineClasses: boolean, ): Promise { this.addTypesSync( ctx, @@ -168,6 +171,7 @@ export class JSONInput implements Input> { inferMaps, inferEnums, fixedTopLevels, + combineClasses, ); } @@ -177,12 +181,14 @@ export class JSONInput implements Input> { inferMaps: boolean, inferEnums: boolean, fixedTopLevels: boolean, + combineClasses: boolean, ): void { const inference = new TypeInference( this._compressedJSON, typeBuilder, inferMaps, inferEnums, + combineClasses, ); for (const [name, { samples, description }] of this._topLevels) { @@ -269,6 +275,7 @@ export class InputData { inferMaps: boolean, inferEnums: boolean, fixedTopLevels: boolean, + combineClasses: boolean, ): Promise { for (const input of this._inputs) { await input.addTypes( @@ -277,6 +284,7 @@ export class InputData { inferMaps, inferEnums, fixedTopLevels, + combineClasses, ); } } @@ -287,6 +295,7 @@ export class InputData { inferMaps: boolean, inferEnums: boolean, fixedTopLevels: boolean, + combineClasses: boolean, ): void { for (const input of this._inputs) { input.addTypesSync( @@ -295,6 +304,7 @@ export class InputData { inferMaps, inferEnums, fixedTopLevels, + combineClasses, ); } } diff --git a/test/fixtures.ts b/test/fixtures.ts index 84616537e..e8f25b1e5 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -558,6 +558,19 @@ class JSONFixture extends LanguageFixture { } } +class NoCombineClassesJSONFixture extends JSONFixture { + constructor() { + super( + languages.CSharpNoCombineClassesLanguage, + "csharp-no-combine-classes", + ); + } + + runForName(name: string): boolean { + return name === "csharp" || super.runForName(name); + } +} + // This fixture tests generating code for language X from JSON, // then generating code for Y from the code for X, making sure // that the resulting code for Y accepts the JSON by running it @@ -1737,6 +1750,7 @@ class CommandSuccessfulLanguageFixture extends LanguageFixture { export const allFixtures: Fixture[] = [ // new JSONFixture(languages.CrystalLanguage), new JSONFixture(languages.CSharpLanguage), + new NoCombineClassesJSONFixture(), new JSONFixture(languages.CSharpLanguageRecords, "csharp-records"), new JSONFixture( languages.CSharpLanguageSystemTextJson, diff --git a/test/languages.ts b/test/languages.ts index 120f3170b..21c2c2081 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -99,6 +99,7 @@ export interface Language { skipMiscJSON: boolean; skipSchema: string[]; rendererOptions: RendererOptions; + combineClasses?: boolean; quickTestRendererOptions: (RendererOptions | [string, RendererOptions])[]; sourceFiles?: string[]; } @@ -172,6 +173,16 @@ export const CSharpLanguage: Language = { sourceFiles: ["src/language/CSharp/index.ts"], }; +export const CSharpNoCombineClassesLanguage: Language = { + ...CSharpLanguage, + diffViaSchema: false, + includeJSON: ["combined-enum.json"], + skipJSON: undefined, + skipMiscJSON: true, + combineClasses: false, + quickTestRendererOptions: [], +}; + export const CSharpLanguageRecords: Language = { ...CSharpLanguage, rendererOptions: { diff --git a/test/unit/combine-classes.test.ts b/test/unit/combine-classes.test.ts new file mode 100644 index 000000000..d4ba33b9b --- /dev/null +++ b/test/unit/combine-classes.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "vitest"; + +import { + type Input, + InputData, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; + +test("does not merge structurally identical inferred classes when combineClasses is false", async () => { + const jsonInput = jsonInputForTargetLanguage("cs"); + await jsonInput.addSource({ + name: "Trade", + // Keep both values non-integral so their object shapes are identical. + samples: [ + '{"amount":{"initialValue":1.5},"rate":{"initialValue":0.012}}', + ], + }); + + const inputData = new InputData(); + inputData.addInput(jsonInput); + + const result = await quicktype({ + inputData, + lang: "cs", + combineClasses: false, + }); + const output = result.lines.join("\n"); + + expect(output).toContain("public partial class Amount"); + expect(output).toContain("public partial class Rate"); +}); + +test("preserves the fixedTopLevels argument position for custom inputs", async () => { + let receivedFixedTopLevels: boolean | undefined; + const input: Input = { + kind: "custom", + needIR: false, + needSchemaProcessing: false, + addSource: async () => {}, + addSourceSync: () => {}, + addTypes: async ( + _ctx, + typeBuilder, + _inferMaps, + _inferEnums, + fixedTopLevels, + ) => { + receivedFixedTopLevels = fixedTopLevels; + typeBuilder.addTopLevel( + "Value", + typeBuilder.getPrimitiveType("integer"), + ); + }, + addTypesSync: () => {}, + singleStringSchemaSource: () => undefined, + }; + const inputData = new InputData(); + inputData.addInput(input); + + await quicktype({ + inputData, + lang: "typescript", + combineClasses: false, + fixedTopLevels: true, + }); + + expect(receivedFixedTopLevels).toBe(true); +}); diff --git a/test/utils.ts b/test/utils.ts index 3a6d1f4bc..8fb5693d1 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -162,6 +162,10 @@ export async function quicktypeForLanguage( // GraphQL input can leave unreachable types in the graph, which means // their provenance won't be propagated. It does that for non-nullables. debug: graphqlSchema === undefined ? "provenance" : undefined, + noCombineClasses: + language.combineClasses === undefined + ? undefined + : !language.combineClasses, }); } catch (e) { failWith("quicktype threw an exception", {