Skip to content

Commit

Permalink
feat(fields): add support for nested arrays (#393)
Browse files Browse the repository at this point in the history
  • Loading branch information
Superd22 authored and MichalLytek committed Aug 16, 2019
1 parent ae71d29 commit 44e12ee
Show file tree
Hide file tree
Showing 7 changed files with 112 additions and 14 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
## Features
- rename `DepreciationOptions` interface to `DeprecationOptions` and deprecate the old one
- update deps to newest minor versions (`tslib`, `semver`, `graphql-query-complexity` and `glob`)
- support nested array types (`@Field(() => [[Int]])`)

## v0.17.4
## Features
Expand Down
4 changes: 2 additions & 2 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ We should use the `[ItemType]` syntax any time the field type or the return type

Even though technically the array notation can be omitted (when the base type is not `Promise`) and only provide the type of array item (e.g. `@Field(() => ItemType) field: ItemType[]`) - it's better to be consistent with other annotations by explicitly defining the type.

### How can I define the two-dimension array (nested arrays, array of arrays)?
### How can I define a tuple?

Unfortunately, [GraphQL spec doesn't support 2D arrays](https://github.com/graphql/graphql-spec/issues/423), so you can't just use `data: [[Float]]` as a GraphQL type.
Unfortunately, [GraphQL spec doesn't support tuples](https://github.com/graphql/graphql-spec/issues/423), so you can't just use `data: [Int, Float]` as a GraphQL type.

Instead, you have to create a transient object (or input) type that fits your data, e.g.:

Expand Down
4 changes: 4 additions & 0 deletions docs/types-and-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ For simple types (like `string` or `boolean`) this is all that's needed but due
- `@Field(type => [Rate])` (recommended, explicit `[ ]` syntax for Array types)
- `@Field(itemType => Rate)` (`array` is inferred from reflection - also works but is prone to errors)

For nested arrays however, the explicit `[ ]` notation is required to determine the depth of the array. `@Field(type => [[Int]])` would tell the compiler we expect an integer array of depth 2.

Why use function syntax and not a simple `{ type: Rate }` config object? Because, by using function syntax we solve the problem of 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.

By default, all fields are non nullable, just like properties in TypeScript. However, you can change that behavior by providing `nullableByDefault: true` option in `buildSchema` settings, described in [bootstrap guide](./bootstrap.md).
Expand All @@ -60,6 +62,8 @@ So for nullable properties like `averageRating` which might not be defined when

In the case of lists, we may also need to define their nullability in a more detailed form. The basic `{ nullable: true | false }` setting only applies to the whole list (`[Item!]` or `[Item!]!`), so if we need a sparse array, we can control the list items' nullability via `nullable: items` (for `[Item]!`) or `nullable: itemsAndList` (for the `[Item]`) option. Be aware that setting `nullableByDefault: true` option will also apply to lists, so it will produce `[Item]` type, just like with `nullable: itemsAndList`.

For nested lists, those options apply to the whole depth of the array: `@Field(() => [[Item]]` would by defaut produce `[[Item!]!]!`, setting `nullable: itemsAndList` would produce `[[Item]]` while `nullable: items` would produce `[[Item]]!`

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

So after these changes our example class would look like this:
Expand Down
5 changes: 4 additions & 1 deletion src/decorators/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
TypeResolver,
} from "../interfaces";

export interface RecursiveArray<TValue> extends Array<RecursiveArray<TValue> | TValue> {}

export type TypeValue = ClassType | GraphQLScalarType | Function | object | symbol;
export type ReturnTypeFuncValue = TypeValue | [TypeValue];
export type ReturnTypeFuncValue = TypeValue | RecursiveArray<TypeValue>;

export type TypeValueThunk = (type?: void) => TypeValue;
export type ClassTypeResolver = (of?: void) => ClassType;
Expand All @@ -34,6 +36,7 @@ export type NullableListOptions = "items" | "itemsAndList";

export interface TypeOptions extends DecoratorTypeOptions {
array?: boolean;
arrayDepth?: number;
}
export interface DescriptionOptions {
description?: string;
Expand Down
28 changes: 24 additions & 4 deletions src/helpers/findType.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ReturnTypeFunc, TypeOptions, TypeValueThunk, TypeValue } from "../decorators/types";
import {
ReturnTypeFunc,
TypeOptions,
TypeValueThunk,
TypeValue,
RecursiveArray,
} from "../decorators/types";
import { bannedTypes } from "./returnTypes";
import { NoExplicitTypeError, CannotDetermineTypeError } from "../errors";

Expand Down Expand Up @@ -46,15 +52,19 @@ export function findType({
}
if (metadataDesignType === Array) {
options.array = true;
options.arrayDepth = 1;
}

if (returnTypeFunc) {
const getType = () => {
if (Array.isArray(returnTypeFunc())) {
const returnTypeFuncReturnValue = returnTypeFunc();
if (Array.isArray(returnTypeFuncReturnValue)) {
const { depth, returnType } = findTypeValueArrayDepth(returnTypeFuncReturnValue);
options.array = true;
return (returnTypeFunc() as [TypeValue])[0];
options.arrayDepth = depth;
return returnType;
}
return returnTypeFunc();
return returnTypeFuncReturnValue;
};
return {
getType,
Expand All @@ -69,3 +79,13 @@ export function findType({
throw new CannotDetermineTypeError(prototype.constructor.name, propertyKey, parameterIndex);
}
}

function findTypeValueArrayDepth(
[typeValueOrArray]: RecursiveArray<TypeValue>,
innerDepth = 1,
): { depth: number; returnType: TypeValue } {
if (!Array.isArray(typeValueOrArray)) {
return { depth: innerDepth, returnType: typeValueOrArray };
}
return findTypeValueArrayDepth(typeValueOrArray, innerDepth + 1);
}
26 changes: 19 additions & 7 deletions src/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,15 @@ export function wrapWithTypeOptions<T extends GraphQLType>(
}

let gqlType: GraphQLType = type;

if (typeOptions.array) {
if (
const isNullableArray =
typeOptions.nullable === "items" ||
typeOptions.nullable === "itemsAndList" ||
(typeOptions.nullable === undefined && nullableByDefault === true)
) {
gqlType = new GraphQLList(gqlType);
} else {
gqlType = new GraphQLList(new GraphQLNonNull(gqlType));
}
(typeOptions.nullable === undefined && nullableByDefault === true);
gqlType = wrapTypeInNestedList(gqlType, typeOptions.arrayDepth!, isNullableArray);
}

if (
typeOptions.defaultValue === undefined &&
(typeOptions.nullable === false ||
Expand All @@ -80,6 +78,7 @@ export function wrapWithTypeOptions<T extends GraphQLType>(
) {
gqlType = new GraphQLNonNull(gqlType);
}

return gqlType as T;
}

Expand Down Expand Up @@ -113,3 +112,16 @@ export function getEnumValuesMap<T extends object>(enumObject: T) {
}, {});
return enumMap;
}

function wrapTypeInNestedList(
targetType: GraphQLType,
depth: number,
nullable: boolean,
): GraphQLList<GraphQLType> {
const targetTypeNonNull = nullable ? targetType : new GraphQLNonNull(targetType);

if (depth === 0) {
return targetType as GraphQLList<GraphQLType>;
}
return wrapTypeInNestedList(new GraphQLList(targetTypeNonNull), depth - 1, nullable);
}
58 changes: 58 additions & 0 deletions tests/functional/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ describe("Fields - schema", () => {

@Field({ name: "complexField", complexity: 10 })
complexField: string;

@Field(type => [[String]], { nullable: true })
nullableNestedArrayField: string[][] | null;

@Field(type => [[String]], { nullable: "items" })
nonNullNestedArrayWithNullableItemField: Array<Array<string | null> | null>;

@Field(type => [[String]], { nullable: "itemsAndList" })
nestedArrayWithNullableItemField: Array<Array<string | null> | null> | null;
}

@Resolver(of => SampleObject)
Expand Down Expand Up @@ -311,4 +320,53 @@ describe("Fields - schema", () => {
expect(overwrittenNameFieldType.kind).toEqual(TypeKind.SCALAR);
expect(overwrittenNameFieldType.name).toEqual("String");
});

it("should generate nullable nested array field type when declared using mongoose syntax", async () => {
const nullableNestedArrayField = sampleObjectType.fields.find(
field => field.name === "nullableNestedArrayField",
)!;
const arrayFieldType = nullableNestedArrayField.type as IntrospectionListTypeRef;
const arrayItemNonNullFieldType = arrayFieldType.ofType as IntrospectionNonNullTypeRef;
const arrayItemFieldType = arrayItemNonNullFieldType.ofType as IntrospectionListTypeRef;
const arrayItemScalarNonNullFieldType = arrayItemFieldType.ofType as IntrospectionNonNullTypeRef;
const arrayItemScalarFieldType = arrayItemScalarNonNullFieldType.ofType as IntrospectionNamedTypeRef;

expect(arrayFieldType.kind).toEqual(TypeKind.LIST);
expect(arrayItemNonNullFieldType.kind).toEqual(TypeKind.NON_NULL);
expect(arrayItemFieldType.kind).toEqual(TypeKind.LIST);
expect(arrayItemScalarNonNullFieldType.kind).toEqual(TypeKind.NON_NULL);
expect(arrayItemScalarFieldType.kind).toEqual(TypeKind.SCALAR);
expect(arrayItemScalarFieldType.name).toEqual("String");
});

it("should generate nested array with nullable option 'items'", async () => {
const nestedArrayField = sampleObjectType.fields.find(
field => field.name === "nonNullNestedArrayWithNullableItemField",
)!;

const arrayNonNullFieldType = nestedArrayField.type as IntrospectionNonNullTypeRef;
const arrayItemFieldType = arrayNonNullFieldType.ofType as IntrospectionListTypeRef;
const arrayItemInnerFieldType = arrayItemFieldType.ofType as IntrospectionListTypeRef;
const arrayItemScalarFieldType = arrayItemInnerFieldType.ofType as IntrospectionNamedTypeRef;

expect(arrayNonNullFieldType.kind).toEqual(TypeKind.NON_NULL);
expect(arrayItemFieldType.kind).toEqual(TypeKind.LIST);
expect(arrayItemInnerFieldType.kind).toEqual(TypeKind.LIST);
expect(arrayItemScalarFieldType.kind).toEqual(TypeKind.SCALAR);
expect(arrayItemScalarFieldType.name).toEqual("String");
});

it("should generate nullable nested array with nullable option 'itemsAndList'", async () => {
const nullableNestedArrayField = sampleObjectType.fields.find(
field => field.name === "nestedArrayWithNullableItemField",
)!;
const arrayFieldType = nullableNestedArrayField.type as IntrospectionListTypeRef;
const arrayItemFieldType = arrayFieldType.ofType as IntrospectionListTypeRef;
const arrayItemScalarFieldType = arrayItemFieldType.ofType as IntrospectionNamedTypeRef;

expect(arrayFieldType.kind).toEqual(TypeKind.LIST);
expect(arrayItemFieldType.kind).toEqual(TypeKind.LIST);
expect(arrayItemScalarFieldType.kind).toEqual(TypeKind.SCALAR);
expect(arrayItemScalarFieldType.name).toEqual("String");
});
});

0 comments on commit 44e12ee

Please sign in to comment.