diff --git a/packages/quicktype-core/src/GraphRewriting.ts b/packages/quicktype-core/src/GraphRewriting.ts index 69d1781ffb..7e0ece821f 100644 --- a/packages/quicktype-core/src/GraphRewriting.ts +++ b/packages/quicktype-core/src/GraphRewriting.ts @@ -189,8 +189,14 @@ export class TypeReconstituter { } public getUniqueArrayType(): void { - this.registerAndAddAttributes( - this.builderForNewType().getUniqueArrayType(this._forwardingRef), + // The attributes must be passed at creation, not added + // afterwards with `addAttributes`, which would assert on + // identity attributes such as minItems/maxItems. + this.register( + this.builderForNewType().getUniqueArrayType( + this._typeAttributes, + this._forwardingRef, + ), ); } diff --git a/packages/quicktype-core/src/Type/TypeBuilder.ts b/packages/quicktype-core/src/Type/TypeBuilder.ts index bb2edba208..d358c6175b 100644 --- a/packages/quicktype-core/src/Type/TypeBuilder.ts +++ b/packages/quicktype-core/src/Type/TypeBuilder.ts @@ -479,11 +479,14 @@ export class TypeBuilder { this.registerType(type); } - public getUniqueArrayType(forwardingRef?: TypeRef): TypeRef { + public getUniqueArrayType( + attributes?: TypeAttributes, + forwardingRef?: TypeRef, + ): TypeRef { return this.addType( forwardingRef, (tr) => new ArrayType(tr, this.typeGraph, undefined), - undefined, + attributes, ); } diff --git a/packages/quicktype-core/src/attributes/Constraints.ts b/packages/quicktype-core/src/attributes/Constraints.ts index 4ef6561866..7f2ca6f5c0 100644 --- a/packages/quicktype-core/src/attributes/Constraints.ts +++ b/packages/quicktype-core/src/attributes/Constraints.ts @@ -130,6 +130,14 @@ export const minMaxLengthTypeAttributeKind: TypeAttributeKind "maxLength", ); +export const minMaxItemsTypeAttributeKind: TypeAttributeKind = + new MinMaxConstraintTypeAttributeKind( + "minMaxItems", + new Set(["array"]), + "minItems", + "maxItems", + ); + function producer( schema: JSONSchema, minProperty: string, @@ -179,6 +187,20 @@ export function minMaxLengthAttributeProducer( }; } +export function minMaxItemsAttributeProducer( + schema: JSONSchema, + _ref: Ref, + types: Set, +): JSONSchemaAttributes | undefined { + if (!types.has("array")) return undefined; + + const maybeMinMaxItems = producer(schema, "minItems", "maxItems"); + if (maybeMinMaxItems === undefined) return undefined; + return { + forArray: minMaxItemsTypeAttributeKind.makeAttributes(maybeMinMaxItems), + }; +} + export function minMaxValueForType(t: Type): MinMaxConstraint | undefined { return minMaxTypeAttributeKind.tryGetInAttributes(t.getAttributes()); } @@ -187,6 +209,10 @@ export function minMaxLengthForType(t: Type): MinMaxConstraint | undefined { return minMaxLengthTypeAttributeKind.tryGetInAttributes(t.getAttributes()); } +export function minMaxItemsForType(t: Type): MinMaxConstraint | undefined { + return minMaxItemsTypeAttributeKind.tryGetInAttributes(t.getAttributes()); +} + export class PatternTypeAttributeKind extends TypeAttributeKind { public constructor() { super("pattern"); diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 5a931731e9..efd2ccbef9 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -25,6 +25,7 @@ import URI from "urijs"; import { accessorNamesAttributeProducer } from "../attributes/AccessorNames.js"; import { minMaxAttributeProducer, + minMaxItemsAttributeProducer, minMaxLengthAttributeProducer, patternAttributeProducer, } from "../attributes/Constraints.js"; @@ -649,6 +650,7 @@ const schemaTypes = Object.getOwnPropertyNames( ) as JSONSchemaType[]; export interface JSONSchemaAttributes { + forArray?: TypeAttributes; forCases?: TypeAttributes[]; forNumber?: TypeAttributes; forObject?: TypeAttributes; @@ -1031,7 +1033,9 @@ async function addTypesInSchema( return typeBuilder.getPrimitiveType(kind, attributes); } - async function makeArrayType(): Promise { + async function makeArrayType( + arrayAttributes: TypeAttributes, + ): Promise { const singularAttributes = singularizeTypeNames(typeAttributes); const items = schema.items; // JSON Schema 2020-12 renamed the array (tuple) form of `items` to @@ -1097,7 +1101,7 @@ async function addTypesInSchema( } typeBuilder.addAttributes(itemType, singularAttributes); - return typeBuilder.getArrayType(emptyTypeAttributes, itemType); + return typeBuilder.getArrayType(arrayAttributes, itemType); } async function makeObjectType(): Promise { @@ -1322,7 +1326,10 @@ async function addTypesInSchema( } if (includeArray) { - unionTypes.push(await makeArrayType()); + const arrayAttributes = combineProducedAttributes( + ({ forArray }) => forArray, + ); + unionTypes.push(await makeArrayType(arrayAttributes)); } if (includeObject) { @@ -1557,6 +1564,7 @@ export class JSONSchemaInput implements Input { uriSchemaAttributesProducer, minMaxAttributeProducer, minMaxLengthAttributeProducer, + minMaxItemsAttributeProducer, patternAttributeProducer, ].concat(additionalAttributeProducers); } diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts index d833317d86..efcdbb2cee 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts @@ -47,6 +47,23 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { return super.namerForObjectProperty(); } + // Flow (pinned at flow-bin 0.66 in CI) has no tuple-rest syntax, so + // the base implementation always renders plain array types; the + // TypeScript renderer overrides this to spell out `minItems` + // guarantees as a tuple. + protected sourceForArrayType(arrayType: ArrayType): MultiWord { + const itemType = this.sourceFor(arrayType.items); + if ( + (arrayType.items instanceof UnionType && + !this._tsFlowOptions.declareUnions) || + arrayType.items instanceof ArrayType + ) { + return singleWord(["Array<", itemType.source, ">"]); + } + + return singleWord([parenIfNeeded(itemType), "[]"]); + } + protected sourceFor(t: Type): MultiWord { if ( this._tsFlowOptions.preferConstValues && @@ -70,18 +87,7 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { (_integerType) => singleWord("number"), (_doubleType) => singleWord("number"), (_stringType) => singleWord("string"), - (arrayType) => { - const itemType = this.sourceFor(arrayType.items); - if ( - (arrayType.items instanceof UnionType && - !this._tsFlowOptions.declareUnions) || - arrayType.items instanceof ArrayType - ) { - return singleWord(["Array<", itemType.source, ">"]); - } - - return singleWord([parenIfNeeded(itemType), "[]"]); - }, + (arrayType) => this.sourceForArrayType(arrayType), (_classType) => panic("We handled this above"), (mapType) => singleWord([ diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts index 1d0d488424..f1c63f6bf3 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts @@ -1,13 +1,25 @@ +import { minMaxItemsForType } from "../../attributes/Constraints.js"; import type { Name } from "../../Naming.js"; -import { type Sourcelike, modifySource } from "../../Source.js"; +import { + type MultiWord, + type Sourcelike, + modifySource, + parenIfNeeded, + singleWord, +} from "../../Source.js"; import { camelCase, utf16StringEscape } from "../../support/Strings.js"; -import type { ClassType, EnumType, Type } from "../../Type/index.js"; +import type { ArrayType, ClassType, EnumType, Type } from "../../Type/index.js"; import { isNamedType } from "../../Type/TypeUtils.js"; import type { JavaScriptTypeAnnotations } from "../JavaScript/index.js"; import { TypeScriptFlowBaseRenderer } from "./TypeScriptFlowBaseRenderer.js"; import { tsFlowTypeAnnotations } from "./utils.js"; +// An array type with a huge `minItems` would otherwise expand into an +// equally huge tuple type, so beyond this limit we fall back to a plain +// array type. +const maxSpelledOutMinItems = 16; + export class TypeScriptRenderer extends TypeScriptFlowBaseRenderer { protected anyType(): string { return this._tsFlowOptions.preferUnknown ? "unknown" : "any"; @@ -17,6 +29,31 @@ export class TypeScriptRenderer extends TypeScriptFlowBaseRenderer { return ["Array", "Date"]; } + // An array with `minItems` >= 1 becomes a tuple that spells out the + // guaranteed elements, followed by a rest element: `minItems: 2` + // renders as `[T, T, ...T[]]`. Only `minItems` shapes the type; + // `maxItems` is enforced by none of the generated code, and spelling + // it out would enumerate every allowed arity as its own tuple. + protected sourceForArrayType(arrayType: ArrayType): MultiWord { + const minItems = minMaxItemsForType(arrayType)?.[0]; + if ( + minItems === undefined || + minItems < 1 || + minItems > maxSpelledOutMinItems + ) { + return super.sourceForArrayType(arrayType); + } + + const itemType = this.sourceFor(arrayType.items); + const source: Sourcelike[] = ["["]; + for (let i = 0; i < minItems; i++) { + source.push(itemType.source, ", "); + } + + source.push("...", parenIfNeeded(itemType), "[]]"); + return singleWord(source); + } + protected uncheckedParsedJson(t: Type, parsedJson: Sourcelike): Sourcelike { // With `raw-type any` and `prefer-unknown` the deserializer's // parameter is `unknown`, which can't be returned as the target diff --git a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts index 6406101476..714c0adf11 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts @@ -1,5 +1,6 @@ import { arrayIntercalate } from "collection-utils"; +import { minMaxItemsForType } from "../../attributes/Constraints.js"; import { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; import { type Name, type Namer, funPrefixNamer } from "../../Naming.js"; import type { RenderContext } from "../../Renderer.js"; @@ -111,11 +112,25 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { (_integerType) => "z.number()", (_doubleType) => "z.number()", (_stringType) => "z.string()", - (arrayType) => [ - "z.array(", - this.typeMapTypeFor(arrayType.items, false), - ")", - ], + (arrayType) => { + const [minItems, maxItems] = + minMaxItemsForType(arrayType) ?? []; + + const arraySource: Sourcelike[] = [ + "z.array(", + this.typeMapTypeFor(arrayType.items, false), + ")", + ]; + if (minItems !== undefined && minItems > 0) { + arraySource.push(".min(", minItems.toString(10), ")"); + } + + if (maxItems !== undefined) { + arraySource.push(".max(", maxItems.toString(10), ")"); + } + + return arraySource; + }, (_classType) => panic("Should already be handled."), (_mapType) => [ "z.record(z.string(), ", diff --git a/test/inputs/schema/min-max-items.1.fail.minmaxitems.json b/test/inputs/schema/min-max-items.1.fail.minmaxitems.json new file mode 100644 index 0000000000..9e45bbc7ee --- /dev/null +++ b/test/inputs/schema/min-max-items.1.fail.minmaxitems.json @@ -0,0 +1,7 @@ +{ + "minOnly": ["one"], + "maxOnly": [1, 2, 3], + "minAndMax": [1.25, 2.5], + "plain": [], + "unionItems": [1, "two", 3] +} diff --git a/test/inputs/schema/min-max-items.1.json b/test/inputs/schema/min-max-items.1.json new file mode 100644 index 0000000000..0d5c53a959 --- /dev/null +++ b/test/inputs/schema/min-max-items.1.json @@ -0,0 +1,7 @@ +{ + "minOnly": ["one", "two"], + "maxOnly": [1, 2, 3], + "minAndMax": [1.25, 2.5], + "plain": [], + "unionItems": [1, "two", 3] +} diff --git a/test/inputs/schema/min-max-items.2.fail.minmaxitems.json b/test/inputs/schema/min-max-items.2.fail.minmaxitems.json new file mode 100644 index 0000000000..6db25cd8fb --- /dev/null +++ b/test/inputs/schema/min-max-items.2.fail.minmaxitems.json @@ -0,0 +1,7 @@ +{ + "minOnly": ["one", "two", "three"], + "maxOnly": [1, 2, 3, 4], + "minAndMax": [3.75], + "plain": ["only"], + "unionItems": ["one", 2] +} diff --git a/test/inputs/schema/min-max-items.2.json b/test/inputs/schema/min-max-items.2.json new file mode 100644 index 0000000000..7bb633b93d --- /dev/null +++ b/test/inputs/schema/min-max-items.2.json @@ -0,0 +1,7 @@ +{ + "minOnly": ["one", "two", "three"], + "maxOnly": [], + "minAndMax": [3.75], + "plain": ["only"], + "unionItems": ["one", 2] +} diff --git a/test/inputs/schema/min-max-items.schema b/test/inputs/schema/min-max-items.schema new file mode 100644 index 0000000000..7aaf9e8092 --- /dev/null +++ b/test/inputs/schema/min-max-items.schema @@ -0,0 +1,41 @@ +{ + "type": "object", + "properties": { + "minOnly": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 2 + }, + "maxOnly": { + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 3 + }, + "minAndMax": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "maxItems": 4 + }, + "plain": { + "type": "array", + "items": { + "type": "string" + } + }, + "unionItems": { + "type": "array", + "items": { + "type": ["integer", "string"] + }, + "minItems": 2 + } + }, + "required": ["minOnly", "maxOnly", "minAndMax", "plain", "unionItems"] +} diff --git a/test/languages.ts b/test/languages.ts index ebc4bcc2c9..fe34256b5a 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -66,6 +66,7 @@ export type LanguageFeature = | "uuid" | "minmax" | "minmaxlength" + | "minmaxitems" | "pattern"; export interface Language { @@ -1012,7 +1013,17 @@ export const TypeScriptLanguage: Language = { topLevel: "TopLevel", skipJSON: [], skipMiscJSON: false, - skipSchema: ["keyword-unions.schema"], // can't handle "constructor" property + skipSchema: [ + "keyword-unions.schema", // can't handle "constructor" property + // Pre-existing failures (this fixture is not in CI yet, and these + // fail with unmodified master too): objects with both declared + // properties and typed additionalProperties render as an interface + // whose properties are not assignable to its index signature + // (TS2411). + "class-map-union.schema", + "class-with-additional.schema", + "vega-lite.schema", + ], rendererOptions: { "explicit-unions": "yes" }, quickTestRendererOptions: [ { "runtime-typecheck": "false" }, @@ -1579,6 +1590,7 @@ export const KotlinXLanguage: Language = { "implicit-class-array-union.schema", "integer-float-union.schema", "integer-string.schema", + "min-max-items.schema", // unionItems is an int|string union array "minmaxlength.schema", "multi-type-enum.schema", "mutually-recursive.schema", @@ -1891,7 +1903,7 @@ export const TypeScriptZodLanguage: Language = { "e8b04.json", ], allowMissingNull: false, - features: ["enum", "union", "no-defaults", "date-time"], + features: ["enum", "union", "no-defaults", "date-time", "minmaxitems"], output: "TopLevel.ts", topLevel: "TopLevel", skipJSON: [