Skip to content

Commit

Permalink
fix(inputs): transform and validate nested inputs and arrays
Browse files Browse the repository at this point in the history
  • Loading branch information
MichalLytek committed Nov 19, 2019
1 parent 0d716c8 commit a56048d
Show file tree
Hide file tree
Showing 7 changed files with 311 additions and 51 deletions.
4 changes: 4 additions & 0 deletions src/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export function convertToType(Target: any, data?: object): object | undefined {
if (data instanceof Target) {
return data;
}
// convert array to instances
if (Array.isArray(data)) {
return data.map(item => convertToType(Target, item));
}

return Object.assign(new Target(), data);
}
Expand Down
138 changes: 138 additions & 0 deletions src/resolvers/convert-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { ArgParamMetadata, ClassMetadata, ArgsParamMetadata } from "../metadata/definitions";
import { convertToType } from "../helpers/types";
import { ArgsDictionary, ClassType } from "../interfaces";
import { getMetadataStorage } from "../metadata/getMetadataStorage";
import { TypeValue } from "../decorators/types";

interface TransformationTreeField {
name: string;
target: TypeValue;
fields?: TransformationTree;
}

interface TransformationTree {
target: TypeValue;
getFields: () => TransformationTreeField[];
}

const generatedTrees = new Map<TypeValue, TransformationTree | null>();

function getInputType(target: TypeValue): ClassMetadata | undefined {
return getMetadataStorage().inputTypes.find(t => t.target === target);
}

function getArgsType(target: TypeValue): ClassMetadata | undefined {
return getMetadataStorage().argumentTypes.find(t => t.target === target);
}

function generateInstanceTransformationTree(target: TypeValue): TransformationTree | null {
if (generatedTrees.has(target)) {
return generatedTrees.get(target)!;
}

const inputType = getInputType(target);
if (!inputType) {
generatedTrees.set(target, null);
return null;
}

function generateTransformationTree(metadata: ClassMetadata): TransformationTree {
let inputFields = metadata.fields!;
let superClass = Object.getPrototypeOf(metadata.target);
while (superClass.prototype !== undefined) {
const superInputType = getInputType(superClass);
if (superInputType) {
inputFields = [...inputFields, ...superInputType.fields!];
}
superClass = Object.getPrototypeOf(superClass);
}

const transformationTree: TransformationTree = {
target: metadata.target,
getFields: () =>
inputFields.map<TransformationTreeField>(field => {
const fieldTarget = field.getType();
const fieldInputType = getInputType(fieldTarget);
return {
name: field.name,
target: fieldTarget,
fields:
fieldTarget === metadata.target
? transformationTree
: fieldInputType && generateTransformationTree(fieldInputType),
};
}),
};

return transformationTree;
}

const generatedTransformationTree = generateTransformationTree(inputType);
generatedTrees.set(target, generatedTransformationTree);
return generatedTransformationTree;
}

function convertToInput(tree: TransformationTree, data: any) {
const inputFields = tree.getFields().reduce<Record<string, any>>((fields, field) => {
const siblings = field.fields;
const value = data[field.name];
if (!siblings || !value) {
fields[field.name] = convertToType(field.target, value);
} else if (Array.isArray(value)) {
fields[field.name] = value.map(itemValue => convertToInput(siblings, itemValue));
} else {
fields[field.name] = convertToInput(siblings, value);
}
return fields;
}, {});

return convertToType(tree.target, inputFields);
}

function convertValueToInstance(target: TypeValue, value: any) {
const transformationTree = generateInstanceTransformationTree(target);
return transformationTree
? convertToInput(transformationTree, value)
: convertToType(target, value);
}

function convertValuesToInstances(target: TypeValue, value: any) {
// skip converting undefined and null
if (value == null) {
return value;
}
if (Array.isArray(value)) {
return value.map(itemValue => convertValueToInstance(target, itemValue));
}
return convertValueToInstance(target, value);
}

export function convertArgsToInstance(argsMetadata: ArgsParamMetadata, args: ArgsDictionary) {
const ArgsClass = argsMetadata.getType() as ClassType;
const argsType = getArgsType(ArgsClass)!;

let argsFields = argsType.fields!;
let superClass = Object.getPrototypeOf(argsType.target);
while (superClass.prototype !== undefined) {
const superArgumentType = getArgsType(superClass);
if (superArgumentType) {
argsFields = [...argsFields, ...superArgumentType.fields!];
}
superClass = Object.getPrototypeOf(superClass);
}

const transformedFields = argsFields.reduce<Record<string, any>>((fields, field) => {
const fieldValue = args[field.name];
const fieldTarget = field.getType();
fields[field.name] = convertValuesToInstances(fieldTarget, fieldValue);
return fields;
}, {});

return convertToType(ArgsClass, transformedFields);
}

export function convertArgToInstance(argMetadata: ArgParamMetadata, args: ArgsDictionary) {
const argValue = args[argMetadata.name];
const argTarget = argMetadata.getType();
return convertValuesToInstances(argTarget, argValue);
}
5 changes: 3 additions & 2 deletions src/resolvers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ResolverData, AuthChecker, AuthMode } from "../interfaces";
import { Middleware, MiddlewareFn, MiddlewareClass } from "../interfaces/Middleware";
import { IOCContainer } from "../utils/container";
import { AuthMiddleware } from "../helpers/auth-middleware";
import { convertArgsToInstance, convertArgToInstance } from "./convert-args";

export async function getParams(
params: ParamMetadata[],
Expand All @@ -22,13 +23,13 @@ export async function getParams(
switch (paramInfo.kind) {
case "args":
return await validateArg(
convertToType(paramInfo.getType(), resolverData.args),
convertArgsToInstance(paramInfo, resolverData.args),
globalValidate,
paramInfo.validate,
);
case "arg":
return await validateArg(
convertToType(paramInfo.getType(), resolverData.args[paramInfo.name]),
convertArgToInstance(paramInfo, resolverData.args),
globalValidate,
paramInfo.validate,
);
Expand Down
6 changes: 5 additions & 1 deletion src/resolvers/validate-arg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ export async function validateArg<T extends Object>(

const { validateOrReject } = await import("class-validator");
try {
await validateOrReject(arg, validatorOptions);
if (Array.isArray(arg)) {
await Promise.all(arg.map(argItem => validateOrReject(argItem, validatorOptions)));
} else {
await validateOrReject(arg, validatorOptions);
}
return arg;
} catch (err) {
throw new ArgumentValidationError(err);
Expand Down
2 changes: 1 addition & 1 deletion src/schema/schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ export abstract class SchemaGenerator {
while (superClass.prototype !== undefined) {
const superArgumentType = getMetadataStorage().argumentTypes.find(
it => it.target === superClass,
)!;
);
if (superArgumentType) {
this.mapArgFields(superArgumentType, args);
}
Expand Down
25 changes: 24 additions & 1 deletion tests/functional/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,9 @@ describe("Resolvers", () => {
@Field()
nestedField: SampleInput;

@Field({ nullable: true })
optionalNestedField?: SampleInput;

@Field(type => [SampleInput])
nestedArrayField: SampleInput[];
}
Expand Down Expand Up @@ -1324,6 +1327,14 @@ describe("Resolvers", () => {
return inputs[0].factor;
}

@Mutation()
mutationWithOptionalArg(
@Arg("input", { nullable: true }) input?: SampleNestedInput,
): number {
mutationInputValue = typeof input;
return 0;
}

@FieldResolver()
fieldResolverField() {
return this.randomValueField;
Expand Down Expand Up @@ -1554,7 +1565,7 @@ describe("Resolvers", () => {
expect(result).toBeLessThanOrEqual(10);
});

it("should create instances of nested input fields input objects", async () => {
it("should create instances of nested input fields input objects without nulls", async () => {
const mutation = `mutation {
mutationWithNestedInputs(input: {
nestedField: {
Expand All @@ -1574,6 +1585,7 @@ describe("Resolvers", () => {
expect(mutationInputValue).toBeInstanceOf(classes.SampleNestedInput);
expect(mutationInputValue.nestedField).toBeInstanceOf(classes.SampleInput);
expect(mutationInputValue.nestedArrayField[0]).toBeInstanceOf(classes.SampleInput);
expect(mutationInputValue.optionalNestedField).toBeUndefined();
});

it("should create instance of nested input field of args type object", async () => {
Expand Down Expand Up @@ -1604,6 +1616,17 @@ describe("Resolvers", () => {
expect(mutationInputValue.instanceField).toBeLessThanOrEqual(1);
});

it("shouldn't create instance of an argument if the value is null or not provided", async () => {
const mutation = `mutation {
mutationWithOptionalArg
}`;

const { data, errors } = await graphql(schema, mutation);
expect(errors).toBeUndefined();
expect(data.mutationWithOptionalArg).toBeDefined();
expect(mutationInputValue).toEqual("undefined");
});

it("should create instance of root object when root type is provided", async () => {
const query = `query {
sampleQuery {
Expand Down
Loading

0 comments on commit a56048d

Please sign in to comment.