diff --git a/src/TypeFormatter/EnumTypeFormatter.ts b/src/TypeFormatter/EnumTypeFormatter.ts index b720f491d..e8a986563 100644 --- a/src/TypeFormatter/EnumTypeFormatter.ts +++ b/src/TypeFormatter/EnumTypeFormatter.ts @@ -1,3 +1,4 @@ +import type { JSONSchema7TypeName } from "json-schema"; import type { Definition } from "../Schema/Definition.js"; import type { SubTypeFormatter } from "../SubTypeFormatter.js"; import type { BaseType } from "../Type/BaseType.js"; @@ -17,11 +18,20 @@ export class EnumTypeFormatter implements SubTypeFormatter { // However, this formatter is used both for enum members and enum types, // so the side effect is that an enum type that contains just a single // value is represented as "const" too. - return values.length === 1 - ? { type: types[0], const: values[0] } - : { type: types.length === 1 ? types[0] : types, enum: values }; + return values.length === 1 ? { type: types[0], const: values[0] } : { type: toEnumType(types), enum: values }; } public getChildren(type: EnumType): BaseType[] { return []; } } + +/** + * Unwraps the array if it contains only one type. + */ +export function toEnumType(types: JSONSchema7TypeName[]) { + if (types.length === 1) { + return types[0]; + } + + return types; +} diff --git a/src/TypeFormatter/LiteralUnionTypeFormatter.ts b/src/TypeFormatter/LiteralUnionTypeFormatter.ts index f96e98b51..e6f69df00 100644 --- a/src/TypeFormatter/LiteralUnionTypeFormatter.ts +++ b/src/TypeFormatter/LiteralUnionTypeFormatter.ts @@ -2,36 +2,41 @@ import type { Definition } from "../Schema/Definition.js"; import type { RawTypeName } from "../Schema/RawType.js"; import type { SubTypeFormatter } from "../SubTypeFormatter.js"; import type { BaseType } from "../Type/BaseType.js"; -import type { LiteralValue } from "../Type/LiteralType.js"; -import { LiteralType } from "../Type/LiteralType.js"; +import { EnumType } from "../Type/EnumType.js"; +import { LiteralType, type LiteralValue } from "../Type/LiteralType.js"; import { NullType } from "../Type/NullType.js"; import { StringType } from "../Type/StringType.js"; import { UnionType } from "../Type/UnionType.js"; import { typeName } from "../Utils/typeName.js"; -import { uniqueArray } from "../Utils/uniqueArray.js"; +import { toEnumType } from "./EnumTypeFormatter.js"; export class LiteralUnionTypeFormatter implements SubTypeFormatter { public supportsType(type: BaseType): boolean { return type instanceof UnionType && type.getTypes().length > 0 && isLiteralUnion(type); } - public getDefinition(type: UnionType): Definition { + + public getDefinition(unionType: UnionType): Definition { let hasString = false; let preserveLiterals = false; let allStrings = true; let hasNull = false; - const literals = type.getFlattenedTypes(); + const literals = unionType.getFlattenedTypes(); // filter out String types since we need to be more careful about them - const types = literals.filter((t) => { - if (t instanceof StringType) { + const types = literals.filter((literal) => { + if (literal instanceof StringType) { hasString = true; - preserveLiterals = preserveLiterals || t.getPreserveLiterals(); + preserveLiterals ||= literal.getPreserveLiterals(); return false; - } else if (t instanceof NullType) { + } + + if (literal instanceof NullType) { hasNull = true; return true; - } else if (t instanceof LiteralType && !t.isString()) { + } + + if (literal instanceof LiteralType && !literal.isString()) { allStrings = false; } @@ -39,33 +44,26 @@ export class LiteralUnionTypeFormatter implements SubTypeFormatter { }); if (allStrings && hasString && !preserveLiterals) { - return { - type: hasNull ? ["string", "null"] : "string", - }; + return hasNull ? { type: ["string", "null"] } : { type: "string" }; } - const values = uniqueArray(types.map(getLiteralValue)); - const typeNames = uniqueArray(types.map(getLiteralType)); + const typeValues: Set = new Set(); + const typeNames: Set = new Set(); - const ret = { - type: typeNames.length === 1 ? typeNames[0] : typeNames, - enum: values, - }; - - if (preserveLiterals) { - return { - anyOf: [ - { - type: "string", - }, - ret, - ], - }; + for (const type of types) { + appendTypeNames(type, typeNames); + appendTypeValues(type, typeValues); } - return ret; + const schema = { + type: toEnumType(Array.from(typeNames)), + enum: Array.from(typeValues), + }; + + return preserveLiterals ? { anyOf: [{ type: "string" }, schema] } : schema; } - public getChildren(type: UnionType): BaseType[] { + + public getChildren(): BaseType[] { return []; } } @@ -73,13 +71,51 @@ export class LiteralUnionTypeFormatter implements SubTypeFormatter { export function isLiteralUnion(type: UnionType): boolean { return type .getFlattenedTypes() - .every((item) => item instanceof LiteralType || item instanceof NullType || item instanceof StringType); + .every( + (item) => + item instanceof LiteralType || + item instanceof NullType || + item instanceof StringType || + item instanceof EnumType, + ); } -function getLiteralValue(value: LiteralType | NullType): LiteralValue | null { - return value instanceof LiteralType ? value.getValue() : null; +/** + * Appends all possible type names of a type to the given set. + */ +function appendTypeNames(type: BaseType, names: Set) { + if (type instanceof EnumType) { + for (const value of type.getValues()) { + names.add(typeName(value)); + } + + return; + } + + if (type instanceof LiteralType) { + names.add(typeName(type.getValue())); + return; + } + + names.add(typeName(null)); } -function getLiteralType(value: LiteralType | NullType): RawTypeName { - return value instanceof LiteralType ? typeName(value.getValue()) : "null"; +/** + * Appends all possible values of a type to the given set. + */ +function appendTypeValues(type: BaseType, values: Set) { + if (type instanceof EnumType) { + for (const value of type.getValues()) { + values.add(value); + } + + return; + } + + if (type instanceof LiteralType) { + values.add(type.getValue()); + return; + } + + values.add(null); } diff --git a/src/Utils/uniqueArray.ts b/src/Utils/uniqueArray.ts index b20a18031..120bb9c17 100644 --- a/src/Utils/uniqueArray.ts +++ b/src/Utils/uniqueArray.ts @@ -1,9 +1,3 @@ export function uniqueArray(array: readonly T[]): T[] { - return array.reduce((result: T[], item: T) => { - if (!result.includes(item)) { - result.push(item); - } - - return result; - }, []); + return array.filter((value, index) => array.indexOf(value) === index); } diff --git a/test/valid-data-other.test.ts b/test/valid-data-other.test.ts index 2b6ddd181..f41f09ff7 100644 --- a/test/valid-data-other.test.ts +++ b/test/valid-data-other.test.ts @@ -9,6 +9,8 @@ describe("valid-data-other", () => { it("enums-mixed", assertValidSchema("enums-mixed", "Enum")); it("enums-member", assertValidSchema("enums-member", "MyObject")); it("enums-template-literal", assertValidSchema("enums-template-literal", "MyObject")); + it("enums-union", assertValidSchema("enums-union", "MyObject")); + it("exported-enums-union", assertValidSchema("exported-enums-union", "MyObject")); it("function-parameters-default-value", assertValidSchema("function-parameters-default-value", "myFunction")); it("function-parameters-declaration", assertValidSchema("function-parameters-declaration", "myFunction")); diff --git a/test/valid-data/enums-union/main.ts b/test/valid-data/enums-union/main.ts new file mode 100644 index 000000000..7e768f474 --- /dev/null +++ b/test/valid-data/enums-union/main.ts @@ -0,0 +1,26 @@ +enum Alphabet { + Alpha = "alpha", + Beta = "beta", + Omega = 666, +} + +enum FileAccess { + None = 0, + Read = 1 << 1, + Write = 1 << 2, + ReadWrite = Read | Write, +} + +export type MyObject = { + // All the members above should be output as enums, not anyOf + enumMembers: Alphabet.Alpha | Alphabet.Beta; + enumMemberWithLiteral: Alphabet.Alpha | "foo"; + enumMemberWithLiteralAndNull: Alphabet.Alpha | "foo" | null; + enumMemberWithInterface: Alphabet.Alpha | { abc: string }; + enumMembersWithNumber: Alphabet.Alpha | Alphabet.Omega; + wholeEnum: Alphabet; // Should output just all of Alphabet + wholeEnumWithLiteral: Alphabet | "bar"; // Should output all of Alphabet members (2 strings, 1 number) and "bar" + wholeEnumWithLiteralAndNull: Alphabet | "bar" | null; + twoDifferentEnumMembers: Alphabet.Alpha | FileAccess.Read; + twoDifferentWholeEnums: Alphabet | FileAccess; +}; diff --git a/test/valid-data/enums-union/schema.json b/test/valid-data/enums-union/schema.json new file mode 100644 index 000000000..1e6b686fe --- /dev/null +++ b/test/valid-data/enums-union/schema.json @@ -0,0 +1,141 @@ +{ + "$ref": "#/definitions/MyObject", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "MyObject": { + "additionalProperties": false, + "properties": { + "enumMemberWithInterface": { + "anyOf": [ + { + "const": "alpha", + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "abc": { + "type": "string" + } + }, + "required": [ + "abc" + ], + "type": "object" + } + ] + }, + "enumMemberWithLiteral": { + "enum": [ + "alpha", + "foo" + ], + "type": "string" + }, + "enumMemberWithLiteralAndNull": { + "enum": [ + "alpha", + "foo", + null + ], + "type": [ + "string", + "null" + ] + }, + "enumMembers": { + "enum": [ + "alpha", + "beta" + ], + "type": "string" + }, + "enumMembersWithNumber": { + "enum": [ + "alpha", + 666 + ], + "type": [ + "string", + "number" + ] + }, + "twoDifferentEnumMembers": { + "enum": [ + "alpha", + 2 + ], + "type": [ + "string", + "number" + ] + }, + "twoDifferentWholeEnums": { + "enum": [ + "alpha", + "beta", + 666, + 0, + 2, + 4, + 6 + ], + "type": [ + "string", + "number" + ] + }, + "wholeEnum": { + "enum": [ + "alpha", + "beta", + 666 + ], + "type": [ + "string", + "number" + ] + }, + "wholeEnumWithLiteral": { + "enum": [ + "alpha", + "beta", + 666, + "bar" + ], + "type": [ + "string", + "number" + ] + }, + "wholeEnumWithLiteralAndNull": { + "enum": [ + "alpha", + "beta", + 666, + "bar", + null + ], + "type": [ + "string", + "number", + "null" + ] + } + }, + "required": [ + "enumMembers", + "enumMemberWithLiteral", + "enumMemberWithLiteralAndNull", + "enumMemberWithInterface", + "enumMembersWithNumber", + "wholeEnum", + "wholeEnumWithLiteral", + "wholeEnumWithLiteralAndNull", + "twoDifferentEnumMembers", + "twoDifferentWholeEnums" + ], + "type": "object" + } + } +} diff --git a/test/valid-data/exported-enums-union/main.ts b/test/valid-data/exported-enums-union/main.ts new file mode 100644 index 000000000..797602dce --- /dev/null +++ b/test/valid-data/exported-enums-union/main.ts @@ -0,0 +1,26 @@ +export enum Alphabet { + Alpha = "alpha", + Beta = "beta", + Omega = 666, +} + +export enum FileAccess { + None = 0, + Read = 1 << 1, + Write = 1 << 2, + ReadWrite = Read | Write, +} + +export type MyObject = { + // All the members above should be output as enums, not anyOf + enumMembers: Alphabet.Alpha | Alphabet.Beta; + enumMemberWithLiteral: Alphabet.Alpha | "foo"; + enumMemberWithLiteralAndNull: Alphabet.Alpha | "foo" | null; + enumMemberWithInterface: Alphabet.Alpha | { abc: string }; + enumMembersWithNumber: Alphabet.Alpha | Alphabet.Omega; + wholeEnum: Alphabet; // Should output just all of Alphabet + wholeEnumWithLiteral: Alphabet | "bar"; // Should output all of Alphabet members (2 strings, 1 number) and "bar" + wholeEnumWithLiteralAndNull: Alphabet | "bar" | null; + twoDifferentEnumMembers: Alphabet.Alpha | FileAccess.Read; + twoDifferentWholeEnums: Alphabet | FileAccess; +}; diff --git a/test/valid-data/exported-enums-union/schema.json b/test/valid-data/exported-enums-union/schema.json new file mode 100644 index 000000000..994331bb2 --- /dev/null +++ b/test/valid-data/exported-enums-union/schema.json @@ -0,0 +1,147 @@ +{ + "$ref": "#/definitions/MyObject", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Alphabet": { + "enum": [ + "alpha", + "beta", + 666 + ], + "type": [ + "string", + "number" + ] + }, + "FileAccess": { + "enum": [ + 0, + 2, + 4, + 6 + ], + "type": "number" + }, + "MyObject": { + "additionalProperties": false, + "properties": { + "enumMemberWithInterface": { + "anyOf": [ + { + "const": "alpha", + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "abc": { + "type": "string" + } + }, + "required": [ + "abc" + ], + "type": "object" + } + ] + }, + "enumMemberWithLiteral": { + "enum": [ + "alpha", + "foo" + ], + "type": "string" + }, + "enumMemberWithLiteralAndNull": { + "enum": [ + "alpha", + "foo", + null + ], + "type": [ + "string", + "null" + ] + }, + "enumMembers": { + "enum": [ + "alpha", + "beta" + ], + "type": "string" + }, + "enumMembersWithNumber": { + "enum": [ + "alpha", + 666 + ], + "type": [ + "string", + "number" + ] + }, + "twoDifferentEnumMembers": { + "enum": [ + "alpha", + 2 + ], + "type": [ + "string", + "number" + ] + }, + "twoDifferentWholeEnums": { + "anyOf": [ + { + "$ref": "#/definitions/Alphabet" + }, + { + "$ref": "#/definitions/FileAccess" + } + ] + }, + "wholeEnum": { + "$ref": "#/definitions/Alphabet" + }, + "wholeEnumWithLiteral": { + "anyOf": [ + { + "$ref": "#/definitions/Alphabet" + }, + { + "const": "bar", + "type": "string" + } + ] + }, + "wholeEnumWithLiteralAndNull": { + "anyOf": [ + { + "$ref": "#/definitions/Alphabet" + }, + { + "const": "bar", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "enumMembers", + "enumMemberWithLiteral", + "enumMemberWithLiteralAndNull", + "enumMemberWithInterface", + "enumMembersWithNumber", + "wholeEnum", + "wholeEnumWithLiteral", + "wholeEnumWithLiteralAndNull", + "twoDifferentEnumMembers", + "twoDifferentWholeEnums" + ], + "type": "object" + } + } +}