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/ConvenienceRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,7 @@ export abstract class ConvenienceRenderer extends Renderer {
}

protected sortClassProperties(
_o: ObjectType,
properties: ReadonlyMap<string, ClassProperty>,
propertyNames: ReadonlyMap<string, Name>,
): ReadonlyMap<string, ClassProperty> {
Expand Down Expand Up @@ -947,6 +948,7 @@ export abstract class ConvenienceRenderer extends Renderer {
): void {
const propertyNames = defined(this._propertyNamesStoreView).get(o);
const sortedProperties = this.sortClassProperties(
o,
o.getProperties(),
propertyNames,
);
Expand Down
85 changes: 85 additions & 0 deletions packages/quicktype-core/src/attributes/DefaultValues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { mapFromObject } from "collection-utils";

import type {
JSONSchemaAttributes,
JSONSchemaType,
Ref,
} from "../input/JSONSchemaInput.js";
import type { JSONSchema } from "../input/JSONSchemaStore.js";

import { TypeAttributeKind, emptyTypeAttributes } from "./TypeAttributes.js";

export type PropertyDefaultValues = ReadonlyMap<string, unknown>;

class PropertyDefaultValuesTypeAttributeKind extends TypeAttributeKind<PropertyDefaultValues> {
public constructor() {
super("propertyDefaultValues");
}

public get inIdentity(): boolean {
return true;
}

public requiresUniqueIdentity(_: PropertyDefaultValues): boolean {
return true;
}

public combine(
attrs: PropertyDefaultValues[],
): PropertyDefaultValues | undefined {
const result = new Map<string, unknown>();
for (const attr of attrs) {
for (const [name, value] of attr) {
if (!result.has(name)) result.set(name, value);
}
}

return result.size === 0 ? undefined : result;
}

public makeInferred(_: PropertyDefaultValues): undefined {
return undefined;
}
}

export const propertyDefaultValuesTypeAttributeKind: TypeAttributeKind<PropertyDefaultValues> =
new PropertyDefaultValuesTypeAttributeKind();

export function defaultValuesAttributeProducer(
schema: JSONSchema,
_ref: Ref,
types: Set<JSONSchemaType>,
): JSONSchemaAttributes | undefined {
if (
typeof schema !== "object" ||
!types.has("object") ||
typeof schema.properties !== "object" ||
schema.properties === null ||
Array.isArray(schema.properties)
) {
return undefined;
}

const defaults = new Map<string, unknown>();
for (const [name, propertySchema] of mapFromObject(schema.properties)) {
if (
typeof propertySchema === "object" &&
propertySchema !== null &&
!Array.isArray(propertySchema) &&
// biome-ignore lint/suspicious/noPrototypeBuiltins: Object.hasOwn is not in quicktype-core's es6 lib
Object.prototype.hasOwnProperty.call(propertySchema, "default")
) {
defaults.set(
name,
(propertySchema as { default: unknown }).default,
);
}
}

if (defaults.size === 0) return undefined;
return {
forType: emptyTypeAttributes,
forObject:
propertyDefaultValuesTypeAttributeKind.makeAttributes(defaults),
};
}
2 changes: 2 additions & 0 deletions packages/quicktype-core/src/input/JSONSchemaInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
patternAttributeProducer,
} from "../attributes/Constraints.js";
import { defaultValueAttributeProducer } from "../attributes/DefaultValue.js";
import { defaultValuesAttributeProducer } from "../attributes/DefaultValues.js";
import { descriptionAttributeProducer } from "../attributes/Description.js";
import { enumValuesAttributeProducer } from "../attributes/EnumValues.js";
import { StringTypes } from "../attributes/StringTypes.js";
Expand Down Expand Up @@ -1568,6 +1569,7 @@ export class JSONSchemaInput implements Input<JSONSchemaSourceData> {
) {
this._attributeProducers = [
descriptionAttributeProducer,
defaultValuesAttributeProducer,
accessorNamesAttributeProducer,
defaultValueAttributeProducer,
enumValuesAttributeProducer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1090,8 +1090,21 @@ export class JSONPythonRenderer extends PythonRenderer {
const args: Sourcelike[] = [];
this.emitLine("assert isinstance(obj, dict)");
this.forEachClassProperty(t, "none", (name, jsonName, cp) => {
const defaultValue = this.defaultValueForClassProperty(
t,
jsonName,
);
const property = {
value: ["obj.get(", this.string(jsonName), ")"],
value:
defaultValue === undefined
? ["obj.get(", this.string(jsonName), ")"]
: [
"obj.get(",
this.string(jsonName),
", ",
this.pythonLiteral(defaultValue),
")",
],
};
this.emitLine(
name,
Expand Down
122 changes: 113 additions & 9 deletions packages/quicktype-core/src/language/Python/PythonRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
setUnionInto,
} from "collection-utils";

import { propertyDefaultValuesTypeAttributeKind } from "../../attributes/DefaultValues.js";
import {
ConvenienceRenderer,
type ForbiddenWordsInfo,
Expand All @@ -16,7 +17,7 @@ import type { RenderContext } from "../../Renderer.js";
import type { OptionValues } from "../../RendererOptions/index.js";
import type { Sourcelike } from "../../Source.js";
import { stringEscape } from "../../support/Strings.js";
import { defined, panic } from "../../support/Support.js";
import { defined, isStringMap, panic } from "../../support/Support.js";
import type { TargetLanguage } from "../../TargetLanguage.js";
import { followTargetType } from "../../Transformers.js";
import {
Expand All @@ -25,6 +26,7 @@ import {
ClassType,
EnumType,
MapType,
type ObjectType,
type Type,
UnionType,
} from "../../Type/index.js";
Expand Down Expand Up @@ -120,6 +122,83 @@ export class PythonRenderer extends ConvenienceRenderer {
return [openQuote, stringEscape(s), '"'];
}

protected pythonLiteral(value: unknown): Sourcelike {
if (value === null) return "None";

switch (typeof value) {
case "string":
return this.string(value);
case "number":
return value.toString();
case "boolean":
return value ? "True" : "False";
case "object":
if (Array.isArray(value)) {
return [
"[",
arrayIntercalate(
", ",
value.map((item) => this.pythonLiteral(item)),
),
"]",
];
}

if (isStringMap(value)) {
return [
"{",
arrayIntercalate(
", ",
Object.entries(value).map<Sourcelike>(
([key, item]) => [
this.string(key),
": ",
this.pythonLiteral(item),
],
),
),
"}",
];
}
}

return panic("JSON Schema default is not a JSON value");
}

protected defaultValueForClassProperty(
o: ObjectType,
jsonName: string,
): unknown | undefined {
return this.typeGraph.attributeStore
.tryGet(propertyDefaultValuesTypeAttributeKind, o)
?.get(jsonName);
}

private classPropertyDefaultSource(
o: ObjectType,
jsonName: string,
): Sourcelike {
const value = this.defaultValueForClassProperty(o, jsonName);
if (value === undefined) return [];

const literal = this.pythonLiteral(value);
if (
this.pyOptions.features.dataClasses &&
!this.pyOptions.pydanticBaseModel &&
(Array.isArray(value) || isStringMap(value))
) {
return [
" = ",
this.withImport("dataclasses", "field"),
"(default_factory=lambda: ",
literal,
")",
];
}

return [" = ", literal];
}

protected withImport(module: string, name: string): Sourcelike {
if (this.pyOptions.features.typeHints || module !== "typing") {
// FIXME: This is ugly. We should rather not generate that import in the first
Expand Down Expand Up @@ -392,8 +471,12 @@ export class PythonRenderer extends ConvenienceRenderer {
return;

const args: Sourcelike[] = [];
this.forEachClassProperty(t, "none", (name, _, cp) => {
args.push([name, this.typeHint(": ", this.pythonType(cp.type))]);
this.forEachClassProperty(t, "none", (name, jsonName, cp) => {
args.push([
name,
this.typeHint(": ", this.pythonType(cp.type)),
this.classPropertyDefaultSource(t, jsonName),
]);
});
this.emitBlock(
[
Expand Down Expand Up @@ -432,21 +515,36 @@ export class PythonRenderer extends ConvenienceRenderer {
}

protected sortClassProperties(
o: ObjectType,
properties: ReadonlyMap<string, ClassProperty>,
propertyNames: ReadonlyMap<string, Name>,
): ReadonlyMap<string, ClassProperty> {
const hasSchemaDefaults = iterableSome(
properties.entries(),
([name]) =>
this.defaultValueForClassProperty(o, name) !== undefined,
);
if (
hasSchemaDefaults ||
this.pyOptions.features.dataClasses ||
this.pyOptions.pydanticBaseModel
) {
// Properties that get a `" = None"` default must come after all
// properties that don't, or the generated dataclass is invalid.
return mapSortBy(properties, (p: ClassProperty) =>
this.classPropertyHasNoneDefault(p) ? 1 : 0,
// Properties that get a default must come after all properties
// that don't, or the generated class is invalid.
return mapSortBy(
properties,
(p: ClassProperty, jsonName: string) =>
this.defaultValueForClassProperty(o, jsonName) !==
undefined ||
((this.pyOptions.features.dataClasses ||
this.pyOptions.pydanticBaseModel) &&
this.classPropertyHasNoneDefault(p))
? 1
: 0,
);
}

return super.sortClassProperties(properties, propertyNames);
return super.sortClassProperties(o, properties, propertyNames);
}

protected emitClass(t: ClassType): void {
Expand All @@ -466,12 +564,18 @@ export class PythonRenderer extends ConvenienceRenderer {
t,
"none",
(name, jsonName, cp) => {
const defaultValue =
this.defaultValueForClassProperty(t, jsonName);
this.emitLine(
name,
this.typeHint(
": ",
this.pythonType(cp.type, true),
this.pythonType(
cp.type,
defaultValue === undefined,
),
),
this.classPropertyDefaultSource(t, jsonName),
);
this.emitDescription(
this.descriptionForClassProperty(t, jsonName),
Expand Down
1 change: 1 addition & 0 deletions test/inputs/schema/default-value.1.fail.no-defaults.json
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
{
"id": []
}
3 changes: 3 additions & 0 deletions test/inputs/schema/default-values.1.fail.no-defaults.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"Name": []
}
3 changes: 3 additions & 0 deletions test/inputs/schema/default-values.1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"Name": "Explicit"
}
4 changes: 4 additions & 0 deletions test/inputs/schema/default-values.1.out.defaults.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"Name": "Explicit",
"Greeting": "World"
}
20 changes: 20 additions & 0 deletions test/inputs/schema/default-values.schema
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"definitions": {
"record:Metadata": {
"type": "object",
"required": ["Name"],
"additionalProperties": false,
"properties": {
"Name": {
"default": "Hello",
"type": "string"
},
"Greeting": {
"default": "World",
"type": "string"
}
}
}
},
"$ref": "#/definitions/record:Metadata"
}
Loading
Loading