Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/quicktype-core/src/Run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ class Run implements RunContext {
this._options.inferMaps,
this._options.inferEnums,
this._options.fixedTopLevels,
this._options.combineClasses,
),
);

Expand All @@ -282,6 +283,7 @@ class Run implements RunContext {
this._options.inferMaps,
this._options.inferEnums,
this._options.fixedTopLevels,
this._options.combineClasses,
),
);

Expand Down
39 changes: 38 additions & 1 deletion packages/quicktype-core/src/input/Inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
inferTransformedStringTypeKindForString,
} from "../attributes/StringTypes.js";
import {
TypeAttributeKind,
type TypeAttributes,
emptyTypeAttributes,
} from "../attributes/TypeAttributes.js";
Expand Down Expand Up @@ -110,6 +111,27 @@ function canBeEnumCase(_s: string): boolean {
return true;
}

class UniqueInferenceClassTypeAttributeKind extends TypeAttributeKind<true> {
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<NestedValueArray, NestedValueArray>;

export class TypeInference {
Expand All @@ -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(
Expand Down Expand Up @@ -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,
);
Expand Down
10 changes: 10 additions & 0 deletions packages/quicktype-core/src/input/Inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface Input<T> {
inferMaps: boolean,
inferEnums: boolean,
fixedTopLevels: boolean,
combineClasses: boolean,
) => Promise<void>;

addTypesSync: (
Expand All @@ -41,6 +42,7 @@ export interface Input<T> {
inferMaps: boolean,
inferEnums: boolean,
fixedTopLevels: boolean,
combineClasses: boolean,
) => void;
readonly kind: string;

Expand Down Expand Up @@ -161,13 +163,15 @@ export class JSONInput<T> implements Input<JSONSourceData<T>> {
inferMaps: boolean,
inferEnums: boolean,
fixedTopLevels: boolean,
combineClasses: boolean,
): Promise<void> {
this.addTypesSync(
ctx,
typeBuilder,
inferMaps,
inferEnums,
fixedTopLevels,
combineClasses,
);
}

Expand All @@ -177,12 +181,14 @@ export class JSONInput<T> implements Input<JSONSourceData<T>> {
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) {
Expand Down Expand Up @@ -269,6 +275,7 @@ export class InputData {
inferMaps: boolean,
inferEnums: boolean,
fixedTopLevels: boolean,
combineClasses: boolean,
): Promise<void> {
for (const input of this._inputs) {
await input.addTypes(
Expand All @@ -277,6 +284,7 @@ export class InputData {
inferMaps,
inferEnums,
fixedTopLevels,
combineClasses,
);
}
}
Expand All @@ -287,6 +295,7 @@ export class InputData {
inferMaps: boolean,
inferEnums: boolean,
fixedTopLevels: boolean,
combineClasses: boolean,
): void {
for (const input of this._inputs) {
input.addTypesSync(
Expand All @@ -295,6 +304,7 @@ export class InputData {
inferMaps,
inferEnums,
fixedTopLevels,
combineClasses,
);
}
}
Expand Down
14 changes: 14 additions & 0 deletions test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions test/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export interface Language {
skipMiscJSON: boolean;
skipSchema: string[];
rendererOptions: RendererOptions;
combineClasses?: boolean;
quickTestRendererOptions: (RendererOptions | [string, RendererOptions])[];
sourceFiles?: string[];
}
Expand Down Expand Up @@ -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: {
Expand Down
69 changes: 69 additions & 0 deletions test/unit/combine-classes.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> = {
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);
});
4 changes: 4 additions & 0 deletions test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
Loading