Skip to content

Commit

Permalink
feat(defaults): add support for default values for input types (#203)
Browse files Browse the repository at this point in the history
  • Loading branch information
benawad authored and MichalLytek committed Dec 15, 2018
1 parent 7ca81ec commit 44e8f89
Show file tree
Hide file tree
Showing 17 changed files with 311 additions and 28 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog and release notes

## Unreleased
### Features
- add support for default values in schema (#203)

## v0.15.0
### Features
- **Breaking Change**: upgrade `graphql` to `^14.0.2`, `graphql-subscriptions` to `^1.0.0` and `@types/graphql` to `^14.0.2`
Expand Down
6 changes: 3 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,11 @@ class NewRecipeDataInput {

@ArgsType()
class RecipesArgs {
@Field(type => Int, { nullable: true })
@Field(type => Int)
@Min(0)
skip: number = 0;

@Field(type => Int, { nullable: true })
@Field(type => Int)
@Min(1) @Max(50)
take: number = 25;
}
Expand Down Expand Up @@ -159,7 +159,7 @@ input NewRecipeInput {
}
type Query {
recipe(id: ID!): Recipe
recipes(skip: Int, take: Int): [Recipe!]!
recipes(skip: Int = 0, take: Int = 25): [Recipe!]!
}
type Mutation {
addRecipe(newRecipeData: NewRecipeInput!): Recipe!
Expand Down
8 changes: 4 additions & 4 deletions docs/interfaces-and-inheritance.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ While creating GraphQL API, it's a common pattern to have pagination args in res
```typescript
@ArgsType()
class PaginationArgs {
@Field(type => Int, { nullable: true })
@Field(type => Int)
skip: number = 0;

@Field(type => Int, { nullable: true })
@Field(type => Int)
take: number = 25;
}
```
Expand All @@ -63,7 +63,7 @@ and then reuse it everywhere:
```typescript
@ArgsType()
class GetTodosArgs extends PaginationArgs {
@Field({ nullable: false })
@Field()
onlyCompleted: boolean = false;
}
```
Expand All @@ -83,7 +83,7 @@ class Student extends Person {
}
```

Note that both the subclass and the parent class must be decorated with the same type of decorator, like `@ObjectType()` in the example `Person -> Student` above. Mixing decorator types across parent and child classes is prohibited and might result in schema building error --- you can't e.g decorate the subclass with `@ObjectType()` and the parent with `@InputType()`.
Note that both the subclass and the parent class must be decorated with the same type of decorator, like `@ObjectType()` in the example `Person -> Student` above. Mixing decorator types across parent and child classes is prohibited and might result in schema building error - you can't e.g decorate the subclass with `@ObjectType()` and the parent with `@InputType()`.

## Resolvers inheritance
The special kind of inheritance in TypeGraphQL is a resolver classes inheritance. This pattern allows you to e.g. create a base CRUD resolver class for your resource/entity, so you don't have to repeat the common boilerplate code all the time.
Expand Down
19 changes: 11 additions & 8 deletions docs/resolvers.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Resolvers
---

Besides [declaring GraphQL's object types](./types-and-fields.md), TypeGraphQL allows to create queries, mutations and field resolvers in an easy way - like a normal class methods, similar to REST controllers in frameworks like Java's `Spring`, .NET `Web API` or TypeScript's [routing-controllers](https://github.com/typestack/routing-controllers).
Besides [declaring GraphQL's object types](./types-and-fields.md), TypeGraphQL allows to create queries, mutations and field resolvers in an easy way - like a normal class methods, similar to REST controllers in frameworks like Java's `Spring`, .NET `Web API` or TypeScript's [`routing-controllers`](https://github.com/typestack/routing-controllers).

## Queries and mutations

Expand Down Expand Up @@ -54,14 +54,15 @@ class RecipeResolver {
### Arguments
Usually queries have some arguments - it might be an id of the resource, the search phrase or pagination settings. TypeGraphQL allows you to define the arguments in two ways.

First is the inline method using `@Arg()` decorator. The drawback is the need of repeating argument name (due to a reflection system limitation) in the decorator parameter.
First is the inline method using `@Arg()` decorator. The drawback is the need of repeating argument name (due to a reflection system limitation) in the decorator parameter. As you can see below, you can also pass a `defaultValue` options that will be reflected in the GraphQL schema.
```typescript
@Resolver()
class RecipeResolver {
// ...
@Query(returns => [Recipe])
async recipes(
@Arg("title" { nullable: true }) title?: string,
@Arg("servings" { defaultValue: 2 }) servings: number,
): Promise<Recipe[]> {
// ...
}
Expand All @@ -81,17 +82,19 @@ class GetRecipesArgs {
title?: string;
}
```
You can define default values for optional fields (remember about `nullable: true`!) as well as helper methods.
Also, this way of declaring arguments allows you to perform validation. You can find more details about this feature in [the validation docs](./validation.md).

You can define default values for optional fields in the `@Field()` decorator using a `defaultValue` option or by using a property initializer - in both cases TypeGraphQL will reflect this in the schema by setting the default value and making the field nullable.

Also, this way of declaring arguments allows you to perform validation. You can find more details about this feature in [the validation docs](./validation.md). You can also define a helper fields and methods for your args or input class.

```typescript
@ArgsType()
class GetRecipesArgs {
@Field(type => Int, { nullable: true })
@Field(type => Int, { defaultValue: 0 })
@Min(0)
skip = 0;
skip: number;

@Field(type => Int, { nullable: true })
@Field(type => Int)
@Min(1) @Max(50)
take = 25;

Expand Down Expand Up @@ -125,7 +128,7 @@ class RecipeResolver {
This declarations will result in the following part of the schema in SDL:
```graphql
type Query {
recipes(skip: Int, take: Int, title: String): [Recipe!]
recipes(skip: Int = 0, take: Int = 25, title: String): [Recipe!]
}
```

Expand Down
2 changes: 1 addition & 1 deletion docs/types-and-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ For simple types (like `string` or `boolean`) it's enough but unfortunately, due

Why function syntax, not simple `{ type: Rate }` config object? Because this way we solve problems with circular dependencies (e.g. Post <--> User), so it was adopted as a convention. You can use the shorthand syntax `@Field(() => Rate)` if you want to save some keystrokes but it might be less readable for others.

For nullable properties like `averageRating` (it might be not defined when recipe has no ratings yet), we mark the class property as optional with `?:` operator and also have to pass `{ nullable: true }` decorator parameter. Be aware that when you declare your type as `string | null`, you need to explicitly provide the type to the `@Field` decorator.
For nullable properties like `averageRating` (it might be not defined when recipe has no ratings yet), we mark the class property as optional with `?:` operator and also have to pass `{ nullable: true }` decorator parameter. Be aware that when you declare your type as a nullable union (e.g. `string | null`), you need to explicitly provide the type to the `@Field` decorator.

In the config object we can also provide `description` and `deprecationReason` for GraphQL schema purposes.

Expand Down
4 changes: 2 additions & 2 deletions examples/automatic-validation/recipes-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { ArgsType, Field, Int } from "../../src";

@ArgsType()
export class RecipesArguments {
@Field(type => Int, { nullable: true })
@Field(type => Int)
@Min(0)
skip: number = 0;

@Field(type => Int, { nullable: true })
@Field(type => Int)
@Min(1)
@Max(50)
take: number = 10;
Expand Down
4 changes: 2 additions & 2 deletions examples/middlewares/recipe/recipe.args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { ArgsType, Field, Int } from "../../../src";

@ArgsType()
export class RecipesArgs {
@Field(type => Int, { nullable: true })
@Field(type => Int)
@Min(0)
skip: number = 0;

@Field(type => Int, { nullable: true })
@Field(type => Int)
@Min(1)
@Max(50)
take: number = 10;
Expand Down
4 changes: 2 additions & 2 deletions examples/resolvers-inheritance/resource/resource.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ export abstract class BaseResourceResolver<TResource extends Resource> {

@ArgsType()
export class GetAllArgs {
@Field(type => Int, { nullable: true })
@Field(type => Int)
skip: number = 0;

@Field(type => Int, { nullable: true })
@Field(type => Int)
take: number = 10;
}

Expand Down
2 changes: 1 addition & 1 deletion examples/simple-usage/recipe-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class RecipeResolver implements ResolverInterface<Recipe> {
@FieldResolver()
ratingsCount(
@Root() recipe: Recipe,
@Arg("minRate", type => Int, { nullable: true }) minRate: number = 0.0,
@Arg("minRate", type => Int, { defaultValue: 0.0 }) minRate: number,
): number {
return recipe.ratings.filter(rating => rating >= minRate).length;
}
Expand Down
2 changes: 1 addition & 1 deletion examples/simple-usage/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type Recipe {
description: String
ratings: [Int!]!
creationDate: DateTime!
ratingsCount(minRate: Int): Int!
ratingsCount(minRate: Int = 0): Int!
averageRating: Float
}

Expand Down
1 change: 1 addition & 0 deletions src/decorators/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type SubscriptionTopicFunc = (

export interface DecoratorTypeOptions {
nullable?: boolean;
defaultValue?: any;
}
export interface TypeOptions extends DecoratorTypeOptions {
array?: boolean;
Expand Down
16 changes: 16 additions & 0 deletions src/errors/ConflictingDefaultValuesError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export class ConflictingDefaultValuesError extends Error {
constructor(
typeName: string,
fieldName: string,
defaultValueFromDecorator: any,
defaultValueFromInitializer: any,
) {
super(
`The '${fieldName}' field of '${typeName}' has conflicting default values. ` +
`Default value from decorator ('${defaultValueFromDecorator}') ` +
`is not equal to the property initializer value ('${defaultValueFromInitializer}').`,
);

Object.setPrototypeOf(this, new.target.prototype);
}
}
1 change: 1 addition & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./ArgumentValidationError";
export * from "./CannotDetermineTypeError";
export * from "./ForbiddenError";
export * from "./GeneratingSchemaError";
export * from "./ConflictingDefaultValuesError";
export * from "./MissingSubscriptionTopicsError";
export * from "./NoExplicitTypeError";
export * from "./ReflectMetadataMissingError";
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function wrapWithTypeOptions<T extends GraphQLType>(
if (typeOptions.array) {
gqlType = new GraphQLList(new GraphQLNonNull(gqlType));
}
if (!typeOptions.nullable) {
if (!typeOptions.nullable && typeOptions.defaultValue === undefined) {
gqlType = new GraphQLNonNull(gqlType);
}
return gqlType as T;
Expand Down
43 changes: 43 additions & 0 deletions src/schema/schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
UnionResolveTypeError,
GeneratingSchemaError,
MissingSubscriptionTopicsError,
ConflictingDefaultValuesError,
} from "../errors";
import { ResolverFilterData, ResolverTopicData } from "../interfaces";
import { getFieldMetadataFromInputType, getFieldMetadataFromObjectType } from "./utils";
Expand Down Expand Up @@ -104,6 +105,30 @@ export abstract class SchemaGenerator {
}
}

private static getDefaultValue(
typeInstance: { [property: string]: unknown },
typeOptions: TypeOptions,
fieldName: string,
typeName: string,
): unknown | undefined {
const defaultValueFromInitializer = typeInstance[fieldName];
if (
typeOptions.defaultValue !== undefined &&
defaultValueFromInitializer !== undefined &&
typeOptions.defaultValue !== defaultValueFromInitializer
) {
throw new ConflictingDefaultValuesError(
typeName,
fieldName,
typeOptions.defaultValue,
defaultValueFromInitializer,
);
}
return typeOptions.defaultValue !== undefined
? typeOptions.defaultValue
: defaultValueFromInitializer;
}

private static buildTypesInfo() {
this.unionTypesInfo = getMetadataStorage().unions.map<UnionTypeInfo>(unionMetadata => {
return {
Expand Down Expand Up @@ -276,6 +301,7 @@ export abstract class SchemaGenerator {
);
return superClassTypeInfo ? superClassTypeInfo.type : undefined;
};
const inputInstance = new (inputType.target as any)();
return {
target: inputType.target,
type: new GraphQLInputObjectType({
Expand All @@ -284,9 +310,17 @@ export abstract class SchemaGenerator {
fields: () => {
let fields = inputType.fields!.reduce<GraphQLInputFieldConfigMap>(
(fieldsMap, field) => {
field.typeOptions.defaultValue = this.getDefaultValue(
inputInstance,
field.typeOptions,
field.name,
inputType.name,
);

fieldsMap[field.schemaName] = {
description: field.description,
type: this.getGraphQLInputType(field.name, field.getType(), field.typeOptions),
defaultValue: field.typeOptions.defaultValue,
};
return fieldsMap;
},
Expand Down Expand Up @@ -410,6 +444,7 @@ export abstract class SchemaGenerator {
args[param.name] = {
description: param.description,
type: this.getGraphQLInputType(param.name, param.getType(), param.typeOptions),
defaultValue: param.typeOptions.defaultValue,
};
} else if (param.kind === "args") {
const argumentType = getMetadataStorage().argumentTypes.find(
Expand All @@ -433,10 +468,18 @@ export abstract class SchemaGenerator {
argumentType: ClassMetadata,
args: GraphQLFieldConfigArgumentMap = {},
) {
const argumentInstance = new (argumentType.target as any)();
argumentType.fields!.forEach(field => {
field.typeOptions.defaultValue = this.getDefaultValue(
argumentInstance,
field.typeOptions,
field.name,
argumentType.name,
);
args[field.schemaName] = {
description: field.description,
type: this.getGraphQLInputType(field.name, field.getType(), field.typeOptions),
defaultValue: field.typeOptions.defaultValue,
};
});
}
Expand Down
1 change: 1 addition & 0 deletions src/schema/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function getFieldMetadataFromInputType(type: GraphQLInputObjectType) {
fieldsMap[fieldName] = {
type: superField.type,
description: superField.description,
defaultValue: superField.defaultValue,
};
return fieldsMap;
},
Expand Down
Loading

0 comments on commit 44e8f89

Please sign in to comment.