diff --git a/packages/quicktype-core/src/ConvenienceRenderer.ts b/packages/quicktype-core/src/ConvenienceRenderer.ts index af8ff1603..4d937b5f9 100644 --- a/packages/quicktype-core/src/ConvenienceRenderer.ts +++ b/packages/quicktype-core/src/ConvenienceRenderer.ts @@ -919,6 +919,7 @@ export abstract class ConvenienceRenderer extends Renderer { } protected sortClassProperties( + _o: ObjectType, properties: ReadonlyMap, propertyNames: ReadonlyMap, ): ReadonlyMap { @@ -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, ); diff --git a/packages/quicktype-core/src/attributes/DefaultValues.ts b/packages/quicktype-core/src/attributes/DefaultValues.ts new file mode 100644 index 000000000..75c2490da --- /dev/null +++ b/packages/quicktype-core/src/attributes/DefaultValues.ts @@ -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; + +class PropertyDefaultValuesTypeAttributeKind extends TypeAttributeKind { + 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(); + 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 = + new PropertyDefaultValuesTypeAttributeKind(); + +export function defaultValuesAttributeProducer( + schema: JSONSchema, + _ref: Ref, + types: Set, +): 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(); + 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), + }; +} diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 66b1c4104..f59f2058f 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -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"; @@ -1568,6 +1569,7 @@ export class JSONSchemaInput implements Input { ) { this._attributeProducers = [ descriptionAttributeProducer, + defaultValuesAttributeProducer, accessorNamesAttributeProducer, defaultValueAttributeProducer, enumValuesAttributeProducer, diff --git a/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts b/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts index 76074fa97..ded7465c9 100644 --- a/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts +++ b/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts @@ -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, diff --git a/packages/quicktype-core/src/language/Python/PythonRenderer.ts b/packages/quicktype-core/src/language/Python/PythonRenderer.ts index 1a5a35ed2..b543e32ad 100644 --- a/packages/quicktype-core/src/language/Python/PythonRenderer.ts +++ b/packages/quicktype-core/src/language/Python/PythonRenderer.ts @@ -7,6 +7,7 @@ import { setUnionInto, } from "collection-utils"; +import { propertyDefaultValuesTypeAttributeKind } from "../../attributes/DefaultValues.js"; import { ConvenienceRenderer, type ForbiddenWordsInfo, @@ -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 { @@ -25,6 +26,7 @@ import { ClassType, EnumType, MapType, + type ObjectType, type Type, UnionType, } from "../../Type/index.js"; @@ -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( + ([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 @@ -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( [ @@ -432,21 +515,36 @@ export class PythonRenderer extends ConvenienceRenderer { } protected sortClassProperties( + o: ObjectType, properties: ReadonlyMap, propertyNames: ReadonlyMap, ): ReadonlyMap { + 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 { @@ -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), diff --git a/test/inputs/schema/default-value.1.fail.no-defaults.json b/test/inputs/schema/default-value.1.fail.no-defaults.json index 2c63c0851..4b3306edf 100644 --- a/test/inputs/schema/default-value.1.fail.no-defaults.json +++ b/test/inputs/schema/default-value.1.fail.no-defaults.json @@ -1,2 +1,3 @@ { + "id": [] } diff --git a/test/inputs/schema/default-values.1.fail.no-defaults.json b/test/inputs/schema/default-values.1.fail.no-defaults.json new file mode 100644 index 000000000..4d64d54db --- /dev/null +++ b/test/inputs/schema/default-values.1.fail.no-defaults.json @@ -0,0 +1,3 @@ +{ + "Name": [] +} diff --git a/test/inputs/schema/default-values.1.json b/test/inputs/schema/default-values.1.json new file mode 100644 index 000000000..7b537d49b --- /dev/null +++ b/test/inputs/schema/default-values.1.json @@ -0,0 +1,3 @@ +{ + "Name": "Explicit" +} diff --git a/test/inputs/schema/default-values.1.out.defaults.json b/test/inputs/schema/default-values.1.out.defaults.json new file mode 100644 index 000000000..43f392ebe --- /dev/null +++ b/test/inputs/schema/default-values.1.out.defaults.json @@ -0,0 +1,4 @@ +{ + "Name": "Explicit", + "Greeting": "World" +} diff --git a/test/inputs/schema/default-values.schema b/test/inputs/schema/default-values.schema new file mode 100644 index 000000000..550860d92 --- /dev/null +++ b/test/inputs/schema/default-values.schema @@ -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" +} diff --git a/test/languages.ts b/test/languages.ts index 120f3170b..d50ad84b6 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -68,6 +68,7 @@ const skipsMapValueValidation = [ const skipsArrayElementValidation = ["issue2680-top-level-array.schema"]; export type LanguageFeature = + | "defaults" | "enum" | "union" | "no-defaults" @@ -321,6 +322,7 @@ export const PythonLanguage: Language = { "enum", "union", "no-defaults", + "defaults", "date-time", "integer-string", "bool-string", @@ -1904,9 +1906,10 @@ export const HaskellLanguage: Language = { ...skipsUntypedUnions, // The test driver encodes the Maybe result, so a failed decode prints // "null" and exits 0 — expected-failure samples cannot be detected. + "default-values.schema", + "boolean-subschema.schema", // (A top-level `[Int]` correctly fails to decode `[1, 2, "three"]`, // but the driver still exits 0.) - "boolean-subschema.schema", "issue2680-top-level-array.schema", "nested-intersection-union.schema", "prefix-items.schema", @@ -2271,6 +2274,7 @@ export const ElixirLanguage: Language = { "mutually-recursive.schema", // Struct keys cannot be enforced at runtime in Elixir and their values will just be set to null. + "default-values.schema", "strict-optional.schema", "required.schema", // The default-value fail sample also relies on required-property enforcement.