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(class-transformer-decorators): add plugin #856

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @graphql-codegen/typescript-class-transformer-decorator

## 1.0.0

### Major Changes

- 8c2ae046: 🚀🚀 NEW PLUGIN: @graphql-codegen/typescript-class-transformer-decorator - decorate classes with class-transformer support 🚀🚀
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../../../jest.project')({ dirname: __dirname });
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@graphql-codegen/typescript-class-transformer-decorator",
"version": "1.0.0",
"type": "module",
"description": "GraphQL Code Generator plugin for decorating classes with class-transformer support",
"repository": {
"type": "git",
"url": "https://github.com/dotansimha/graphql-code-generator-community.git",
"directory": "packages/plugins/typescript/class-transformer-decorator"
},
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"exports": {
".": {
"require": {
"types": "./dist/typings/index.d.cts",
"default": "./dist/cjs/index.js"
},
"import": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
},
"default": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
}
},
"./package.json": "./package.json"
},
"typings": "dist/typings/index.d.ts",
"scripts": {
"lint": "eslint **/*.ts",
"test": "jest --no-watchman --config ../../../../jest.config.js"
},
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
},
"dependencies": {
"@babel/generator": "^7.25.7",
"@babel/parser": "^7.25.7",
"@babel/traverse": "^7.25.7",
"@babel/types": "^7.25.7",
"@graphql-codegen/plugin-helpers": "^3.0.0",
"@graphql-codegen/typescript": "^4.0.9"
},
"devDependencies": {},
"publishConfig": {
"directory": "dist",
"access": "public"
},
"typescript": {
"definition": "dist/typings/index.d.ts"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type ClassTransformerDecoratorConfig = {
declarationKind?: 'class';
// List of classes to decorate
classWhitelist?: string[];
// RegExp pattern of classes to decorate
classNamePattern?: string;
};
136 changes: 136 additions & 0 deletions packages/plugins/typescript/class-transformer-decorators/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import generate from '@babel/generator';
import * as parser from '@babel/parser';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
import { PluginFunction } from '@graphql-codegen/plugin-helpers';
import { plugin as typescriptPlugin } from '@graphql-codegen/typescript';
import { ClassTransformerDecoratorConfig } from './config';

export const plugin: PluginFunction<ClassTransformerDecoratorConfig> = async (
schema,
documents,
config,
) => {
// Generate the initial TypeScript code using the default TypeScript plugin
const generatedTS = await typescriptPlugin(schema, documents, config);
const generatedContent = generatedTS.content || '';

// Parse the generated TypeScript code into an AST
const ast = parser.parse(generatedContent, {
sourceType: 'module',
plugins: ['typescript', 'classProperties', 'classPrivateProperties', 'decorators-legacy'],
});

// Get the class whitelist and class name pattern from the plugin config
const classWhitelist = config.classWhitelist || [];
const classNamePattern = config.classNamePattern || null; // Default to null if not provided
let classNameRegex = null;

if (classNamePattern) {
classNameRegex = new RegExp(classNamePattern);
}

// Collect all class names to identify custom types
const classNames = new Set();
traverse(ast, {
ClassDeclaration(path) {
classNames.add(path.node.id.name);
},
});

// Traverse the AST and add decorators
traverse(ast, {
ClassDeclaration(classPath) {
const className = classPath.node.id.name;

// Determine if the class should be processed
const isWhitelisted = classWhitelist.includes(className);
const matchesPattern = classNameRegex ? classNameRegex.test(className) : false;

// Skip classes that are neither whitelisted nor matching the pattern
if (!isWhitelisted && !matchesPattern) {
return; // Skip this class
}

classPath
.get('body')
.get('body')
.forEach(classElementPath => {
// Check if it's a class property
if (
classElementPath.isClassProperty() ||
classElementPath.isTSDeclareMethod() ||
classElementPath.isTSPropertySignature()
) {
// Add @Expose() decorator to the class property
const exposeDecorator = t.decorator(t.callExpression(t.identifier('Expose'), []));
classElementPath.node.decorators = classElementPath.node.decorators || [];
classElementPath.node.decorators.push(exposeDecorator);

// Process type annotation to add @Type() decorator for custom types
const typeAnnotationNode = (classElementPath.node as any).typeAnnotation;

if (typeAnnotationNode && typeAnnotationNode.typeAnnotation) {
const typeName = getTypeName(typeAnnotationNode.typeAnnotation);
if (typeName && classNames.has(typeName)) {
// Add @Type(() => TypeName) decorator
const typeDecorator = t.decorator(
t.callExpression(t.identifier('Type'), [
t.arrowFunctionExpression([], t.identifier(typeName)),
]),
);
classElementPath.node.decorators.push(typeDecorator);
}
}
}
});
},
});

// Generate the modified code from the AST
const { code: modifiedContent } = generate(ast, {}, generatedContent);

// Add the import statement for Expose and Type
const importExpose = `import 'reflect-metadata';\nimport { Expose, Type } from 'class-transformer';`;
const prependContent = (generatedTS.prepend || []).join('\n');

return {
prepend: [importExpose],
content: `${prependContent}\n${modifiedContent}`,
};
};

function getTypeName(typeAnnotation: any): string | null {
switch (typeAnnotation.type) {
case 'TSTypeReference':
if (typeAnnotation.typeParameters && typeAnnotation.typeParameters.params.length > 0) {
for (const param of typeAnnotation.typeParameters.params) {
const typeName = getTypeName(param);
if (typeName) {
return typeName;
}
}
} else if (typeAnnotation.typeName.type === 'Identifier') {
return typeAnnotation.typeName.name;
} else if (typeAnnotation.typeName.type === 'TSQualifiedName') {
return typeAnnotation.typeName.right.name;
}
break;
case 'TSUnionType':
case 'TSIntersectionType':
for (const type of typeAnnotation.types) {
const typeName = getTypeName(type);
if (typeName) {
return typeName;
}
}
break;
case 'TSArrayType':
return getTypeName(typeAnnotation.elementType);
case 'TSParenthesizedType':
return getTypeName(typeAnnotation.typeAnnotation);
default:
return null;
}
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`class-transformer-decorators should decorate @Expose() on matching class properties and @Type on all nested classes 1`] = `
"import 'reflect-metadata';
import { Expose, Type } from 'class-transformer';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export class Scalars {
ID: {
input: string;
output: string;
};
String: {
input: string;
output: string;
};
Boolean: {
input: boolean;
output: boolean;
};
Int: {
input: number;
output: number;
};
Float: {
input: number;
output: number;
};
}
;
export class User {
@Expose()
__typename?: 'User';
@Expose()
id: Scalars['ID']['output'];
@Expose()
name: Scalars['String']['output'];
}
;
export class Address {
__typename?: 'Address';
id?: Maybe<Scalars['String']['output']>;
city: Scalars['String']['output'];
postalCode: Scalars['String']['output'];
stateProvince: Scalars['String']['output'];
street: Scalars['String']['output'];
}
;
export class AddressInput {
@Expose()
id?: InputMaybe<Scalars['String']['input']>;
@Expose()
city: Scalars['String']['input'];
@Expose()
postalCode: Scalars['String']['input'];
@Expose()
stateProvince: Scalars['String']['input'];
@Expose()
street: Scalars['String']['input'];
}
;
export class UserInput {
@Expose()
id?: InputMaybe<Scalars['String']['input']>;
@Expose()
name?: InputMaybe<Scalars['String']['input']>;
@Expose()
@Type(() => AddressInput)
address?: InputMaybe<AddressInput>;
}
;"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { buildSchema } from 'graphql';
import { mergeOutputs } from '@graphql-codegen/plugin-helpers';
import { plugin } from '../src/index.js';

const SCHEMA = `
type User {
id: ID!
name: String!
}

type Address {
id: String
city: String!
postalCode: String!
stateProvince: String!
street: String!
}

input AddressInput {
id: String
city: String!
postalCode: String!
stateProvince: String!
street: String!
}

input UserInput {
id: String
name: String
address: AddressInput
}
`;

describe('class-transformer-decorators', () => {
it('should decorate @Expose() on matching class properties and @Type on all nested classes', async () => {
const schema = buildSchema(SCHEMA);
const result = mergeOutputs([
await plugin(schema, [], {
classWhitelist: ['User'],
classNamePattern: '.*Input$',
declarationKind: 'class',
}),
]);

expect(result).toMatchSnapshot();
});
});