diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index fe9efcbd8e..88b3a684e0 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -1084,14 +1084,17 @@ async function addTypesInSchema( emptyTypeAttributes, new Set(itemTypes), ); - } else if (typeof items === "object") { + } else if ( + typeof items === "object" || + typeof items === "boolean" + ) { const itemsLoc = loc.push("items"); itemType = await toType( checkJSONSchema(items, itemsLoc.canonicalRef), itemsLoc, singularAttributes, ); - } else if (items !== undefined && items !== true) { + } else if (items !== undefined) { return messageError( "SchemaArrayItemsMustBeStringOrArray", withRef(loc, { actual: items }), @@ -1396,14 +1399,7 @@ async function addTypesInSchema( let result: TypeRef; if (typeof schema === "boolean") { - // FIXME: Empty union. We'd have to check that it's supported everywhere, - // in particular in union flattening. - messageAssert( - schema === true, - "SchemaFalseNotSupported", - withRef(loc), - ); - result = typeBuilder.getPrimitiveType("any"); + result = typeBuilder.getPrimitiveType(schema ? "any" : "none"); } else { loc = loc.updateWithID(schema.$id); result = await convertToType(schema, loc, typeAttributes); diff --git a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts index 64f530a23c..fb15faf3a6 100644 --- a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts +++ b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts @@ -1597,6 +1597,12 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { getter = [name]; } + const value = this._stringType.wrapEncodingChange( + [ourQualifier], + cppType, + toType, + ["x.", getter], + ); const assignment: Sourcelike[] = [ "j[", this._stringType.wrapEncodingChange( @@ -1608,31 +1614,17 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { ]), ), "] = ", - this._stringType.wrapEncodingChange( - [ourQualifier], - cppType, - toType, - ["x.", getter], - ), + value, ";", ]; if (p.isOptional && this._options.hideNullOptional) { - this.emitBlock( - [ - "if (", - this._stringType.wrapEncodingChange( - [ourQualifier], - cppType, - toType, - ["x.", getter], - ), - ")", - ], - false, - () => { - this.emitLine(assignment); - }, - ); + const condition = + propType.kind === "null" || propType.kind === "any" + ? ["!", value, ".is_null()"] + : value; + this.emitBlock(["if (", condition, ")"], false, () => { + this.emitLine(assignment); + }); } else { this.emitLine(assignment); } diff --git a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts index 4fbd16c23c..960ed47820 100644 --- a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts @@ -408,7 +408,7 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { this.emitType( undefined, AccessModifier.Public, - "static class", + "static partial class", "Serialize", undefined, () => { @@ -471,7 +471,7 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { this.emitType( undefined, AccessModifier.Internal, - "static class", + "static partial class", converterName, undefined, () => { diff --git a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts index 96bbf0edc0..72a741c9ca 100644 --- a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts @@ -416,7 +416,7 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { this.emitType( undefined, AccessModifier.Public, - "static class", + "static partial class", "Serialize", undefined, () => { @@ -487,7 +487,7 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { this.emitType( undefined, AccessModifier.Internal, - "static class", + "static partial class", converterName, undefined, () => { diff --git a/packages/quicktype-core/src/language/Golang/GolangRenderer.ts b/packages/quicktype-core/src/language/Golang/GolangRenderer.ts index 72d50068e8..37cbfaf5e4 100644 --- a/packages/quicktype-core/src/language/Golang/GolangRenderer.ts +++ b/packages/quicktype-core/src/language/Golang/GolangRenderer.ts @@ -12,7 +12,7 @@ import { assert, defined } from "../../support/Support.js"; import type { TargetLanguage } from "../../TargetLanguage.js"; import { type ClassProperty, - type ClassType, + ClassType, type EnumType, type Type, type TypeKind, @@ -194,28 +194,37 @@ export class GoRenderer extends ConvenienceRenderer { if ( this._options.multiFileOutput && this._options.justTypes === false && - this._options.justTypesAndPackage === false && - this.leadingComments === undefined + this._options.justTypesAndPackage === false ) { - this.emitLineOnce( - "// Code generated from JSON Schema using quicktype. DO NOT EDIT.", - ); - this.emitLineOnce( - "// To parse and unparse this JSON data, add this code to your project and do:", - ); - this.emitLineOnce("//"); - const ref = modifySource(camelCase, name); - this.emitLineOnce( - "// ", - ref, - ", err := ", - defined(this._topLevelUnmarshalNames.get(name)), - "(bytes)", - ); - this.emitLineOnce("// bytes, err = ", ref, ".Marshal()"); + if (this.leadingComments !== undefined) { + this.emitComments(this.leadingComments); + } else { + this.emitLineOnce( + "// Code generated from JSON Schema using quicktype. DO NOT EDIT.", + ); + this.emitLineOnce( + "// To parse and unparse this JSON data, add this code to your project and do:", + ); + this.emitLineOnce("//"); + const ref = modifySource(camelCase, name); + this.emitLineOnce( + "// ", + ref, + ", err := ", + defined(this._topLevelUnmarshalNames.get(name)), + "(bytes)", + ); + this.emitLineOnce("// bytes, err = ", ref, ".Marshal()"); + } } - this.emitPackageDefinitons(true); + const imports = + t instanceof ClassType + ? this.collectClassImports(t) + : t instanceof UnionType + ? this.collectUnionImports(t) + : undefined; + this.emitPackageDefinitons(true, imports); const unmarshalName = defined(this._topLevelUnmarshalNames.get(name)); if (this.namedTypeToNameForTopLevel(t) === undefined) { @@ -305,7 +314,7 @@ export class GoRenderer extends ConvenienceRenderer { private emitUnion(u: UnionType, unionName: Name): void { this.startFile(unionName); - this.emitPackageDefinitons(false); + this.emitPackageDefinitons(false, this.collectUnionImports(u)); const [hasNull, nonNulls] = removeNullFromUnion(u); const isNullableArg = hasNull !== null ? "true" : "false"; @@ -610,10 +619,13 @@ func marshalUnion(pi *int64, pf *float64, pb *bool, ps *string, haveArray bool, if ( this._options.multiFileOutput === false && this._options.justTypes === false && - this._options.justTypesAndPackage === false && - this.leadingComments === undefined + this._options.justTypesAndPackage === false ) { - this.emitSingleFileHeaderComments(); + if (this.leadingComments !== undefined) { + this.emitComments(this.leadingComments); + } else { + this.emitSingleFileHeaderComments(); + } this.emitPackageDefinitons(false, this.collectAllImports()); } diff --git a/packages/quicktype-core/src/language/Golang/utils.ts b/packages/quicktype-core/src/language/Golang/utils.ts index 6ba526ed2f..6c202655f1 100644 --- a/packages/quicktype-core/src/language/Golang/utils.ts +++ b/packages/quicktype-core/src/language/Golang/utils.ts @@ -51,7 +51,8 @@ export function canOmitEmpty( omitEmptyOption: boolean, ): boolean { if (!cp.isOptional) return false; - if (omitEmptyOption) return true; + if (omitEmptyOption) + return !["union", "null", "any"].includes(cp.type.kind); const t = cp.type; return !["union", "null", "any"].includes(t.kind); } diff --git a/packages/quicktype-core/src/language/Java/JavaJacksonRenderer.ts b/packages/quicktype-core/src/language/Java/JavaJacksonRenderer.ts index 15c24bebed..35407b4cb7 100644 --- a/packages/quicktype-core/src/language/Java/JavaJacksonRenderer.ts +++ b/packages/quicktype-core/src/language/Java/JavaJacksonRenderer.ts @@ -10,7 +10,10 @@ import { type TypeKind, UnionType, } from "../../Type/index.js"; -import { removeNullFromUnion } from "../../Type/TypeUtils.js"; +import { + nullableFromUnion, + removeNullFromUnion, +} from "../../Type/TypeUtils.js"; import { JavaRenderer } from "./JavaRenderer.js"; import { stringEscape } from "./utils.js"; @@ -58,7 +61,12 @@ export class JacksonRenderer extends JavaRenderer { `@JsonProperty("${stringEscape(jsonName)}")`, ]; - switch (p.type.kind) { + const propertyType = + p.type instanceof UnionType + ? (nullableFromUnion(p.type) ?? p.type) + : p.type; + + switch (propertyType.kind) { case "date-time": this._dateTimeProvider.dateTimeJacksonAnnotations.forEach( (annotation) => { diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts index a39af4d0fc..963b68b54a 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts @@ -33,7 +33,7 @@ import { import { keywords } from "./constants.js"; import type { kotlinOptions } from "./language.js"; -import { kotlinNameStyle } from "./utils.js"; +import { kotlinNameStyle, stringEscape } from "./utils.js"; export class KotlinRenderer extends ConvenienceRenderer { public constructor( @@ -391,10 +391,38 @@ export class KotlinRenderer extends ConvenienceRenderer { protected emitEnumDefinition(e: EnumType, enumName: Name): void { this.emitDescription(this.descriptionForType(e)); - this.emitBlock(["enum class ", enumName], () => { + this.emitBlock(["enum class ", enumName, "(val value: String)"], () => { let count = e.cases.size; - this.forEachEnumCase(e, "none", (name) => { - this.emitLine(name, --count === 0 ? "" : ","); + this.forEachEnumCase(e, "none", (name, json) => { + this.emitLine( + name, + `("${stringEscape(json)}")`, + --count === 0 ? ";" : ",", + ); + }); + this.ensureBlankLine(); + this.emitBlock("companion object", () => { + this.emitBlock( + [ + "fun fromValue(value: String): ", + enumName, + " = when (value)", + ], + () => { + const table: Sourcelike[][] = []; + this.forEachEnumCase(e, "none", (name, json) => { + table.push([ + [`"${stringEscape(json)}"`], + [" -> ", name], + ]); + }); + table.push([ + ["else"], + [" -> throw IllegalArgumentException()"], + ]); + this.emitTable(table); + }, + ); }); }); } diff --git a/packages/quicktype-core/src/language/Php/PhpRenderer.ts b/packages/quicktype-core/src/language/Php/PhpRenderer.ts index 85d4ea4ee0..a507cd8827 100644 --- a/packages/quicktype-core/src/language/Php/PhpRenderer.ts +++ b/packages/quicktype-core/src/language/Php/PhpRenderer.ts @@ -1016,6 +1016,7 @@ export class PhpRenderer extends ConvenienceRenderer { t: Type, attrName: Sourcelike, scopeAttrName: string, + skipPrimitiveTypeCheck = false, ): void { const is = (isfn: string, myT: Name = className): void => { this.emitBlock(["if (!", isfn, "(", scopeAttrName, "))"], () => @@ -1038,30 +1039,38 @@ export class PhpRenderer extends ConvenienceRenderer { // non-string arguments.) }, (_nullType) => is("is_null"), - (_boolType) => is("is_bool"), - (_integerType) => is("is_integer"), + (_boolType) => { + if (!skipPrimitiveTypeCheck) is("is_bool"); + }, + (_integerType) => { + if (!skipPrimitiveTypeCheck) is("is_integer"); + }, (_doubleType) => { - // PHP integers are acceptable wherever floats are, and - // json_decode gives an int for a whole JSON number. - this.emitBlock( - [ - "if (!is_float(", - scopeAttrName, - ") && !is_int(", - scopeAttrName, - "))", - ], - () => - this.emitLine( - 'throw new Exception("Attribute Error:', - className, - "::", - attrName, - '");', - ), - ); + if (!skipPrimitiveTypeCheck) { + // PHP integers are acceptable wherever floats are, and + // json_decode gives an int for a whole JSON number. + this.emitBlock( + [ + "if (!is_float(", + scopeAttrName, + ") && !is_int(", + scopeAttrName, + "))", + ], + () => + this.emitLine( + 'throw new Exception("Attribute Error:', + className, + "::", + attrName, + '");', + ), + ); + } + }, + (_stringType) => { + if (!skipPrimitiveTypeCheck) is("is_string"); }, - (_stringType) => is("is_string"), (arrayType) => { is("is_array"); this.emitLine( @@ -1110,6 +1119,7 @@ export class PhpRenderer extends ConvenienceRenderer { nullable, attrName, scopeAttrName, + skipPrimitiveTypeCheck, ); }, ); @@ -1146,7 +1156,7 @@ export class PhpRenderer extends ConvenienceRenderer { } if (transformedStringType.kind === "uuid") { - is("is_string"); + if (!skipPrimitiveTypeCheck) is("is_string"); this.emitBlock( [ "if (!preg_match('/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/', ", @@ -1286,7 +1296,10 @@ export class PhpRenderer extends ConvenienceRenderer { " $value): bool", ], () => { - this.phpValidate(className, p.type, name, "$value"); + // PHP has already enforced scalar parameter type hints before + // entering this method. Recursive collection and union + // validation still performs its own runtime checks. + this.phpValidate(className, p.type, name, "$value", true); this.emitLine("return true;"); }, ); @@ -1811,6 +1824,7 @@ export class PhpRenderer extends ConvenienceRenderer { protected emitSourceStructure(givenFilename: string): void { this.emitLine(" this.emitClassDefinition(c, n), diff --git a/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts b/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts index 98b44f4db1..76074fa97e 100644 --- a/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts +++ b/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts @@ -40,6 +40,8 @@ export type ConverterFunction = | "to-class" | "dict" | "union" + | "from-date" + | "from-time" | "from-datetime" | "from-stringified-bool" | "is-type"; @@ -334,12 +336,20 @@ export class JSONPythonRenderer extends PythonRenderer { ); } + protected typingTypeHint(tvar: Sourcelike): Sourcelike { + if (!this.pyOptions.features.typingType) { + return []; + } + + return this.typeHint(": ", this.withTyping("Type"), "[", tvar, "]"); + } + protected emitToEnumConverter(): void { const tvar = this.enumTypeVar(); this.emitBlock( [ "def to_enum(c", - this.typeHint(": ", this.withTyping("Type"), "[", tvar, "]"), + this.typingTypeHint(tvar), ", ", this.typingDecl("x", "Any"), ")", @@ -393,7 +403,7 @@ export class JSONPythonRenderer extends PythonRenderer { this.emitBlock( [ "def to_class(c", - this.typeHint(": ", this.withTyping("Type"), "[", tvar, "]"), + this.typingTypeHint(tvar), ", ", this.typingDecl("x", "Any"), ")", @@ -458,13 +468,64 @@ export class JSONPythonRenderer extends PythonRenderer { assert False`); } + protected emitFromDateConverter(): void { + this.emitBlock( + [ + "def from_date(", + this.typingDecl("x", "Any"), + ")", + this.typeHint(" -> ", [ + this.withModuleImport("datetime"), + ".date", + ]), + ":", + ], + () => { + this._haveDateutil = true; + this.emitLine( + "assert isinstance(x, str) and ", + this.withModuleImport("re"), + '.match(r"^\\d{4}-\\d{2}-\\d{2}$", x)', + ); + this.emitLine("return dateutil.parser.parse(x).date()"); + }, + ); + } + + protected emitFromTimeConverter(): void { + this.emitBlock( + [ + "def from_time(", + this.typingDecl("x", "Any"), + ")", + this.typeHint(" -> ", [ + this.withModuleImport("datetime"), + ".time", + ]), + ":", + ], + () => { + this._haveDateutil = true; + this.emitLine( + "assert isinstance(x, str) and ", + this.withModuleImport("re"), + '.match(r"^\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})?$", x)', + ); + this.emitLine("return dateutil.parser.parse(x).time()"); + }, + ); + } + protected emitFromDatetimeConverter(): void { this.emitBlock( [ "def from_datetime(", this.typingDecl("x", "Any"), ")", - this.typeHint(" -> ", this.withImport("datetime", "datetime")), + this.typeHint(" -> ", [ + this.withModuleImport("datetime"), + ".datetime", + ]), ":", ], () => { @@ -500,7 +561,7 @@ export class JSONPythonRenderer extends PythonRenderer { this.emitBlock( [ "def is_type(t", - this.typeHint(": ", this.withTyping("Type"), "[", tvar, "]"), + this.typingTypeHint(tvar), ", ", this.typingDecl("x", "Any"), ")", @@ -571,6 +632,16 @@ export class JSONPythonRenderer extends PythonRenderer { return; } + case "from-date": { + this.emitFromDateConverter(); + return; + } + + case "from-time": { + this.emitFromTimeConverter(); + return; + } + case "from-datetime": { this.emitFromDatetimeConverter(); return; @@ -628,8 +699,16 @@ export class JSONPythonRenderer extends PythonRenderer { (enumType) => this.nameForNamedType(enumType), (_unionType) => undefined, (transformedStringType) => { + if (transformedStringType.kind === "date") { + return [this.withModuleImport("datetime"), ".date"]; + } + + if (transformedStringType.kind === "time") { + return [this.withModuleImport("datetime"), ".time"]; + } + if (transformedStringType.kind === "date-time") { - return this.withImport("datetime", "datetime"); + return [this.withModuleImport("datetime"), ".datetime"]; } if (transformedStringType.kind === "uuid") { @@ -731,6 +810,12 @@ export class JSONPythonRenderer extends PythonRenderer { immediateTargetType, ); break; + case "date": + vol = this.convFn("from-date", inputTransformer); + break; + case "time": + vol = this.convFn("from-time", inputTransformer); + break; case "date-time": vol = this.convFn("from-datetime", inputTransformer); break; @@ -767,6 +852,8 @@ export class JSONPythonRenderer extends PythonRenderer { case "enum": vol = this.serializer(inputTransformer, xfer.sourceType); break; + case "date": + case "time": case "date-time": vol = compose(inputTransformer, (v) => [v, ".isoformat()"]); break; @@ -866,6 +953,14 @@ export class JSONPythonRenderer extends PythonRenderer { }, (transformedStringType) => { // FIXME: handle via transformers + if (transformedStringType.kind === "date") { + return this.convFn("from-date", value); + } + + if (transformedStringType.kind === "time") { + return this.convFn("from-time", value); + } + if (transformedStringType.kind === "date-time") { return this.convFn("from-datetime", value); } @@ -957,7 +1052,11 @@ export class JSONPythonRenderer extends PythonRenderer { ]); }, (transformedStringType) => { - if (transformedStringType.kind === "date-time") { + if ( + transformedStringType.kind === "date" || + transformedStringType.kind === "time" || + transformedStringType.kind === "date-time" + ) { return compose(value, (v) => [v, ".isoformat()"]); } diff --git a/packages/quicktype-core/src/language/Python/PythonRenderer.ts b/packages/quicktype-core/src/language/Python/PythonRenderer.ts index e0d60263ab..1a5a35ed25 100644 --- a/packages/quicktype-core/src/language/Python/PythonRenderer.ts +++ b/packages/quicktype-core/src/language/Python/PythonRenderer.ts @@ -37,6 +37,8 @@ import { classNameStyle, snakeNameStyle } from "./utils.js"; export class PythonRenderer extends ConvenienceRenderer { private readonly imports: Map> = new Map(); + private readonly moduleImports: Set = new Set(); + private readonly declaredTypes: Set = new Set(); public constructor( @@ -132,6 +134,11 @@ export class PythonRenderer extends ConvenienceRenderer { return name; } + protected withModuleImport(module: string): Sourcelike { + this.moduleImports.add(module); + return module; + } + protected withTyping(name: string): Sourcelike { return this.withImport("typing", name); } @@ -319,8 +326,16 @@ export class PythonRenderer extends ConvenienceRenderer { ]; }, (transformedStringType) => { + if (transformedStringType.kind === "date") { + return [this.withModuleImport("datetime"), ".date"]; + } + + if (transformedStringType.kind === "time") { + return [this.withModuleImport("datetime"), ".time"]; + } + if (transformedStringType.kind === "date-time") { - return this.withImport("datetime", "datetime"); + return [this.withModuleImport("datetime"), ".datetime"]; } if (transformedStringType.kind === "uuid") { @@ -481,6 +496,9 @@ export class PythonRenderer extends ConvenienceRenderer { } protected emitImports(): void { + this.moduleImports.forEach((module) => { + this.emitLine("import ", module); + }); this.imports.forEach((names, module) => { this.emitLine( "from ", diff --git a/packages/quicktype-core/src/language/Python/language.ts b/packages/quicktype-core/src/language/Python/language.ts index 263026b7e5..d3e1ed48c9 100644 --- a/packages/quicktype-core/src/language/Python/language.ts +++ b/packages/quicktype-core/src/language/Python/language.ts @@ -25,6 +25,8 @@ export interface PythonFeatures { builtinGenerics: boolean; dataClasses: boolean; typeHints: boolean; + /** `typing.Type`, unavailable in Python 3.6.0 */ + typingType: boolean; /** PEP 604 union operators (`str | None`), Python 3.10+ */ unionOperators: boolean; } @@ -36,30 +38,35 @@ export const pythonOptions = { { "3.5": { typeHints: false, + typingType: false, dataClasses: false, builtinGenerics: false, unionOperators: false, }, "3.6": { typeHints: true, + typingType: false, dataClasses: false, builtinGenerics: false, unionOperators: false, }, "3.7": { typeHints: true, + typingType: true, dataClasses: true, builtinGenerics: false, unionOperators: false, }, "3.9": { typeHints: true, + typingType: true, dataClasses: true, builtinGenerics: true, unionOperators: false, }, "3.10": { typeHints: true, + typingType: true, dataClasses: true, builtinGenerics: true, unionOperators: true, @@ -105,10 +112,9 @@ export class PythonTargetLanguage extends TargetLanguage< public get stringTypeMapping(): StringTypeMapping { const mapping: Map = new Map(); - const dateTimeType = "date-time"; - mapping.set("date", dateTimeType); - mapping.set("time", dateTimeType); - mapping.set("date-time", dateTimeType); + mapping.set("date", "date"); + mapping.set("time", "time"); + mapping.set("date-time", "date-time"); mapping.set("uuid", "uuid"); mapping.set("integer-string", "integer-string"); mapping.set("bool-string", "bool-string"); diff --git a/packages/quicktype-core/src/language/Rust/RustRenderer.ts b/packages/quicktype-core/src/language/Rust/RustRenderer.ts index eda0ace698..aebf7cc56c 100644 --- a/packages/quicktype-core/src/language/Rust/RustRenderer.ts +++ b/packages/quicktype-core/src/language/Rust/RustRenderer.ts @@ -14,7 +14,6 @@ import type { Name, Namer } from "../../Naming.js"; import type { RenderContext } from "../../Renderer.js"; import type { OptionValues } from "../../RendererOptions/index.js"; import { type Sourcelike, maybeAnnotated } from "../../Source.js"; -import { defined } from "../../support/Support.js"; import type { TargetLanguage } from "../../TargetLanguage.js"; import { type ClassType, @@ -184,6 +183,13 @@ export class RustRenderer extends ConvenienceRenderer { const rustType = this.rustType(t, withIssues); const isCycleBreaker = this.isCycleBreakerType(t); + if (isCycleBreaker && t instanceof UnionType) { + const nullable = nullableFromUnion(t); + if (nullable === null || this.isCycleBreakerType(nullable)) { + return rustType; + } + } + return isCycleBreaker ? ["Box<", rustType, ">"] : rustType; } @@ -374,9 +380,10 @@ export class RustRenderer extends ConvenienceRenderer { return; } - const topLevelName = defined( - mapFirst(this.topLevels), - ).getCombinedName(); + const topLevel = mapFirst(this.topLevels); + if (topLevel === undefined) return; + + const topLevelName = topLevel.getCombinedName(); this.emitMultiline( `// Example code that deserializes and serializes the model. // extern crate serde; diff --git a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts index a47b189df1..d6b2b95e11 100644 --- a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts +++ b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts @@ -386,8 +386,7 @@ export class SwiftRenderer extends ConvenienceRenderer { if ( this._options.sendable && - (!this._options.mutableProperties || !isClass) && - !this._options.objcSupport + (!this._options.mutableProperties || !isClass) ) { protocols.push("Sendable"); } @@ -501,7 +500,11 @@ export class SwiftRenderer extends ConvenienceRenderer { const isClass = this._options.useClasses || this.isCycleBreakerType(c); const structOrClass = isClass ? "class" : "struct"; const finalPrefix = - isClass && this._options.finalClasses ? "final " : ""; + isClass && + (this._options.finalClasses || + (this._options.sendable && !this._options.mutableProperties)) + ? "final " + : ""; if (isClass && this._options.objcSupport) { // [Michael Fey (@MrRooni), 2019-4-24] Swift 5 or greater, must come before the access declaration for the class. diff --git a/packages/quicktype-core/src/rewrites/ResolveIntersections.ts b/packages/quicktype-core/src/rewrites/ResolveIntersections.ts index a7aeefa83a..05b424f434 100644 --- a/packages/quicktype-core/src/rewrites/ResolveIntersections.ts +++ b/packages/quicktype-core/src/rewrites/ResolveIntersections.ts @@ -471,6 +471,15 @@ export function resolveIntersections( return t; } + const noneType = iterableFind(members, (t) => t.kind === "none"); + if (noneType !== undefined) { + return builder.reconstituteType( + noneType, + intersectionAttributes, + forwardingRef, + ); + } + if (members.size === 1) { return builder.reconstituteType( defined(iterableFirst(members)), diff --git a/src/index.ts b/src/index.ts index d22d5e70f5..8919671a58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1271,6 +1271,17 @@ export async function main( } if (require.main === module) { + const exitOnEPIPE = (error: NodeJS.ErrnoException): void => { + if (error.code === "EPIPE") { + process.exit(0); + } + + throw error; + }; + + process.stdout.on("error", exitOnEPIPE); + process.stderr.on("error", exitOnEPIPE); + main(process.argv.slice(2)).catch((e) => { if (e instanceof Error) { console.error(`Error: ${e.message}.`); diff --git a/test/fixtures.ts b/test/fixtures.ts index 827ead7b0b..1ecbfabbeb 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -25,7 +25,16 @@ import { callAndExpectFailure, } from "./utils"; import * as languages from "./languages"; -import type { LanguageName, Option, RendererOptions } from "quicktype-core"; +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + type LanguageName, + type Option, + type RendererOptions, + quicktype as quicktypeCore, + quicktypeMultiFile, +} from "quicktype-core"; import { mustNotHappen, defined, @@ -896,6 +905,83 @@ class JSONSchemaFixture extends LanguageFixture { } } +// `leadingComments` is a quicktype-core API option, so the CLI fixture path +// cannot exercise it. +class LeadingCommentsGoFixture extends JSONSchemaFixture { + constructor() { + super(languages.GoLanguage, "schema-golang-leading-comments"); + } + + getSamples(sources: string[]): { priority: Sample[]; others: Sample[] } { + return samplesFromSources( + sources, + ["test/inputs/schema/date-time.schema"], + [], + "schema", + ); + } + + private async inputData(filename: string): Promise { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ + name: this.language.topLevel, + schema: fs.readFileSync(filename, "utf8"), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + return inputData; + } + + async runQuicktype( + filename: string, + additionalRendererOptions: RendererOptions, + ): Promise { + const rendererOptions = _.merge( + {}, + this.language.rendererOptions, + additionalRendererOptions, + ); + const result = await quicktypeCore({ + inputData: await this.inputData(filename), + lang: this.language.name, + leadingComments: [], + rendererOptions, + }); + fs.writeFileSync(this.language.output, result.lines.join("\n")); + + const multiFileResult = await quicktypeMultiFile({ + inputData: await this.inputData(filename), + lang: this.language.name, + leadingComments: [], + rendererOptions: { + ...rendererOptions, + "multi-file-output": true, + }, + }); + mkdirs("multi"); + for (const [outputFilename, output] of multiFileResult) { + fs.writeFileSync( + path.join("multi", outputFilename), + output.lines.join("\n"), + ); + } + } + + async test( + filename: string, + additionalRendererOptions: RendererOptions, + additionalFiles: string[], + ): Promise { + const numFiles = await super.test( + filename, + additionalRendererOptions, + additionalFiles, + ); + await execAsync("go test multi/*.go"); + return numFiles + testsInDir("multi", "go").length; + } +} + type TreeSitterTarget = { displayName: string; language: languages.Language; @@ -1651,6 +1737,10 @@ export const allFixtures: Fixture[] = [ new JSONFixture(languages.PythonLanguage), new JSONFixture(languages.ElmLanguage), new JSONFixture(languages.SwiftLanguage), + new JSONFixture( + languages.SwiftSendableObjectiveCSupportLanguage, + "swift-sendable-objective-c", + ), new JSONFixture(languages.ObjectiveCLanguage), new JSONFixture(languages.TypeScriptLanguage), new JSONFixture(languages.TypeScriptZodLanguage), @@ -1685,6 +1775,7 @@ export const allFixtures: Fixture[] = [ "schema-java-lombok", ), new JSONSchemaFixture(languages.GoLanguage), + new LeadingCommentsGoFixture(), new JSONSchemaFixture(languages.CJSONLanguage), new JSONSchemaFixture(languages.CPlusPlusLanguage), new JSONSchemaFixture(languages.RustLanguage), diff --git a/test/fixtures/csharp-SystemTextJson/Issue1520.cs b/test/fixtures/csharp-SystemTextJson/Issue1520.cs new file mode 100644 index 0000000000..f288194daf --- /dev/null +++ b/test/fixtures/csharp-SystemTextJson/Issue1520.cs @@ -0,0 +1,6 @@ +namespace QuickType +{ + // Simulates partial helper declarations from another generated file. + public static partial class Serialize { } + internal static partial class Converter { } +} diff --git a/test/fixtures/csharp/Issue1520.cs b/test/fixtures/csharp/Issue1520.cs new file mode 100644 index 0000000000..f288194daf --- /dev/null +++ b/test/fixtures/csharp/Issue1520.cs @@ -0,0 +1,6 @@ +namespace QuickType +{ + // Simulates partial helper declarations from another generated file. + public static partial class Serialize { } + internal static partial class Converter { } +} diff --git a/test/fixtures/python/main.py b/test/fixtures/python/main.py index 93aacd51c9..a74d08d325 100644 --- a/test/fixtures/python/main.py +++ b/test/fixtures/python/main.py @@ -1,8 +1,16 @@ import quicktype +import datetime import json import sys import io f = io.open(sys.argv[1], mode="r", encoding="utf-8") -obj = quicktype.top_level_from_dict(json.load(f)) +input_obj = json.load(f) +obj = quicktype.top_level_from_dict(input_obj) + +if isinstance(input_obj, dict) and {"date", "time", "date-time"} <= input_obj.keys(): + assert type(obj.date) is datetime.date + assert type(obj.time) is datetime.time + assert type(obj.date_time) is datetime.datetime + print(json.dumps(quicktype.top_level_to_dict(obj))) diff --git a/test/fixtures/swift/verify-sendable.cjs b/test/fixtures/swift/verify-sendable.cjs new file mode 100644 index 0000000000..bd427bf97c --- /dev/null +++ b/test/fixtures/swift/verify-sendable.cjs @@ -0,0 +1,37 @@ +const fs = require("fs"); + +const source = fs.readFileSync("quicktype.swift", "utf8"); +const declarations = source + .split("\n") + .filter((line) => + /^(?:@objcMembers )?(?:final )?(?:class|struct|enum) /.test(line), + ); + +if (declarations.length === 0) { + throw new Error("No generated type declarations found"); +} + +if (!declarations.some((line) => line.startsWith("enum "))) { + throw new Error("No generated enum declaration found"); +} + +if ( + !declarations.some( + (line) => line.includes("class ") || line.startsWith("struct "), + ) +) { + throw new Error("No generated class or struct declaration found"); +} + +for (const declaration of declarations) { + if (!declaration.includes("Sendable")) { + throw new Error(`Generated type is not Sendable: ${declaration}`); + } + + if ( + declaration.includes("class ") && + !declaration.startsWith("@objcMembers final class ") + ) { + throw new Error(`Generated Sendable class is not final: ${declaration}`); + } +} diff --git a/test/inputs/json/priority/omit-empty.out.omit-empty.json b/test/inputs/json/priority/omit-empty.out.omit-empty.json index 809e3cfa02..87924cedf4 100644 --- a/test/inputs/json/priority/omit-empty.out.omit-empty.json +++ b/test/inputs/json/priority/omit-empty.out.omit-empty.json @@ -1,10 +1,12 @@ { "results": [ { - "age": 52 + "age": 52, + "name": null }, { - "age": 27 + "age": 27, + "name": null } ] } diff --git a/test/inputs/json/priority/php-validation.json b/test/inputs/json/priority/php-validation.json new file mode 100644 index 0000000000..f6fd54c062 --- /dev/null +++ b/test/inputs/json/priority/php-validation.json @@ -0,0 +1,7 @@ +{ + "boolean": true, + "integer": 1, + "number": 1.5, + "string": "value", + "integers": [1, 2, 3] +} diff --git a/test/inputs/schema/boolean-subschema.1.fail.json b/test/inputs/schema/boolean-subschema.1.fail.json new file mode 100644 index 0000000000..18abda8846 --- /dev/null +++ b/test/inputs/schema/boolean-subschema.1.fail.json @@ -0,0 +1,4 @@ +{ + "foo": "hello", + "empty": "not an array" +} diff --git a/test/inputs/schema/boolean-subschema.1.json b/test/inputs/schema/boolean-subschema.1.json new file mode 100644 index 0000000000..3dd8cd613d --- /dev/null +++ b/test/inputs/schema/boolean-subschema.1.json @@ -0,0 +1,4 @@ +{ + "foo": "hello", + "empty": [] +} diff --git a/test/inputs/schema/boolean-subschema.schema b/test/inputs/schema/boolean-subschema.schema new file mode 100644 index 0000000000..3994e3347c --- /dev/null +++ b/test/inputs/schema/boolean-subschema.schema @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "foo": { "type": "string" }, + "disallowed": false, + "empty": { + "type": "array", + "items": false + }, + "impossible": { + "allOf": [false, { "type": "string" }] + } + }, + "required": ["foo", "empty"], + "additionalProperties": false +} diff --git a/test/inputs/schema/nullable-optional-one-of.1.fail.enum.json b/test/inputs/schema/nullable-optional-one-of.1.fail.enum.json new file mode 100644 index 0000000000..fd710347dc --- /dev/null +++ b/test/inputs/schema/nullable-optional-one-of.1.fail.enum.json @@ -0,0 +1,4 @@ +{ + "kind": "three", + "b": null +} diff --git a/test/inputs/schema/nullable-optional-one-of.1.json b/test/inputs/schema/nullable-optional-one-of.1.json new file mode 100644 index 0000000000..29aeb7de27 --- /dev/null +++ b/test/inputs/schema/nullable-optional-one-of.1.json @@ -0,0 +1,4 @@ +{ + "kind": "one", + "b": null +} diff --git a/test/inputs/schema/nullable-optional-one-of.schema b/test/inputs/schema/nullable-optional-one-of.schema new file mode 100644 index 0000000000..1b0759ac35 --- /dev/null +++ b/test/inputs/schema/nullable-optional-one-of.schema @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "bar": { + "type": ["string", "null"] + }, + "one": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "one" + }, + "b": { + "$ref": "#/definitions/bar" + } + }, + "required": ["kind", "b"] + }, + "two": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "two" + }, + "b": { + "$ref": "#/definitions/bar" + } + }, + "required": ["kind"] + } + }, + "oneOf": [ + { + "$ref": "#/definitions/one" + }, + { + "$ref": "#/definitions/two" + } + ] +} diff --git a/test/inputs/schema/optional-any.1.json b/test/inputs/schema/optional-any.1.json index 0967ef424b..fc902e365c 100644 --- a/test/inputs/schema/optional-any.1.json +++ b/test/inputs/schema/optional-any.1.json @@ -1 +1,3 @@ -{} +{ + "bar": true +} diff --git a/test/inputs/schema/optional-any.2.json b/test/inputs/schema/optional-any.2.json index 6465e11c40..33580c8c7e 100644 --- a/test/inputs/schema/optional-any.2.json +++ b/test/inputs/schema/optional-any.2.json @@ -1 +1,4 @@ -{ "foo": 123 } +{ + "foo": 123, + "bar": false +} diff --git a/test/inputs/schema/optional-any.3.fail.json b/test/inputs/schema/optional-any.3.fail.json new file mode 100644 index 0000000000..7f62983c35 --- /dev/null +++ b/test/inputs/schema/optional-any.3.fail.json @@ -0,0 +1,3 @@ +{ + "bar": "not a boolean" +} diff --git a/test/inputs/schema/optional-any.schema b/test/inputs/schema/optional-any.schema index b6d2e91e48..cc682a350e 100644 --- a/test/inputs/schema/optional-any.schema +++ b/test/inputs/schema/optional-any.schema @@ -2,6 +2,8 @@ "type": "object", "additionalProperties": false, "properties": { - "foo": {} - } + "foo": {}, + "bar": { "type": "boolean" } + }, + "required": ["bar"] } diff --git a/test/inputs/schema/optional-date-time.1.fail.date-time.json b/test/inputs/schema/optional-date-time.1.fail.date-time.json new file mode 100644 index 0000000000..f7c14ea5ff --- /dev/null +++ b/test/inputs/schema/optional-date-time.1.fail.date-time.json @@ -0,0 +1,8 @@ +{ + "required-date": "2024-01-02", + "required-time": "12:34:56Z", + "required-date-time": "not a valid date-time at all", + "optional-date": "2024-01-02", + "optional-time": "12:34:56Z", + "optional-date-time": "2024-01-02T12:34:56Z" +} diff --git a/test/inputs/schema/optional-date-time.1.json b/test/inputs/schema/optional-date-time.1.json new file mode 100644 index 0000000000..ba76d40946 --- /dev/null +++ b/test/inputs/schema/optional-date-time.1.json @@ -0,0 +1,8 @@ +{ + "required-date": "2024-01-02", + "required-time": "12:34:56Z", + "required-date-time": "2024-01-02T12:34:56Z", + "optional-date": "2024-01-02", + "optional-time": "12:34:56Z", + "optional-date-time": "2024-01-02T12:34:56Z" +} diff --git a/test/inputs/schema/optional-date-time.schema b/test/inputs/schema/optional-date-time.schema new file mode 100644 index 0000000000..485d7f1a02 --- /dev/null +++ b/test/inputs/schema/optional-date-time.schema @@ -0,0 +1,35 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "required-date": { + "type": "string", + "format": "date" + }, + "required-time": { + "type": "string", + "format": "time" + }, + "required-date-time": { + "type": "string", + "format": "date-time" + }, + "optional-date": { + "type": "string", + "format": "date" + }, + "optional-time": { + "type": "string", + "format": "time" + }, + "optional-date-time": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "required-date", + "required-time", + "required-date-time" + ] +} diff --git a/test/inputs/schema/rust-cycle-breaker-union.1.fail.union.json b/test/inputs/schema/rust-cycle-breaker-union.1.fail.union.json new file mode 100644 index 0000000000..11747fa523 --- /dev/null +++ b/test/inputs/schema/rust-cycle-breaker-union.1.fail.union.json @@ -0,0 +1,4 @@ +{ + "value": "root", + "next": 42 +} diff --git a/test/inputs/schema/rust-cycle-breaker-union.1.json b/test/inputs/schema/rust-cycle-breaker-union.1.json new file mode 100644 index 0000000000..0e44a45e50 --- /dev/null +++ b/test/inputs/schema/rust-cycle-breaker-union.1.json @@ -0,0 +1,7 @@ +{ + "value": "root", + "next": { + "value": "leaf", + "next": "done" + } +} diff --git a/test/inputs/schema/rust-cycle-breaker-union.schema b/test/inputs/schema/rust-cycle-breaker-union.schema new file mode 100644 index 0000000000..f2a709c442 --- /dev/null +++ b/test/inputs/schema/rust-cycle-breaker-union.schema @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Node", + "type": "object", + "properties": { + "value": { "type": "string" }, + "next": { + "anyOf": [ + { "$ref": "#" }, + { "type": "string" }, + { "type": "null" } + ] + } + }, + "required": ["value"] +} diff --git a/test/languages.ts b/test/languages.ts index c798e154c4..4e2cf42083 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -25,6 +25,7 @@ const skipsEnumValueValidation = [ "enum-large.schema", "optional-enum.schema", "const-non-string.schema", + "nullable-optional-one-of.schema", "all-of-additional-properties-false.schema", ]; @@ -551,9 +552,10 @@ export const GoLanguage: Language = { rendererOptions: {}, quickTestRendererOptions: [ // Runs against the expected-output file - // `omit-empty.out.omit-empty.json`, which asserts that `omitempty` - // actually drops the null field. + // `omit-empty.out.omit-empty.json`, which asserts that nullable + // fields preserve null instead of being omitted. ["omit-empty.json", { "omit-empty": "true" }], + ["nullable-optional-one-of.schema", { "omit-empty": "true" }], ], sourceFiles: ["src/language/Golang/index.ts"], }; @@ -630,6 +632,7 @@ export const CJSONLanguage: Language = { /* Enum with invalid values are not checked (for the current implementation, can be added later, should abord parsing and return NULL) */ ...skipsEnumValueValidation, /* Union, Map and Arrays with invalid types are not checked (for the current implementation, can be added later, should abord parsing and return NULL) */ + "boolean-subschema.schema", "class-with-additional.schema", ...skipsMapValueValidation, "multi-type-enum.schema", @@ -650,6 +653,11 @@ export const CJSONLanguage: Language = { "direct-union.schema", "optional-any.schema", "recursive-union-flattening.schema", + /* Self-referential union member (a union whose member recursively + * refers back to the enclosing object) is not supported by the + * multi-source renderer; generation aborts. Pre-existing cJSON + * limitation, unrelated to the Rust fixture this schema targets. */ + "rust-cycle-breaker-union.schema", "required-non-properties.schema", /* Class elements with invalid type are not checked (for the current implementation, can be added later, should abord parsing and return NULL) */ ...skipsUntypedUnions, @@ -808,6 +816,7 @@ export const CPlusPlusLanguage: Language = { // boost and std optional/variant code paths differ. ["unions.json", { boost: "true" }], ["pokedex.json", { boost: "true" }], + ["optional-any.schema", { "hide-null-optional": "true" }], ], sourceFiles: ["src/language/CPlusPlus/index.ts"], }; @@ -887,6 +896,7 @@ export const ElmLanguage: Language = { "vega-lite.schema", // recursion "simple-ref.schema", // recursion "recursive-union-flattening.schema", // recursion + "rust-cycle-breaker-union.schema", // recursion // elm/json's field decoder uses the JS `in` operator, which finds // inherited Object.prototype members, so an absent "constructor" // property decodes to the object's constructor function. @@ -987,6 +997,24 @@ export const SwiftLanguage: Language = { sourceFiles: ["src/language/Swift/index.ts"], }; +export const SwiftSendableObjectiveCSupportLanguage: Language = { + ...SwiftLanguage, + compileCommand: "node verify-sendable.cjs", + diffViaSchema: false, + includeJSON: ["pokedex.json"], + rendererOptions: { + ...SwiftLanguage.rendererOptions, + sendable: "true", + "struct-or-class": "class", + "objective-c-support": "true", + }, + quickTestRendererOptions: [ + ["pokedex.json", { "struct-or-class": "struct" }], + ], + runCommand: undefined, + skipMiscJSON: true, +}; + export const ObjectiveCLanguage: Language = { name: "objective-c", base: "test/fixtures/objective-c", @@ -1650,6 +1678,7 @@ export const KotlinXLanguage: Language = { "mutually-recursive.schema", "prefix-items.schema", "recursive-union-flattening.schema", + "rust-cycle-breaker-union.schema", "tuple.schema", "union-int-double.schema", "union-list.schema", @@ -1857,6 +1886,7 @@ 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. + "boolean-subschema.schema", "nested-intersection-union.schema", "prefix-items.schema", "direct-union.schema", @@ -1906,6 +1936,7 @@ export const PHPLanguage: Language = { // The motivating repro for non-nullable union support: a // heterogeneous array under a PHP-reserved-word property name. "php-mixed-union.json", + "php-validation.json", ], skipMiscJSON: true, skipSchema: [ @@ -2210,7 +2241,9 @@ export const ElixirLanguage: Language = { // Struct keys cannot be enforced at runtime in Elixir and their values will just be set to null. "strict-optional.schema", "required.schema", + "boolean-subschema.schema", "intersection.schema", + "optional-any.schema", // The test incorrectly succeeds due to the emitter being permissive for unions that contain only primitives. A future enhancement // for the Elixir emitter could be a user-controlled 'strict' mode that pattern matches even on unions of only primitive types. diff --git a/test/unit/cjson-enum-default.test.ts b/test/unit/cjson-enum-default.test.ts index b2c02d60ee..0ecd24cb92 100644 --- a/test/unit/cjson-enum-default.test.ts +++ b/test/unit/cjson-enum-default.test.ts @@ -28,9 +28,9 @@ describe("cJSON enum invalid value", () => { const output = await cJSONOutput(); expect(output).toContain(`enum Subscription { - SUBSCRIPTION_CONFIG = 1, + SUBSCRIPTION_STATE = 1, + SUBSCRIPTION_CONFIG, SUBSCRIPTION_HEARTBEAT, - SUBSCRIPTION_STATE, };`); expect(output).toContain("enum Subscription x = 0;"); }); diff --git a/test/unit/cli-epipe.test.ts b/test/unit/cli-epipe.test.ts new file mode 100644 index 0000000000..460dbe36eb --- /dev/null +++ b/test/unit/cli-epipe.test.ts @@ -0,0 +1,57 @@ +import { spawn } from "node:child_process"; +import * as path from "node:path"; + +import { describe, expect, test } from "vitest"; + +const repositoryRoot = process.cwd(); + +function runWithClosedStdout(): Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + stderr: string; +}> { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + path.join(repositoryRoot, "dist", "index.js"), + "--src-lang", + "schema", + "--lang", + "swift", + "--just-types", + path.join( + repositoryRoot, + "test", + "inputs", + "schema", + "vega-lite.schema", + ), + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let stderr = ""; + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code, signal) => { + resolve({ code, signal, stderr }); + }); + + child.stdout.destroy(); + }); +} + +describe("CLI output", () => { + test("exits successfully when stdout is closed early", async () => { + const result = await runWithClosedStdout(); + + expect(result.signal).toBeNull(); + expect(result.code).toBe(0); + expect(result.stderr).not.toContain("Error: write EPIPE"); + expect(result.stderr).not.toContain("Unhandled 'error' event"); + }); +}); diff --git a/test/unit/csharp-partial-helpers.test.ts b/test/unit/csharp-partial-helpers.test.ts new file mode 100644 index 0000000000..9f472a6e36 --- /dev/null +++ b/test/unit/csharp-partial-helpers.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; + +import { + InputData, + type RendererOptions, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; + +async function cSharpFor(framework: string): Promise { + const jsonInput = jsonInputForTargetLanguage("csharp"); + await jsonInput.addSource({ + name: "Something", + samples: ['{"some_property":"hello"}'], + }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + const rendererOptions = { framework } as RendererOptions; + const result = await quicktype({ + inputData, + lang: "csharp", + rendererOptions, + }); + return result.lines.join("\n"); +} + +describe("C# helper classes", () => { + for (const framework of ["NewtonSoft", "SystemTextJson"]) { + test(`${framework} helpers are partial`, async () => { + const output = await cSharpFor(framework); + expect(output).toContain("public static partial class Serialize"); + expect(output).toContain("internal static partial class Converter"); + }); + } +}); diff --git a/test/unit/just-types-option.test.ts b/test/unit/just-types-option.test.ts index 26f435bbae..cac04a5e15 100644 --- a/test/unit/just-types-option.test.ts +++ b/test/unit/just-types-option.test.ts @@ -3,15 +3,20 @@ // `features=just-types` / `features=just-types-and-namespace` and Kotlin's, // Scala 3's, and Smithy4s's `framework=just-types` are gone; the boolean // wins over a conflicting explicit enum value. +import * as fs from "node:fs"; +import * as path from "node:path"; + import { describe, expect, test } from "vitest"; import { InputData, + JSONSchemaInput, type LanguageName, type RendererOptions, jsonInputForTargetLanguage, quicktype, } from "quicktype-core"; +import { schemaForTypeScriptSources } from "quicktype-typescript-input"; async function linesFor( lang: LanguageName, @@ -28,6 +33,29 @@ async function linesFor( return result.lines.join("\n"); } +async function kotlinLinesForTypeScript(source: string): Promise { + const temporaryDirectory = fs.mkdtempSync( + path.join(process.cwd(), ".tmp-kotlin-just-types-test-"), + ); + const fileName = path.join(temporaryDirectory, "input.ts"); + + try { + fs.writeFileSync(fileName, source); + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource(schemaForTypeScriptSources([fileName])); + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ + inputData, + lang: "kotlin", + rendererOptions: { "just-types": true }, + }); + return result.lines.join("\n"); + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +} + describe("just-types generates plain types in every language", () => { test("C#: no attributes, no helpers, but a namespace", async () => { const output = await linesFor("csharp", { "just-types": true }); @@ -43,6 +71,31 @@ describe("just-types generates plain types in every language", () => { expect(output).not.toContain("Klaxon"); }); + test("Kotlin: enum cases retain their TypeScript string values", async () => { + const output = await kotlinLinesForTypeScript(` + export enum CanvasAction { + ADD = "add", + BRING_TO_FRONT = "bringtofront", + DELETE = "delete", + FLIP = "flip", + INVERT = "invert", + UNDO = "undo", + REDO = "redo", + } + + export interface Canvas { + action: CanvasAction; + } + `); + + expect(output).toContain("enum class CanvasAction(val value: String)"); + expect(output).toContain('Add("add")'); + expect(output).toContain('Bringtofront("bringtofront")'); + expect(output).toContain( + "fun fromValue(value: String): CanvasAction = when (value)", + ); + }); + test("Scala 3: plain case classes, no circe", async () => { const output = await linesFor("scala3", { "just-types": true }); expect(output).toContain("case class Person"); diff --git a/test/unit/php-validation.test.ts b/test/unit/php-validation.test.ts new file mode 100644 index 0000000000..2d26be8ba1 --- /dev/null +++ b/test/unit/php-validation.test.ts @@ -0,0 +1,82 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function phpForSchema(schema: object): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "ScalarValidation", + schema: JSON.stringify(schema), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ inputData, lang: "php" }); + return result.lines.join("\n"); +} + +function validationMethod(php: string, propertyName: string): string { + const start = php.indexOf( + `public static function validate${propertyName}(`, + ); + if (start < 0) { + throw new Error( + `No validate${propertyName} method found in generated PHP`, + ); + } + const end = php.indexOf("\n /**", start); + return php.slice(start, end < 0 ? undefined : end); +} + +describe("PHP property validation", () => { + test("does not recheck scalar parameter types", async () => { + const php = await phpForSchema({ + type: "object", + properties: { + boolean: { type: "boolean" }, + integer: { type: "integer" }, + number: { type: "number" }, + nullableNumber: { type: ["number", "null"] }, + string: { type: "string" }, + integers: { type: "array", items: { type: "integer" } }, + scalarUnion: { + oneOf: [{ type: "integer" }, { type: "string" }], + }, + }, + required: [ + "boolean", + "integer", + "number", + "nullableNumber", + "string", + "integers", + "scalarUnion", + ], + }); + + for (const property of [ + "Boolean", + "Integer", + "Number", + "NullableNumber", + "String", + ]) { + expect(validationMethod(php, property)).not.toMatch( + /is_(?:bool|float|int|integer|string)\(/, + ); + } + + expect(validationMethod(php, "Integers")).toContain( + "if (!is_integer($value_v))", + ); + expect(validationMethod(php, "ScalarUnion")).toContain( + "if (is_int($value))", + ); + expect(validationMethod(php, "ScalarUnion")).toContain( + "elseif (is_string($value))", + ); + }); +}); diff --git a/test/unit/python-typing-type.test.ts b/test/unit/python-typing-type.test.ts new file mode 100644 index 0000000000..110d6fe1a6 --- /dev/null +++ b/test/unit/python-typing-type.test.ts @@ -0,0 +1,79 @@ +// Python fixtures exercise every version preset, but cannot verify that the +// generated imports also work on the first Python 3.6 micro-version. +import { describe, expect, test } from "vitest"; + +import { + InputData, + JSONSchemaInput, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; + +const schema = { + type: "object", + properties: { + child: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + status: { type: "string", enum: ["ready", "done"] }, + value: { oneOf: [{ type: "integer" }, { type: "string" }] }, + }, + required: ["child", "status", "value"], +}; + +async function pythonFor(version: "3.6" | "3.7"): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify(schema), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + const schemaResult = await quicktype({ + inputData, + lang: "python", + rendererOptions: { "python-version": version }, + }); + + // An inferred integer-string union exercises the is_type helper. + const jsonInput = jsonInputForTargetLanguage("python"); + await jsonInput.addSource({ + name: "MixedValues", + samples: ['{"mixed":[null,1,"1",{}]}'], + }); + const jsonInputData = new InputData(); + jsonInputData.addInput(jsonInput); + const jsonResult = await quicktype({ + inputData: jsonInputData, + lang: "python", + rendererOptions: { "python-version": version }, + }); + + return [...schemaResult.lines, ...jsonResult.lines].join("\n"); +} + +describe("Python typing.Type compatibility (issue #1728)", () => { + test("Python 3.6 does not import or use typing.Type", async () => { + const output = await pythonFor("3.6"); + + expect(output).toContain('T = TypeVar("T")'); + expect(output).not.toMatch(/from typing import [^\n]*\bType\b/); + expect(output).not.toMatch(/\bType\[/); + expect(output).toContain("def to_class(c, x: Any) -> dict:"); + expect(output).toContain("def to_enum(c, x: Any) -> EnumT:"); + expect(output).toContain("def is_type(t, x: Any) -> T:"); + }); + + test("Python 3.7 keeps typing.Type annotations", async () => { + const output = await pythonFor("3.7"); + + expect(output).toMatch(/from typing import [^\n]*\bType\b/); + expect(output).toContain("def to_class(c: Type[T], x: Any) -> dict:"); + expect(output).toContain( + "def to_enum(c: Type[EnumT], x: Any) -> EnumT:", + ); + expect(output).toContain("def is_type(t: Type[T], x: Any) -> T:"); + }); +}); diff --git a/test/unit/rust-cycle-breaker-boxing.test.ts b/test/unit/rust-cycle-breaker-boxing.test.ts new file mode 100644 index 0000000000..c738c27697 --- /dev/null +++ b/test/unit/rust-cycle-breaker-boxing.test.ts @@ -0,0 +1,48 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function renderRust(next: object, requireNext = false): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "Node", + schema: JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + title: "Node", + type: "object", + properties: { + value: { type: "string" }, + next, + }, + required: requireNext ? ["value", "next"] : ["value"], + }), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "rust" }); + return result.lines.join("\n"); +} + +const recursiveUnionMembers = [{ $ref: "#" }, { type: "string" }]; + +describe("Rust cycle-breaker boxing", () => { + test("does not box an Option whose union member is already boxed", async () => { + const output = await renderRust({ + anyOf: [...recursiveUnionMembers, { type: "null" }], + }); + + expect(output).toContain("pub next: Option>,"); + expect(output).not.toContain("Box>>"); + }); + + test("keeps a non-nullable cycle-breaking union boxed", async () => { + const output = await renderRust({ anyOf: recursiveUnionMembers }, true); + + expect(output).toContain("pub next: Box,"); + }); +}); diff --git a/test/unit/rust-empty-top-levels.test.ts b/test/unit/rust-empty-top-levels.test.ts new file mode 100644 index 0000000000..f5cc68f3fb --- /dev/null +++ b/test/unit/rust-empty-top-levels.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "vitest"; + +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; + +async function renderTypeScriptSchema(definitions: object): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "", + schema: JSON.stringify({ definitions }), + uris: ["#/definitions/"], + isConverted: true, + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "rust" }); + return result.lines.join("\n"); +} + +// TypeScript inputs without a #TopLevel marker use #/definitions/ as their +// source URI. If the compiler did not produce any definitions, the renderer +// receives no top levels and must still emit Rust without crashing. +test("Rust renders TypeScript schemas with no top levels", async () => { + const output = await renderTypeScriptSchema({}); + + expect(output).toContain("use serde::{Serialize, Deserialize};"); +}); + +test("Rust still renders named TypeScript schema definitions", async () => { + const output = await renderTypeScriptSchema({ + Person: { + type: "object", + properties: { age: { type: "integer" } }, + required: ["age"], + }, + }); + + expect(output).toContain("pub struct Person"); +});