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
54 changes: 54 additions & 0 deletions packages/quicktype-core/src/attributes/PropertyNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { setIntersect, setUnion } from "collection-utils";

import type { Type, TypeKind } from "../Type/index.js";

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

/**
* The keys a JSON Schema `propertyNames` constraint allows, when it restricts
* them to a finite set of strings. Kept as plain data rather than a graph type
* so unrelated renderers never see a key enum they can't use.
*/
class PropertyNamesTypeAttributeKind extends TypeAttributeKind<
ReadonlySet<string>
> {
public constructor() {
super("propertyNames");
}

public appliesToTypeKind(kind: TypeKind): boolean {
return kind === "object" || kind === "class" || kind === "map";
}

// A union of objects allows the union of their keys.
public combine(attrs: Array<ReadonlySet<string>>): ReadonlySet<string> {
return setUnion(...attrs);
}

// Only keys every side allows survive; empty means a contradiction we
// don't try to express.
public intersect(
attrs: Array<ReadonlySet<string>>,
): ReadonlySet<string> | undefined {
const cases = attrs.reduce(setIntersect);
return cases.size === 0 ? undefined : cases;
}

// A schema-stated fact, not a weakening guess, so it stays as-is.
public makeInferred(cases: ReadonlySet<string>): ReadonlySet<string> {
return cases;
}

public stringify(cases: ReadonlySet<string>): string {
return `propertyNames: ${Array.from(cases).join(", ")}`;
}
}

export const propertyNamesTypeAttributeKind: TypeAttributeKind<
ReadonlySet<string>
> = new PropertyNamesTypeAttributeKind();

/** The keys `t` restricts its properties to, if it restricts them. */
export function propertyNamesForType(t: Type): ReadonlySet<string> | undefined {
return propertyNamesTypeAttributeKind.tryGetInAttributes(t.getAttributes());
}
69 changes: 68 additions & 1 deletion packages/quicktype-core/src/input/JSONSchemaInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { defaultValueAttributeProducer } from "../attributes/DefaultValue.js";
import { descriptionAttributeProducer } from "../attributes/Description.js";
import { enumValuesAttributeProducer } from "../attributes/EnumValues.js";
import { propertyNamesTypeAttributeKind } from "../attributes/PropertyNames.js";
import { StringTypes } from "../attributes/StringTypes.js";
import {
type TypeAttributes,
Expand Down Expand Up @@ -799,6 +800,23 @@ class Resolver {
}
}

/** The strings a schema restricts to, if it's a finite set. A mixed-type
* `enum` doesn't count, since the non-string cases can't be property names. */
function finiteStringCases(
schema: JSONSchema,
): ReadonlySet<string> | undefined {
if (typeof schema !== "object" || schema === null) return undefined;
if (typeof schema.const === "string") return new Set([schema.const]);
if (!Array.isArray(schema.enum)) return undefined;

const cases = schema.enum.filter((c) => typeof c === "string");
if (cases.length === 0 || cases.length !== schema.enum.length) {
return undefined;
}

return new Set(cases);
}

async function addTypesInSchema(
resolver: Resolver,
typeBuilder: TypeBuilder,
Expand Down Expand Up @@ -827,6 +845,7 @@ async function addTypesInSchema(
additionalProperties: unknown,
sortKey: (k: string) => number | string = (k: string): string =>
k.toLowerCase(),
propertyNames?: ReadonlySet<string>,
): Promise<TypeRef> {
const required = new Set(requiredArray);
const propertiesMap = mapSortBy(mapFromObject(properties), (_, k) =>
Expand Down Expand Up @@ -865,6 +884,29 @@ async function addTypesInSchema(
);
}

// Required keys can't live in a map, so expand a finite key set into
// explicit properties (#1959). Required keys outside that set fall
// through to the check below, which rejects them.
const valueType = additionalPropertiesType;
const expandKeyCases =
propertyNames !== undefined &&
props.size === 0 &&
required.size > 0 &&
valueType !== undefined;
if (expandKeyCases) {
const caseProps = mapSortBy(
mapFromIterable(propertyNames, (name) =>
typeBuilder.makeClassProperty(
valueType,
!required.has(name),
),
),
(_, k) => sortKey(k),
);
mapMergeInto(props, caseProps);
additionalPropertiesType = undefined;
}

const additionalRequired = setSubtract(required, props.keys());
if (additionalRequired.size > 0) {
const t = additionalPropertiesType;
Expand All @@ -882,8 +924,17 @@ async function addTypesInSchema(
mapMergeInto(props, additionalProps);
}

let objectAttributes = attributes;
if (propertyNames !== undefined && !expandKeyCases) {
objectAttributes = combineTypeAttributes(
"union",
attributes,
propertyNamesTypeAttributeKind.makeAttributes(propertyNames),
);
}

return typeBuilder.getUniqueObjectType(
attributes,
objectAttributes,
props,
additionalPropertiesType,
);
Expand Down Expand Up @@ -1112,6 +1163,20 @@ async function addTypesInSchema(
return typeBuilder.getArrayType(arrayAttributes, itemType);
}

/** The finite key set `propertyNames` allows, if any; anything else
* (`pattern`, `$ref`, ...) leaves the keys unrestricted. */
function makePropertyNames(): ReadonlySet<string> | undefined {
if (schema.propertyNames === undefined) return undefined;

const propertyNamesLoc = loc.push("propertyNames");
return finiteStringCases(
checkJSONSchema(
schema.propertyNames,
propertyNamesLoc.canonicalRef,
),
);
}

async function makeObjectType(): Promise<TypeRef> {
let required: string[];
if (
Expand Down Expand Up @@ -1181,6 +1246,7 @@ async function addTypesInSchema(
required,
additionalProperties,
orderKey,
makePropertyNames(),
);
}

Expand Down Expand Up @@ -1280,6 +1346,7 @@ async function addTypesInSchema(
schema.properties !== undefined ||
schema.additionalProperties !== undefined ||
schema.unevaluatedProperties !== undefined ||
schema.propertyNames !== undefined ||
schema.items !== undefined ||
schema.prefixItems !== undefined ||
schema.required !== undefined ||
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { arrayIntercalate } from "collection-utils";

import { propertyNamesForType } from "../../attributes/PropertyNames.js";
import { ConvenienceRenderer } from "../../ConvenienceRenderer.js";
import { type Name, type Namer, funPrefixNamer } from "../../Naming.js";
import type { RenderContext } from "../../Renderer.js";
Expand All @@ -21,6 +22,7 @@ import type { TargetLanguage } from "../../TargetLanguage.js";
import type {
ClassProperty,
ClassType,
MapType,
ObjectType,
Type,
} from "../../Type/index.js";
Expand Down Expand Up @@ -127,7 +129,12 @@ export class JavaScriptRenderer extends ConvenienceRenderer {
(_stringType) => '""',
(arrayType) => ["a(", this.typeMapTypeFor(arrayType.items), ")"],
(_classType) => panic("We handled this above"),
(mapType) => ["m(", this.typeMapTypeFor(mapType.values), ")"],
(mapType) => [
"m(",
this.typeMapTypeFor(mapType.values),
...this.typeMapKeysFor(mapType),
")",
],
(_enumType) => panic("We handled this above"),
(unionType) => {
const children = Array.from(unionType.getChildren()).map(
Expand All @@ -145,6 +152,16 @@ export class JavaScriptRenderer extends ConvenienceRenderer {
);
}

/** Extra `m()` argument listing allowed keys, so the converter can reject
* others. Empty when the map's keys are unconstrained. */
private typeMapKeysFor(mapType: MapType): Sourcelike[] {
const keys = propertyNamesForType(mapType);
if (keys === undefined) return [];

const cases = Array.from(keys).map((c) => `"${utf16StringEscape(c)}"`);
return [", [", cases.join(", "), "]"];
}

private typeMapTypeForProperty(p: ClassProperty): Sourcelike {
const typeMap = this.typeMapTypeFor(p.type);
if (!p.isOptional) {
Expand Down Expand Up @@ -413,7 +430,7 @@ function transform(val${anyAnnotation}, typ${anyAnnotation}, getProps${anyAnnota
return d;
}

function transformObject(props${anyMapAnnotation}, additional${anyAnnotation}, val${anyAnnotation})${anyAnnotation} {
function transformObject(props${anyMapAnnotation}, additional${anyAnnotation}, val${anyAnnotation}, keys${anyAnnotation})${anyAnnotation} {
if (val === null || typeof val !== "object" || Array.isArray(val)) {
return invalidValue(l(ref || "object"), val, key, parent);
}
Expand All @@ -425,6 +442,9 @@ function transform(val${anyAnnotation}, typ${anyAnnotation}, getProps${anyAnnota
});
Object.getOwnPropertyNames(val).forEach(key => {
if (!Object.prototype.hasOwnProperty.call(props, key)) {
if (keys !== undefined && keys.indexOf(key) < 0) {
return invalidValue(l(keys.map((k${stringAnnotation}) => JSON.stringify(k)).join(" | ")), key, key, ref);
}
result[key] = ${
this._jsOptions.runtimeTypecheckIgnoreUnknownProperties
? "val[key]"
Expand All @@ -450,7 +470,7 @@ function transform(val${anyAnnotation}, typ${anyAnnotation}, getProps${anyAnnota
if (typeof typ === "object") {
return typ.hasOwnProperty("unionMembers") ? transformUnion(typ.unionMembers, val)
: typ.hasOwnProperty("arrayItems") ? transformArray(typ.arrayItems, val)
: typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val)
: typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val, typ.keys)
: invalidValue(typ, val, key, parent);
}
// Numbers can be parsed by Date but shouldn't be.
Expand Down Expand Up @@ -482,9 +502,9 @@ function o(props${anyArrayAnnotation}, additional${anyAnnotation}) {
return { props, additional };
}

function m(additional${anyAnnotation}) {
function m(additional${anyAnnotation}, keys${anyAnnotation} = undefined) {
const props${anyArrayAnnotation} = [];
return { props, additional };
return { props, additional, keys };
}

function r(name${stringAnnotation}) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ArrayType,
type ClassType,
EnumType,
type MapType,
ObjectType,
type Type,
UnionType,
Expand Down Expand Up @@ -79,6 +80,16 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer {
);
}

/** Overridden by TypeScript to spell out a `propertyNames` key constraint;
* Flow has no mapped types. */
protected sourceForMapType(mapType: MapType): MultiWord {
return singleWord([
"{ [key: string]: ",
this.sourceFor(mapType.values).source,
" }",
]);
}

// 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`
Expand Down Expand Up @@ -126,12 +137,7 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer {
(_stringType) => singleWord("string"),
(arrayType) => this.sourceForArrayType(arrayType),
(_classType) => panic("We handled this above"),
(mapType) =>
singleWord([
"{ [key: string]: ",
this.sourceFor(mapType.values).source,
" }",
]),
(mapType) => this.sourceForMapType(mapType),
(_enumType) => panic("We handled this above"),
(unionType) => {
if (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { minMaxItemsForType } from "../../attributes/Constraints.js";
import { propertyNamesForType } from "../../attributes/PropertyNames.js";
import type { Name } from "../../Naming.js";
import {
type MultiWord,
Expand All @@ -8,7 +9,13 @@ import {
singleWord,
} from "../../Source.js";
import { camelCase, utf16StringEscape } from "../../support/Strings.js";
import type { ArrayType, ClassType, EnumType, Type } from "../../Type/index.js";
import type {
ArrayType,
ClassType,
EnumType,
MapType,
Type,
} from "../../Type/index.js";
import { isNamedType } from "../../Type/TypeUtils.js";
import type { JavaScriptTypeAnnotations } from "../JavaScript/index.js";

Expand Down Expand Up @@ -58,6 +65,26 @@ export class TypeScriptRenderer extends TypeScriptFlowBaseRenderer {
return singleWord(source);
}

// `propertyNames` restricts the allowed keys, not which are present, so
// this is `Partial<Record<Key, V>>` rather than a bare `Record` (which
// would require every key) or an index signature (`error TS1337`).
protected sourceForMapType(mapType: MapType): MultiWord {
const keys = propertyNamesForType(mapType);
if (keys === undefined) {
return super.sourceForMapType(mapType);
}

return singleWord([
"Partial<Record<",
Array.from(keys)
.map((c) => `"${utf16StringEscape(c)}"`)
.join(" | "),
", ",
this.sourceFor(mapType.values).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
Expand Down
6 changes: 6 additions & 0 deletions test/inputs/schema/property-names.1.fail.property-names.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"icon": "flag",
"languages": {
"fr": { "name": "Français" }
}
}
7 changes: 7 additions & 0 deletions test/inputs/schema/property-names.1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"icon": "flag",
"languages": {
"de": { "name": "Deutsch", "description": "German" },
"en": { "name": "English" }
}
}
4 changes: 4 additions & 0 deletions test/inputs/schema/property-names.2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"icon": "flag",
"languages": {}
}
25 changes: 25 additions & 0 deletions test/inputs/schema/property-names.schema
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"required": ["icon", "languages"],
"properties": {
"icon": { "type": "string" },
"languages": {
"type": "object",
"propertyNames": {
"type": "string",
"enum": ["de", "en"]
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": { "type": "string" },
"description": { "type": "string" }
}
}
}
}
}
Loading