Skip to content

Commit

Permalink
feat: Custom decorators
Browse files Browse the repository at this point in the history
  • Loading branch information
unlight committed Apr 7, 2021
1 parent 70198db commit b14f0fe
Show file tree
Hide file tree
Showing 9 changed files with 437 additions and 163 deletions.
202 changes: 200 additions & 2 deletions src/generate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
SourceFile,
} from 'ts-morph';

import { User } from './@generated/user/user.model';
import { generate } from './generate';
import { generateFileName } from './helpers/generate-file-name';
import {
Expand All @@ -26,11 +25,13 @@ let sourceText: string;
let project: Project;
let propertyStructure: PropertyDeclarationStructure;
let imports: ReturnType<typeof getImportDeclarations>;
let classFile: ClassDeclaration;

const p = (name: string) => getPropertyStructure(sourceFile, name);
const d = (name: string) => getPropertyStructure(sourceFile, name)?.decorators?.[0];
const setSourceFile = (name: string) => {
sourceFile = project.getSourceFile(s => s.getFilePath().endsWith(name))!;
classFile = sourceFile.getClass(() => true)!;
sourceText = sourceFile.getText();
imports = getImportDeclarations(sourceFile);
};
Expand Down Expand Up @@ -260,7 +261,6 @@ describe('model with one id int', () => {
});

describe('aggregate user args', () => {
let classFile: ClassDeclaration;
before(() => {
sourceFile = project.getSourceFile(s =>
s.getFilePath().endsWith('aggregate-user.args.ts'),
Expand Down Expand Up @@ -1574,3 +1574,201 @@ describe('emit single', () => {
// it('^', () => console.log(sourceFile.getText()));
});
});

describe('custom decorators namespace both input and output', () => {
let importDeclarations: any[];
before(async () => {
await testGenerate({
schema: `
model User {
id Int @id
/// @Validator.MaxLength(30)
name String
/// @Validator.Max(999)
/// @Validator.Min(18)
age Int
/// @Validator.IsEmail()
email String?
}`,
options: [
`outputFilePattern = "{name}.{type}.ts"`,
// custom decorators (validate)
// import * as Validator from 'class-validator'
// @Validator.IsEmail()
// email: string
`fields_Validator_from = "class-validator"`,
`fields_Validator_input = true`,
],
});
});

describe('custom decorators in user create input', () => {
before(() => {
setSourceFile('user-create.input.ts');
importDeclarations = sourceFile
.getImportDeclarations()
.map(d => d.getStructure());
});

// it('^', () => console.log(sourceFile.getText()));

it('decorator validator maxlength should exists', () => {
const d = classFile
.getProperty('name')
?.getDecorator(d => d.getFullName() === 'Validator.MaxLength');
expect(d).toBeTruthy();
expect(d?.getText()).toBe('@Validator.MaxLength(30)');
});

it('imports should contains custom import', () => {
expect(importDeclarations).toContainEqual(
expect.objectContaining({
namespaceImport: 'Validator',
moduleSpecifier: 'class-validator',
}),
);
});

it('several decorators', () => {
const decorators = p('age')?.decorators;
expect(decorators).toHaveLength(3);
});
});

describe('user model output should not have validator decorator', () => {
before(() => setSourceFile('user.model.ts'));

describe('should not have metadata in description', () => {
it('age', () => {
expect(d('age')?.arguments?.[1]).not.toContain('description');
});

it('name', () => {
expect(d('name')?.arguments?.[1]).not.toContain('description');
});

it('email', () => {
expect(d('email')?.arguments?.[1]).not.toContain('description');
});
});

it('output model has no maxlength decorator', () => {
const decorator = p('name')?.decorators?.find(d => d.name === 'MaxLength');
expect(decorator).toBeFalsy();
});

// it('^', () => console.log(sourceFile.getText()));
});
});

describe('custom decorators default import', () => {
let importDeclarations: any[];
before(async () => {
await testGenerate({
schema: `
model User {
id Int @id
/// @IsValidName()
name String
}`,
options: [
`outputFilePattern = "{name}.{type}.ts"`,
`fields_IsValidName_from = "is-valid-name"`,
`fields_IsValidName_input = true`,
`fields_IsValidName_defaultImport = IsValidName`,
],
});
});

describe('in user create input', () => {
before(() => {
setSourceFile('user-create.input.ts');
});

it('importDeclarations should import default', () => {
importDeclarations = sourceFile
.getImportDeclarations()
.map(d => d.getStructure())
.filter(d => d.moduleSpecifier === 'is-valid-name');

expect(importDeclarations).toHaveLength(1);
expect(importDeclarations[0]).toEqual(
expect.objectContaining({
defaultImport: 'IsValidName',
namedImports: [],
namespaceImport: undefined,
}),
);
});

// it('^', () => console.log(sourceFile.getText()));
});
});

describe('custom decorators field custom type namespace', () => {
let importDeclarations: any[];
before(async () => {
await testGenerate({
schema: `
model User {
id Int @id
/// @FieldType({ name: 'Scalars.EmailAddress', output: true, input: true })
email String
/// @FieldType('Scalars.EmailAddress')
secondEmail String
}`,
options: [
`outputFilePattern = "{name}.{type}.ts"`,
// import { EmailAddress } from 'graphql-scalars'
// @Field(() => EmailAddress)
`fields_Scalars_from = "graphql-scalars"`,
`fields_Scalars_input = true`,
],
});
});

describe('user create input', () => {
before(() => {
setSourceFile('user-create.input.ts');
});

it('field type', () => {
const decorator = p('email')?.decorators?.find(d => d.name === 'Field');
const typeArgument = decorator?.arguments?.[0];
expect(typeArgument).toEqual('() => Scalars.EmailAddress');
});

it('should not apply to field as decorator', () => {
const decorators = p('email')?.decorators;
expect(decorators).toHaveLength(1);
});

it('field type secondemail', () => {
const decorator = p('secondEmail')?.decorators?.find(
d => d.name === 'Field',
);
const typeArgument = decorator?.arguments?.[0];
expect(typeArgument).toEqual('() => Scalars.EmailAddress');
});

it('importdeclarations should import namespace', () => {
importDeclarations = sourceFile
.getImportDeclarations()
.map(d => d.getStructure())
.filter(d => d.moduleSpecifier === 'graphql-scalars');

expect(importDeclarations).toHaveLength(1);
expect(importDeclarations[0]).toEqual(
expect.objectContaining({
defaultImport: undefined,
namedImports: [],
namespaceImport: 'Scalars',
}),
);
});

// it('^', () => console.log(sourceFile.getText()));
});
});

// it('^', () => console.log(sourceFile.getText()));
103 changes: 70 additions & 33 deletions src/handlers/input-type.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import assert from 'assert';
import JSON5 from 'json5';
import { ClassDeclarationStructure, StructureKind } from 'ts-morph';

Expand All @@ -23,6 +24,9 @@ export function inputType(
config,
eventEmitter,
classDecoratorName,
fieldSettings,
getModelName,
models,
} = args;
const importDeclarations = new ImportDeclarationMap();
const sourceFile = getSourceFile({
Expand All @@ -41,6 +45,8 @@ export function inputType(
],
properties: [],
};
const modelName = getModelName(inputType.name) || '';
const model = models.get(modelName);

importDeclarations
.set('Field', {
Expand All @@ -60,6 +66,7 @@ export function inputType(
const { isList, location, type } = graphqlInputType;
const typeName = String(type);
const customType = config.types[typeName];
const settings = model && fieldSettings.get(model.name)?.get(field.name);

const propertyType = getPropertyType({
location,
Expand All @@ -75,53 +82,83 @@ export function inputType(

classStructure.properties?.push(property);

const graphqlType = getGraphqlType({
location,
type: typeName,
});
let graphqlType: string;
const fieldType = settings?.getFieldType();

const graphqlImport = getGraphqlImport({
sourceFile,
location,
name: graphqlType,
customType,
getSourceFile,
});
if (fieldType) {
graphqlType = fieldType.name;
importDeclarations.create({ ...fieldType });
} else {
graphqlType = getGraphqlType({
location,
type: typeName,
});

// if (inputType.name === 'JsonFilter') {
// console.log({
const graphqlImport = getGraphqlImport({
sourceFile,
location,
name: graphqlType,
customType,
getSourceFile,
});

if (
graphqlImport.name !== inputType.name &&
graphqlImport.specifier &&
!importDeclarations.has(graphqlImport.name)
) {
importDeclarations.set(graphqlImport.name, {
namedImports: [{ name: graphqlImport.name }],
moduleSpecifier: graphqlImport.specifier,
});
}
}

// if (inputType.name === 'UserCreateInput') {
// console.dir({
// 'inputType.name': inputType.name,
// 'field.name': field.name,
// typeName,
// field,
// graphqlInputType,
// propertyType,
// graphqlType,
// graphqlImport,
// // graphqlImport,
// settings,
// });
// }

if (
graphqlImport.name !== inputType.name &&
graphqlImport.specifier &&
!importDeclarations.has(graphqlImport.name)
) {
importDeclarations.set(graphqlImport.name, {
namedImports: [{ name: graphqlImport.name }],
moduleSpecifier: graphqlImport.specifier,
if (settings?.hideInput) {
importDeclarations.add('HideField', '@nestjs/graphql');
property.decorators?.push({ name: 'HideField', arguments: [] });
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
property.decorators!.push({
name: 'Field',
arguments: [
`() => ${isList ? `[${graphqlType}]` : graphqlType}`,
JSON5.stringify({
nullable: !isRequired,
}),
],
});
}

// Generate `@Field()` decorator
property.decorators?.push({
name: 'Field',
arguments: [
`() => ${isList ? `[${graphqlType}]` : graphqlType}`,
JSON5.stringify({
nullable: !isRequired,
}),
],
});
for (const options of settings || []) {
if (!options.input || options.isFieldType) {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
property.decorators!.push({
name: options.name,
arguments: options.arguments,
});
assert(
options.from,
"Missed 'from' part in configuration or field setting",
);
importDeclarations.create(options);
}
}

eventEmitter.emitSync('ClassProperty', property, { location, isList });
}
Expand Down
Loading

0 comments on commit b14f0fe

Please sign in to comment.