Skip to content

Commit

Permalink
Merge pull request #1473 from vinnymac/feature/support-null-unions
Browse files Browse the repository at this point in the history
feat(plugin): Update plugin with null union support
  • Loading branch information
kamilmysliwiec committed Jun 18, 2021
2 parents 238e9fb + d9a288c commit 588d140
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 44 deletions.
25 changes: 25 additions & 0 deletions lib/plugin/utils/ast-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
CommentRange,
getLeadingCommentRanges,
getTrailingCommentRanges,
UnionTypeNode,
TypeNode,
} from 'typescript';
import { isDynamicallyAdded } from './plugin-utils';

Expand Down Expand Up @@ -70,6 +72,22 @@ export function isEnumLiteral(type: Type) {
return hasFlag(type, TypeFlags.EnumLiteral) && !type.isUnion();
}

export function isNull(type: Type) {
if (type.isUnion()) {
return Boolean(type.types.find((t) => hasFlag(t, TypeFlags.Null)));
} else {
return hasFlag(type, TypeFlags.Null);
}
}

export function isUndefined(type: Type) {
if (type.isUnion()) {
return Boolean(type.types.find((t) => hasFlag(t, TypeFlags.Undefined)));
} else {
return hasFlag(type, TypeFlags.Undefined);
}
}

export function hasFlag(type: Type, flag: TypeFlags) {
return (type.flags & flag) === flag;
}
Expand Down Expand Up @@ -172,3 +190,10 @@ export function getDescriptionOfNode(
}
return description.join('\n');
}

export function findNullableTypeFromUnion(typeNode: UnionTypeNode, typeChecker: TypeChecker) {
return typeNode.types.find(
(tNode: TypeNode) =>
hasFlag(typeChecker.getTypeAtLocation(tNode), TypeFlags.Null)
);
}
4 changes: 2 additions & 2 deletions lib/plugin/utils/plugin-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function getTypeReferenceAsString(
return Boolean.name;
}
if (isEnum(type)) {
return getText(type, typeChecker);
return text;
}
const isEnumMember =
type.symbol && type.symbol.flags === ts.SymbolFlags.EnumMember;
Expand All @@ -76,7 +76,7 @@ export function getTypeReferenceAsString(
if (!type) {
return undefined;
}
return getText(type, typeChecker);
return text;
}
if (
isAutoGeneratedTypeUnion(type) ||
Expand Down
99 changes: 60 additions & 39 deletions lib/plugin/visitors/model-class.visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as ts from 'typescript';
import { HideField } from '../../decorators';
import { PluginOptions } from '../merge-options';
import { METADATA_FACTORY_NAME } from '../plugin-constants';
import { getDescriptionOfNode } from '../utils/ast-utils';
import { findNullableTypeFromUnion, getDescriptionOfNode, isNull, isUndefined } from '../utils/ast-utils';
import {
getDecoratorOrUndefinedByNames,
getTypeReferenceAsString,
Expand Down Expand Up @@ -131,17 +131,18 @@ export class ModelClassVisitor {
typeChecker: ts.TypeChecker,
existingProperties: ts.NodeArray<ts.PropertyAssignment> = ts.createNodeArray(),
hostFilename = '',
sourceFile: ts.SourceFile,
pluginOptions: PluginOptions,
sourceFile?: ts.SourceFile,
pluginOptions?: PluginOptions,
): ts.ObjectLiteralExpression {
const isRequired = !node.questionToken;
const type = typeChecker.getTypeAtLocation(node);
const isNullable = !!node.questionToken || isNull(type) || isUndefined(type);

const properties = [
...existingProperties,
!hasPropertyKey('nullable', existingProperties) &&
ts.createPropertyAssignment('nullable', ts.createLiteral(!isRequired)),
!hasPropertyKey('nullable', existingProperties) && isNullable &&
ts.createPropertyAssignment('nullable', ts.createLiteral(isNullable)),
this.createTypePropertyAssignment(
node,
node.type,
typeChecker,
existingProperties,
hostFilename,
Expand All @@ -160,50 +161,69 @@ export class ModelClassVisitor {
}

createTypePropertyAssignment(
node: ts.PropertyDeclaration | ts.PropertySignature,
node: ts.TypeNode,
typeChecker: ts.TypeChecker,
existingProperties: ts.NodeArray<ts.PropertyAssignment>,
hostFilename: string,
sourceFile: ts.SourceFile,
pluginOptions: PluginOptions,
) {
sourceFile?: ts.SourceFile,
pluginOptions?: PluginOptions
): ts.PropertyAssignment {
const key = 'type';
if (hasPropertyKey(key, existingProperties)) {
return undefined;
}
const type = typeChecker.getTypeAtLocation(node);
if (!type) {
return undefined;
}
if (node.type && ts.isTypeLiteralNode(node.type)) {
const propertyAssignments = Array.from(node.type.members || []).map(
(member) => {
const literalExpr = this.createDecoratorObjectLiteralExpr(
member as ts.PropertySignature,

if (node) {
if (ts.isTypeLiteralNode(node)) {
const propertyAssignments = Array.from(node.members || []).map(
(member) => {
const literalExpr = this.createDecoratorObjectLiteralExpr(
member as ts.PropertySignature,
typeChecker,
existingProperties,
hostFilename,
sourceFile,
pluginOptions,
);
return ts.createPropertyAssignment(
ts.createIdentifier(member.name.getText()),
literalExpr,
);
},
);
return ts.createPropertyAssignment(
key,
ts.createArrowFunction(
undefined,
undefined,
[],
undefined,
undefined,
ts.createParen(ts.createObjectLiteral(propertyAssignments)),
),
);
} else if (ts.isUnionTypeNode(node)) {
const nullableType = findNullableTypeFromUnion(node, typeChecker);
const remainingTypes = node.types.filter(
(item) => item !== nullableType
);

if (remainingTypes.length === 1) {
return this.createTypePropertyAssignment(
remainingTypes[0],
typeChecker,
existingProperties,
hostFilename,
sourceFile,
pluginOptions,
);
return ts.createPropertyAssignment(
ts.createIdentifier(member.name.getText()),
literalExpr,
);
},
);
return ts.createPropertyAssignment(
key,
ts.createArrowFunction(
undefined,
undefined,
[],
undefined,
undefined,
ts.createParen(ts.createObjectLiteral(propertyAssignments)),
),
);
}
}
}

const type = typeChecker.getTypeAtLocation(node);
if (!type) {
return undefined;
}

let typeReference = getTypeReferenceAsString(type, typeChecker);
if (!typeReference) {
return undefined;
Expand All @@ -221,6 +241,7 @@ export class ModelClassVisitor {
importsToAdd.add(importPath.slice(2, importPath.length - 1));
}
}

return ts.createPropertyAssignment(
key,
ts.createArrowFunction(
Expand Down
2 changes: 1 addition & 1 deletion tests/plugin/fixtures/create-cat-alt.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export class CreateCatDto2 {
this.status = Status.ENABLED;
}
static _GRAPHQL_METADATA_FACTORY() {
return { name: { nullable: false, type: () => String, description: "name description" }, age: { nullable: false, type: () => Number, description: "test on age" }, tags: { nullable: false, type: () => [String] }, status: { nullable: false, type: () => Status }, breed: { nullable: true, type: () => String }, nodes: { nullable: false, type: () => [Object] }, alias: { nullable: false, type: () => Object }, numberAlias: { nullable: false, type: () => Number }, union: { nullable: false, type: () => Object }, intersection: { nullable: false, type: () => Object }, optionalBoolean: { nullable: true, type: () => Boolean }, nested: { nullable: false, type: () => ({ first: { nullable: false, type: () => String }, second: { nullable: false, type: () => Number }, status: { nullable: false, type: () => Status }, tags: { nullable: false, type: () => [String] }, nodes: { nullable: false, type: () => [Object] }, alias: { nullable: false, type: () => Object }, numberAlias: { nullable: false, type: () => Number } }) }, tuple: { nullable: false, type: () => Object } };
return { name: { type: () => String, description: "name description" }, age: { type: () => Number, description: "test on age" }, tags: { type: () => [String] }, status: { type: () => Status }, breed: { nullable: true, type: () => String }, nodes: { type: () => [Object] }, alias: { type: () => Object }, numberAlias: { type: () => Number }, union: { type: () => Object }, intersection: { type: () => Object }, optionalBoolean: { nullable: true, type: () => Boolean }, nested: { type: () => ({ first: { type: () => String }, second: { type: () => Number }, status: { type: () => Status }, tags: { type: () => [String] }, nodes: { type: () => [Object] }, alias: { type: () => Object }, numberAlias: { type: () => Number } }) }, tuple: { type: () => Object } };
}
}
`;
2 changes: 1 addition & 1 deletion tests/plugin/fixtures/create-cat.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class CreateCatDto {
this.status = Status.ENABLED;
}
static _GRAPHQL_METADATA_FACTORY() {
return { name: { nullable: false, type: () => String }, age: { nullable: false, type: () => Number }, tags: { nullable: false, type: () => [String] }, status: { nullable: false, type: () => Status }, status2: { nullable: true, type: () => Status }, statusArr: { nullable: true, type: () => [Status] }, breed: { nullable: true, type: () => String }, nodes: { nullable: false, type: () => [Object] }, date: { nullable: false, type: () => Date } };
return { name: { type: () => String }, age: { type: () => Number }, tags: { type: () => [String] }, status: { type: () => Status }, status2: { nullable: true, type: () => Status }, statusArr: { nullable: true, type: () => [Status] }, breed: { nullable: true, type: () => String }, nodes: { type: () => [Object] }, date: { type: () => Date } };
}
}
__decorate([
Expand Down
2 changes: 1 addition & 1 deletion tests/plugin/fixtures/es5-class.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var CreateCatDtoEs5 = /** @class */ (function () {
this.obj = constants_1.CONSTANT_OBJECT;
}
CreateCatDtoEs5._GRAPHQL_METADATA_FACTORY = function () {
return { name: { nullable: false, type: function () { return String; } }, status: { nullable: false, type: function () { return Object; } }, obj: { nullable: false, type: function () { return Object; } } };
return { name: { type: function () { return String; } }, status: { type: function () { return Object; } }, obj: { type: function () { return Object; } } };
};
return CreateCatDtoEs5;
}());
Expand Down
34 changes: 34 additions & 0 deletions tests/plugin/fixtures/nullable.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export const nullableDtoText = `
enum Status {
ENABLED,
DISABLED
}
export class NullableDto {
name: string | null;
age: number = 3;
tags: string[];
status: Status = Status.ENABLED;
status2?: Status | null;
statusArr?: Status[];
readonly breed?: string;
nodes: Node[];
date: Date | undefined;
}
`;

export const nullableDtoTextTranspiled = `var Status;
(function (Status) {
Status[Status["ENABLED"] = 0] = "ENABLED";
Status[Status["DISABLED"] = 1] = "DISABLED";
})(Status || (Status = {}));
export class NullableDto {
constructor() {
this.age = 3;
this.status = Status.ENABLED;
}
static _GRAPHQL_METADATA_FACTORY() {
return { name: { nullable: true, type: () => String }, age: { type: () => Number }, tags: { type: () => [String] }, status: { type: () => Status }, status2: { nullable: true, type: () => Status }, statusArr: { nullable: true, type: () => [Status] }, breed: { nullable: true, type: () => String }, nodes: { type: () => [Object] }, date: { nullable: true, type: () => Date } };
}
}
`
25 changes: 25 additions & 0 deletions tests/plugin/model-class-visitor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
es5CreateCatDtoText,
es5CreateCatDtoTextTranspiled,
} from './fixtures/es5-class.dto';
import {
nullableDtoText,
nullableDtoTextTranspiled
} from './fixtures/nullable.dto';

describe('API model properties', () => {
it('should add the metadata factory when no decorators exist', () => {
Expand Down Expand Up @@ -84,4 +88,25 @@ describe('API model properties', () => {
});
expect(result.outputText).toEqual(es5CreateCatDtoTextTranspiled);
});

it('should support & understand nullable type unions', () => {
const options: ts.CompilerOptions = {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
newLine: ts.NewLineKind.LineFeed,
noEmitHelpers: true,
strict: true
};
const filename = 'nullable.input.ts';
const fakeProgram = ts.createProgram([filename], options);

const result = ts.transpileModule(nullableDtoText, {
compilerOptions: options,
fileName: filename,
transformers: {
before: [before({}, fakeProgram)],
},
});
expect(result.outputText).toEqual(nullableDtoTextTranspiled);
});
});

0 comments on commit 588d140

Please sign in to comment.