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

feat: Improved Promise handling to support packages like Prisma #1924

Merged
merged 24 commits into from May 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Expand Up @@ -8,3 +8,6 @@ node_modules/
# local config for auto
.env

# Other package managers
pnpm-lock.yaml
package-lock.json
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -231,6 +231,7 @@ fs.writeFile(outputPath, schemaString, (err) => {
- `keyof`
- conditional types
- functions
- `Promise<T>` unwraps to `T`

## Run locally

Expand Down
12 changes: 7 additions & 5 deletions factory/parser.ts
@@ -1,12 +1,12 @@
import ts from "typescript";
import type ts from "typescript";
import { BasicAnnotationsReader } from "../src/AnnotationsReader/BasicAnnotationsReader.js";
import { ExtendedAnnotationsReader } from "../src/AnnotationsReader/ExtendedAnnotationsReader.js";
import { ChainNodeParser } from "../src/ChainNodeParser.js";
import { CircularReferenceNodeParser } from "../src/CircularReferenceNodeParser.js";
import { CompletedConfig } from "../src/Config.js";
import type { CompletedConfig } from "../src/Config.js";
import { ExposeNodeParser } from "../src/ExposeNodeParser.js";
import { MutableParser } from "../src/MutableParser.js";
import { NodeParser } from "../src/NodeParser.js";
import type { MutableParser } from "../src/MutableParser.js";
import type { NodeParser } from "../src/NodeParser.js";
import { AnnotatedNodeParser } from "../src/NodeParser/AnnotatedNodeParser.js";
import { AnyTypeNodeParser } from "../src/NodeParser/AnyTypeNodeParser.js";
import { ArrayLiteralExpressionNodeParser } from "../src/NodeParser/ArrayLiteralExpressionNodeParser.js";
Expand Down Expand Up @@ -55,9 +55,10 @@ import { UndefinedTypeNodeParser } from "../src/NodeParser/UndefinedTypeNodePars
import { UnionNodeParser } from "../src/NodeParser/UnionNodeParser.js";
import { UnknownTypeNodeParser } from "../src/NodeParser/UnknownTypeNodeParser.js";
import { VoidTypeNodeParser } from "../src/NodeParser/VoidTypeNodeParser.js";
import { SubNodeParser } from "../src/SubNodeParser.js";
import type { SubNodeParser } from "../src/SubNodeParser.js";
import { TopRefNodeParser } from "../src/TopRefNodeParser.js";
import { SatisfiesNodeParser } from "../src/NodeParser/SatisfiesNodeParser.js";
import { PromiseNodeParser } from "../src/NodeParser/PromiseNodeParser.js";

export type ParserAugmentor = (parser: MutableParser) => void;

Expand Down Expand Up @@ -121,6 +122,7 @@ export function createParser(program: ts.Program, config: CompletedConfig, augme
.addNodeParser(new LiteralNodeParser(chainNodeParser))
.addNodeParser(new ParenthesizedNodeParser(chainNodeParser))

.addNodeParser(new PromiseNodeParser(typeChecker, chainNodeParser))
.addNodeParser(new TypeReferenceNodeParser(typeChecker, chainNodeParser))
.addNodeParser(new ExpressionWithTypeArgumentsNodeParser(typeChecker, chainNodeParser))
.addNodeParser(new IndexedAccessTypeNodeParser(typeChecker, chainNodeParser))
Expand Down
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -65,6 +65,7 @@
"@types/jest": "^29.5.12",
"@types/node": "^20.12.7",
"@types/normalize-path": "^3.0.2",
"@types/ts-expose-internals": "npm:ts-expose-internals@^5.4.5",
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"auto": "^11.1.6",
Expand Down Expand Up @@ -94,6 +95,5 @@
"debug": "tsx --inspect-brk ts-json-schema-generator.ts",
"run": "tsx ts-json-schema-generator.ts",
"release": "yarn build && auto shipit"
},
"packageManager": "yarn@1.22.19"
}
}
3 changes: 3 additions & 0 deletions src/NodeParser/FunctionNodeParser.ts
Expand Up @@ -18,8 +18,11 @@ export class FunctionNodeParser implements SubNodeParser {
public supportsNode(node: ts.TypeNode): boolean {
return (
node.kind === ts.SyntaxKind.FunctionType ||
// @ts-expect-error internals type bug
node.kind === ts.SyntaxKind.FunctionExpression ||
// @ts-expect-error internals type bug
node.kind === ts.SyntaxKind.ArrowFunction ||
// @ts-expect-error internals type bug
node.kind === ts.SyntaxKind.FunctionDeclaration
);
}
Expand Down
98 changes: 98 additions & 0 deletions src/NodeParser/PromiseNodeParser.ts
@@ -0,0 +1,98 @@
import ts from "typescript";
import { Context, type NodeParser } from "../NodeParser.js";
import type { SubNodeParser } from "../SubNodeParser.js";
import { AliasType } from "../Type/AliasType.js";
import type { BaseType } from "../Type/BaseType.js";
import { DefinitionType } from "../Type/DefinitionType.js";
import { getKey } from "../Utils/nodeKey.js";

/**
* Needs to be registered before 261, 260, 230, 262 node kinds
*/
export class PromiseNodeParser implements SubNodeParser {
public constructor(
protected typeChecker: ts.TypeChecker,
protected childNodeParser: NodeParser,
) {}

public supportsNode(node: ts.Node): boolean {
if (
// 261 interface PromiseInterface extends Promise<T>
!ts.isInterfaceDeclaration(node) &&
// 260 class PromiseClass implements Promise<T>
!ts.isClassDeclaration(node) &&
// 230 Promise<T>
!ts.isExpressionWithTypeArguments(node) &&
// 262 type PromiseAlias = Promise<T>;
!ts.isTypeAliasDeclaration(node)
) {
return false;
}

const type = this.typeChecker.getTypeAtLocation(node);

const awaitedType = this.typeChecker.getAwaitedType(type);

// ignores non awaitable types
if (!awaitedType) {
return false;

Check warning on line 38 in src/NodeParser/PromiseNodeParser.ts

View check run for this annotation

Codecov / codecov/patch

src/NodeParser/PromiseNodeParser.ts#L38

Added line #L38 was not covered by tests
}

// If the awaited type differs from the original type, the type extends promise
// Awaited<Promise<T>> -> T (Promise<T> !== T)
// Awaited<Y> -> Y (Y === Y)
if (awaitedType === type) {
return false;
}

// In types like: A<T> = T, type C = A<1>, C has the same type as A<1> and 1,
// the awaitedType is NOT the same reference as the type, so a assignability
// check is needed
return (
!this.typeChecker.isTypeAssignableTo(type, awaitedType) &&
!this.typeChecker.isTypeAssignableTo(awaitedType, type)
);
}

public createType(
node: ts.InterfaceDeclaration | ts.ClassDeclaration | ts.ExpressionWithTypeArguments | ts.TypeAliasDeclaration,
context: Context,
): BaseType {
const type = this.typeChecker.getTypeAtLocation(node);
const awaitedType = this.typeChecker.getAwaitedType(type)!; // supportsNode ensures this
const awaitedNode = this.typeChecker.typeToTypeNode(awaitedType, undefined, ts.NodeBuilderFlags.IgnoreErrors);

if (!awaitedNode) {
throw new Error(

Check warning on line 66 in src/NodeParser/PromiseNodeParser.ts

View check run for this annotation

Codecov / codecov/patch

src/NodeParser/PromiseNodeParser.ts#L66

Added line #L66 was not covered by tests
`Could not find awaited node for type ${node.pos === -1 ? "<unresolved>" : node.getText()}`,
);
}

const baseNode = this.childNodeParser.createType(awaitedNode, new Context(node));

const name = this.getNodeName(node);

// Nodes without name should just be their awaited type
// export class extends Promise<T> {} -> T
// export class A extends Promise<T> {} -> A (ref to T)
if (!name) {
return baseNode;

Check warning on line 79 in src/NodeParser/PromiseNodeParser.ts

View check run for this annotation

Codecov / codecov/patch

src/NodeParser/PromiseNodeParser.ts#L79

Added line #L79 was not covered by tests
}

return new DefinitionType(name, new AliasType(`promise-${getKey(node, context)}`, baseNode));
}

private getNodeName(
node: ts.InterfaceDeclaration | ts.ClassDeclaration | ts.ExpressionWithTypeArguments | ts.TypeAliasDeclaration,
) {
if (ts.isExpressionWithTypeArguments(node)) {
if (!ts.isHeritageClause(node.parent)) {
throw new Error("Expected ExpressionWithTypeArguments to have a HeritageClause parent");

Check warning on line 90 in src/NodeParser/PromiseNodeParser.ts

View check run for this annotation

Codecov / codecov/patch

src/NodeParser/PromiseNodeParser.ts#L90

Added line #L90 was not covered by tests
}

return node.parent.parent.name?.getText();

Check warning on line 93 in src/NodeParser/PromiseNodeParser.ts

View check run for this annotation

Codecov / codecov/patch

src/NodeParser/PromiseNodeParser.ts#L93

Added line #L93 was not covered by tests
}

return node.name?.getText();
}
}
18 changes: 11 additions & 7 deletions src/NodeParser/TypeReferenceNodeParser.ts
@@ -1,6 +1,5 @@
import ts from "typescript";

import { Context, NodeParser } from "../NodeParser.js";
import { Context, type NodeParser } from "../NodeParser.js";
import type { SubNodeParser } from "../SubNodeParser.js";
import { AnnotatedType } from "../Type/AnnotatedType.js";
import { AnyType } from "../Type/AnyType.js";
Expand Down Expand Up @@ -31,11 +30,6 @@
// property on the node itself.
(node.typeName as unknown as ts.Type).symbol;

// Wraps promise type to avoid resolving to a empty Object type.
if (typeSymbol.name === "Promise") {
return this.childNodeParser.createType(node.typeArguments![0], this.createSubContext(node, context));
}

if (typeSymbol.flags & ts.SymbolFlags.Alias) {
const aliasedSymbol = this.typeChecker.getAliasedSymbol(typeSymbol);

Expand All @@ -53,6 +47,16 @@
return context.getArgument(typeSymbol.name);
}

// Wraps promise type to avoid resolving to a empty Object type.
if (typeSymbol.name === "Promise" || typeSymbol.name === "PromiseLike") {
// Promise without type resolves to Promise<any>
if (!node.typeArguments || node.typeArguments.length === 0) {
return new AnyType();

Check warning on line 54 in src/NodeParser/TypeReferenceNodeParser.ts

View check run for this annotation

Codecov / codecov/patch

src/NodeParser/TypeReferenceNodeParser.ts#L54

Added line #L54 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this logic into the PromiseNodeParser?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually not, It might be a skill issue of mine but I couldn't get it to work without this statement.

When I remove it, this is the error thrown:

  ● valid-data-type › promise-extensions

    TypeError: Cannot read properties of undefined (reading 'getId')

      123 |
      124 |     // Check for simple type equality
    > 125 |     if (source.getId() === target.getId()) {
          |                ^
      126 |         return true;
      127 |     }
      128 |

      at getId (src/Utils/isAssignableTo.ts:125:16)
      at src/NodeParser/ConditionalTypeNodeParser.ts:44:77
      at predicate (src/Utils/narrowType.ts:59:12)
      at ConditionalTypeNodeParser.createType (src/NodeParser/ConditionalTypeNodeParser.ts:44:41)
      at ChainNodeParser.createType (src/ChainNodeParser.ts:35:54)
      at TypeAliasNodeParser.createType (src/NodeParser/TypeAliasNodeParser.ts:40:43)
      at AnnotatedNodeParser.createType (src/NodeParser/AnnotatedNodeParser.ts:34:47)
      at ExposeNodeParser.createType (src/ExposeNodeParser.ts:23:45)
      at CircularReferenceNodeParser.createType (src/CircularReferenceNodeParser.ts:24:43)
      at ChainNodeParser.createType (src/ChainNodeParser.ts:35:54)
      at TypeReferenceNodeParser.createType (src/NodeParser/TypeReferenceNodeParser.ts:78:37)
      at ChainNodeParser.createType (src/ChainNodeParser.ts:35:54)
      at PromiseNodeParser.createType (src/NodeParser/PromiseNodeParser.ts:71:47)
      at ChainNodeParser.createType (src/ChainNodeParser.ts:35:54)
      at TypeReferenceNodeParser.createType (src/NodeParser/TypeReferenceNodeParser.ts:78:37)
      at ChainNodeParser.createType (src/ChainNodeParser.ts:35:54)
      at createType (src/NodeParser/UnionNodeParser.ts:22:45)
          at Array.map (<anonymous>)
      at UnionNodeParser.map [as createType] (src/NodeParser/UnionNodeParser.ts:21:14)
      at ChainNodeParser.createType (src/ChainNodeParser.ts:35:54)
      at TypeAliasNodeParser.createType (src/NodeParser/TypeAliasNodeParser.ts:40:43)
      at AnnotatedNodeParser.createType (src/NodeParser/AnnotatedNodeParser.ts:34:47)
      at ExposeNodeParser.createType (src/ExposeNodeParser.ts:23:45)
      at CircularReferenceNodeParser.createType (src/CircularReferenceNodeParser.ts:24:43)
      at ChainNodeParser.createType (src/ChainNodeParser.ts:35:54)
      at TopRefNodeParser.createType (src/TopRefNodeParser.ts:14:47)
      at createType (src/SchemaGenerator.ts:30:36)
          at Array.map (<anonymous>)
      at SchemaGenerator.map [as createSchemaFromNodes] (src/SchemaGenerator.ts:29:37)
      at SchemaGenerator.createSchemaFromNodes [as createSchema] (src/SchemaGenerator.ts:25:21)
      at Object.createSchema (test/utils.ts:61:34)

}

return this.childNodeParser.createType(node.typeArguments[0], this.createSubContext(node, context));
}

if (typeSymbol.name === "Array" || typeSymbol.name === "ReadonlyArray") {
const type = this.createSubContext(node, context).getArguments()[0];

Expand Down
2 changes: 1 addition & 1 deletion test/utils.ts
Expand Up @@ -69,7 +69,7 @@ export function assertValidSchema(
const actual: any = JSON.parse(JSON.stringify(schema));

expect(typeof actual).toBe("object");
expect(actual).toEqual(expected);
expect(actual).toStrictEqual(expected);

let localValidator = validator;
if (config.extraTags) {
Expand Down
2 changes: 2 additions & 0 deletions test/valid-data-type.test.ts
Expand Up @@ -143,4 +143,6 @@ describe("valid-data-type", () => {
it("ignore-export", assertValidSchema("ignore-export", "*"));

it("lowercase", assertValidSchema("lowercase", "MyType"));

it("promise-extensions", assertValidSchema("promise-extensions", "*"));
});
59 changes: 59 additions & 0 deletions test/valid-data/promise-extensions/main.ts
@@ -0,0 +1,59 @@
export type A = { a: string; b: number[] };

export type PromiseAlias = Promise<A>;

export class PromiseClass extends Promise<A> {}

export interface PromiseInterface extends Promise<A> {}

export type LikeType = PromiseLike<A>;

export type PromiseOrAlias = Promise<A> | A;

export type LikeOrType = PromiseLike<A> | A;

export type AndPromise = Promise<A> & { a: string };

export type AndLikePromise = PromiseLike<A> & { a: string };

// Should not be present
export default class extends Promise<A> {}

export class LikeClass implements PromiseLike<A> {
then<TResult1 = A, TResult2 = never>(
onfulfilled?: ((value: A) => TResult1 | PromiseLike<TResult1>) | null | undefined,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined
): PromiseLike<TResult1 | TResult2> {
return new Promise(() => {});
}
}

export abstract class LikeAbstractClass implements PromiseLike<A> {
abstract then<TResult1 = A, TResult2 = never>(
onfulfilled?: ((value: A) => TResult1 | PromiseLike<TResult1>) | null | undefined,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined
);
}

export interface LikeInterface extends PromiseLike<A> {}

// Prisma has a base promise type just like this
export interface WithProperty extends Promise<A> {
[Symbol.toStringTag]: "WithProperty";
}

export interface ThenableInterface {
then<TResult1 = A, TResult2 = never>(
onfulfilled?: ((value: A) => TResult1 | PromiseLike<TResult1>) | null | undefined,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined
): PromiseLike<TResult1 | TResult2>;
}

export class ThenableClass {
then<TResult1 = A, TResult2 = never>(
onfulfilled?: ((value: A) => TResult1 | PromiseLike<TResult1>) | null | undefined,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined
): PromiseLike<TResult1 | TResult2> {
return new Promise(() => {});
}
}
66 changes: 66 additions & 0 deletions test/valid-data/promise-extensions/schema.json
@@ -0,0 +1,66 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"A": {
"additionalProperties": false,
"properties": {
"a": {
"type": "string"
},
"b": {
"items": {
"type": "number"
},
"type": "array"
}
},
"required": [
"a",
"b"
],
"type": "object"
},
"AndLikePromise": {
"$ref": "#/definitions/A"
},
"AndPromise": {
"$ref": "#/definitions/A"
},
"LikeAbstractClass": {
"$ref": "#/definitions/A"
},
"LikeClass": {
"$ref": "#/definitions/A"
},
"LikeInterface": {
"$ref": "#/definitions/A"
},
"LikeOrType": {
"$ref": "#/definitions/A"
},
"LikeType": {
"$ref": "#/definitions/A"
},
"PromiseAlias": {
"$ref": "#/definitions/A"
},
"PromiseClass": {
"$ref": "#/definitions/A"
},
"PromiseInterface": {
"$ref": "#/definitions/A"
},
"PromiseOrAlias": {
"$ref": "#/definitions/A"
},
"ThenableClass": {
"$ref": "#/definitions/A"
},
"ThenableInterface": {
"$ref": "#/definitions/A"
},
"WithProperty": {
"$ref": "#/definitions/A"
}
}
}
4 changes: 2 additions & 2 deletions test/vega-lite/schema.json
Expand Up @@ -27149,6 +27149,8 @@
},
"SingleDefUnitChannel": {
"enum": [
"text",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somehow this type started getting out of order and breaking CI:

https://github.com/vega/ts-json-schema-generator/actions/runs/9105537200/job/25031273060#step:7:25

I changed its order so tests can pass, I'm also almost sure enum is order insensitive.

"shape",
"x",
"y",
"xOffset",
Expand All @@ -27173,9 +27175,7 @@
"strokeDash",
"size",
"angle",
"shape",
"key",
"text",
"href",
"url",
"description"
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Expand Up @@ -1971,6 +1971,11 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"
integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==

"@types/ts-expose-internals@npm:ts-expose-internals@^5.4.5":
version "5.4.5"
resolved "https://registry.yarnpkg.com/ts-expose-internals/-/ts-expose-internals-5.4.5.tgz#94da2b665627135ad1281d98af3ccb08cb4c1950"
integrity sha512-0HfRwjgSIOyuDlHzkFedMWU4aHWq9pu4MUKHgH75U+L76wCAtK5WB0rc/dAIhulMRcPUlcKONeiiR5Sxy/7XcA==

"@types/yargs-parser@*":
version "21.0.3"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
Expand Down