Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Go back to generating enums out of unions containing enums #2149

Merged
merged 14 commits into from
Jan 7, 2025
Merged
88 changes: 49 additions & 39 deletions src/TypeFormatter/LiteralUnionTypeFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,84 +2,94 @@ 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";

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;
}

return true;
});

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<LiteralValue | null> = new Set();
const typeNames: Set<RawTypeName> = new Set();

const ret = {
type: typeNames.length === 1 ? typeNames[0] : typeNames,
enum: values,
};
for (const type of types) {
arthurfiorette marked this conversation as resolved.
Show resolved Hide resolved
if (type instanceof EnumType) {
for (const value of type.getValues()) {
typeValues.add(value);
typeNames.add(typeName(value));
}

continue;
}

if (preserveLiterals) {
return {
anyOf: [
{
type: "string",
},
ret,
],
};
if (type instanceof LiteralType) {
typeValues.add(type.getValue());
typeNames.add(typeName(type.getValue()));
continue;
}

typeValues.add(null);
typeNames.add("null");
}

return ret;
const schema = {
type: typeNames.size === 1 ? typeNames.values().next().value : Array.from(typeNames),
enum: Array.from(typeValues),
};

return preserveLiterals ? { anyOf: [{ type: "string" }, schema] } : schema;
}
public getChildren(type: UnionType): BaseType[] {

public getChildren(): BaseType[] {
return [];
}
}

export function isLiteralUnion(type: UnionType): boolean {
return type
.getFlattenedTypes()
.every((item) => item instanceof LiteralType || item instanceof NullType || item instanceof StringType);
}

function getLiteralValue(value: LiteralType | NullType): LiteralValue | null {
return value instanceof LiteralType ? value.getValue() : null;
}

function getLiteralType(value: LiteralType | NullType): RawTypeName {
return value instanceof LiteralType ? typeName(value.getValue()) : "null";
.every(
(item) =>
item instanceof LiteralType ||
item instanceof NullType ||
item instanceof StringType ||
item instanceof EnumType,
);
}
8 changes: 1 addition & 7 deletions src/Utils/uniqueArray.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
export function uniqueArray<T>(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);
}
2 changes: 2 additions & 0 deletions test/valid-data-other.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
26 changes: 26 additions & 0 deletions test/valid-data/enums-union/main.ts
Original file line number Diff line number Diff line change
@@ -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;
arthurfiorette marked this conversation as resolved.
Show resolved Hide resolved
twoDifferentEnumMembers: Alphabet.Alpha | FileAccess.Read;
twoDifferentWholeEnums: Alphabet | FileAccess;
};
141 changes: 141 additions & 0 deletions test/valid-data/enums-union/schema.json
Original file line number Diff line number Diff line change
@@ -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": [
arthurfiorette marked this conversation as resolved.
Show resolved Hide resolved
"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"
}
}
}
26 changes: 26 additions & 0 deletions test/valid-data/exported-enums-union/main.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading