Skip to content

Commit

Permalink
feat: add nullable type transformations (#236)
Browse files Browse the repository at this point in the history
This change lets you supply a NullFunctions object which will box and unbox nullable types.

This allows you to e.g. parse items into a Maybe monad
  • Loading branch information
nagirrab committed Jan 30, 2021
1 parent 72254b4 commit c09f3a4
Show file tree
Hide file tree
Showing 9 changed files with 327 additions and 8 deletions.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# `apollo-link-scalars`

<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->

[![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat-square)](#contributors-)

<!-- ALL-CONTRIBUTORS-BADGE:END -->

[![npm version](https://badge.fury.io/js/apollo-link-scalars.svg)](https://badge.fury.io/js/apollo-link-scalars)
Expand Down Expand Up @@ -91,6 +93,7 @@ We can pass extra options to `withScalars()` to modify the behaviour

- **`removeTypenameFromInputs`** (`Boolean`, default `false`): when enabled, it will remove from the inputs the `__typename` if it is found. This could be useful if we are using data received from a query as an input on another query.
- **`validateEnums`** (`Boolean`, default `false`): when enabled, it will validate the enums on parsing, throwing an error if it sees a value that is not one of the enum values.
- **`nullFunction`** (`NullFunction`, default `null`): by passing a set of transforms on how to box and unbox null types, you can automatically construct e.g. Maybe monads from the null types. See below for an example.

```typescript
withScalars({
Expand Down Expand Up @@ -180,6 +183,38 @@ const scalarsLink = withScalars({
});
```

#### Changing the behavior of nullable types

By passing the `nullFunctions` parameter to `withScalar`, you can change the way that nullable types are handled. The default implementation will leave them exactly as is, i.e. `null` => `null` and `value` => `value`. If instead, you e.g. wish to transform nulls into a Maybe monad, you can supply functions corresponding to the following type. The examples below are based on the Maybe monad from [Seidr](https://github.com/hojberg/seidr) but any implementation will do.

```typescript

type NullFunctions = {
serialize(input: any): any | null;
parseValue(raw: any | null): any;
};

const nullFunctions: NullFunctions = {
parseValue(raw: any) {
if (isNone(raw)) {
return Nothing()
} else {
return Just(raw);
}
},
serialize(input: any) {
return input.caseOf({
Just(value) {
return value;
},
Nothing() {
return null;
}
})
},
};
```

## Acknowledgements

The link code is heavily based on [`apollo-link-response-resolver`](https://github.com/with-heart/apollo-link-response-resolver) by [will-heart](https://github.com/with-heart).
Expand Down Expand Up @@ -295,6 +330,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d

<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->

<!-- ALL-CONTRIBUTORS-LIST:END -->

This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
200 changes: 200 additions & 0 deletions src/__tests__/nullable-functions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { ApolloLink, DocumentNode, execute, gql, GraphQLRequest, Observable } from "@apollo/client/core";
import { getOperationName } from "@apollo/client/utilities";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { withScalars } from "..";
import { isNone } from "../lib/is-none";
import { NullFunctions } from "../types/null-functions";

const typeDefs = gql`
type Query {
exampleNullableArray: [String!]
exampleNullableNestedArray: [String]
nonNullObject: ExampleObject!
nullObject: ExampleObject
}
type ExampleObject {
nullField: String
nonNullField: String!
}
type MyInput {
nullField: String
}
`;

const schema = makeExecutableSchema({ typeDefs });

const queryDocument: DocumentNode = gql`
query MyQuery($input: MyInput!) {
exampleNullableArray
exampleNullableNestedArray
nonNullObject {
nullField
nonNullField
}
nullObject {
nullField
nonNullField
}
}
`;
const queryOperationName = getOperationName(queryDocument);
if (!queryOperationName) throw new Error("invalid query operation name");

const responseWithNulls = {
data: {
exampleNullableArray: null,
exampleNullableNestedArray: [null],
nonNullObject: {
nullField: null,
nonNullField: "a",
},
nullObject: null,
},
};

const responseWithoutNulls = {
data: {
exampleNullableArray: ["a"],
exampleNullableNestedArray: [null, "b"],
nonNullObject: {
nullField: "c",
nonNullField: "d",
},
nullObject: {
nullField: "e",
nonNullField: "f",
},
},
};

describe("with default null functions", () => {
const request: GraphQLRequest = {
query: queryDocument,
variables: { input: { nullField: "a" } },
operationName: queryOperationName,
};
it("parses nulls correctly", (done) => {
const link = ApolloLink.from([
withScalars({ schema }),
new ApolloLink(() => {
return Observable.of(responseWithNulls);
}),
]);

const observable = execute(link, request);
observable.subscribe((result) => {
expect(result).toEqual(responseWithNulls);
done();
});
});

it("parses non-nulls correctly", (done) => {
const link = ApolloLink.from([
withScalars({ schema }),
new ApolloLink(() => {
return Observable.of(responseWithoutNulls);
}),
]);

const observable = execute(link, request);
observable.subscribe((result) => {
expect(result).toEqual(responseWithoutNulls);
done();
});
});
});

type Maybe<T> = {
typename: "just" | "nothing";
value?: T;
};

describe("with custom null functions", () => {
const request: GraphQLRequest = {
query: queryDocument,
variables: { input: { nullField: { typename: "just", value: "a" } } },
operationName: queryOperationName,
};

const nullFunctions: NullFunctions = {
parseValue(raw: any): Maybe<any> {
if (isNone(raw)) {
return {
typename: "nothing",
};
} else {
return {
typename: "just",
value: raw,
};
}
},
serialize(input: any) {
if (input.typename === "just") {
return input.value;
} else {
return null;
}
},
};
it("parses nulls correctly", (done) => {
const link = ApolloLink.from([
withScalars({ schema, nullFunctions }),
new ApolloLink(() => {
return Observable.of(responseWithNulls);
}),
]);

const observable = execute(link, request);
observable.subscribe((result) => {
expect(result).toEqual({
data: {
exampleNullableArray: { typename: "nothing" },
exampleNullableNestedArray: { typename: "just", value: [{ typename: "nothing" }] },
nonNullObject: {
nullField: { typename: "nothing" },
nonNullField: "a",
},
nullObject: { typename: "nothing" },
},
});
done();
});
});

it("parses non-nulls correctly", (done) => {
const link = ApolloLink.from([
withScalars({ schema, nullFunctions }),
new ApolloLink(() => {
return Observable.of(responseWithoutNulls);
}),
]);

const observable = execute(link, request);
observable.subscribe((result) => {
expect(result).toEqual({
data: {
exampleNullableArray: { typename: "just", value: ["a"] },
exampleNullableNestedArray: {
typename: "just",
value: [{ typename: "nothing" }, { typename: "just", value: "b" }],
},
nonNullObject: {
nullField: { typename: "just", value: "c" },
nonNullField: "d",
},
nullObject: {
typename: "just",
value: {
nullField: { typename: "just", value: "e" },
nonNullField: "f",
},
},
},
});
done();
});
});
});
10 changes: 10 additions & 0 deletions src/lib/__tests__/default-null-functions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import defaultNullFunctions from "../default-null-functions";

describe("default null functions", () => {
it("parses as identity", () => {
expect(defaultNullFunctions.parseValue("a")).toEqual("a");
});
it("serialies as identity", () => {
expect(defaultNullFunctions.serialize("a")).toEqual("a");
});
});
16 changes: 16 additions & 0 deletions src/lib/default-null-functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NullFunctions } from "../types/null-functions";

/**
* By default, make no transforms for null types. If you prefer, you could use these transforms to e.g.
* transform null into a Maybe monad.
*/
const defaultNullFunctions: NullFunctions = {
serialize(input: any) {
return input;
},
parseValue(raw: any) {
return raw;
},
};

export default defaultNullFunctions;
8 changes: 7 additions & 1 deletion src/lib/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { GraphQLSchema, isInputType, isLeafType, NamedTypeNode, TypeNode } from
import pickBy from "lodash.pickby";
import { ZenObservable } from "zen-observable-ts";
import { FunctionsMap } from "..";
import { NullFunctions } from "../types/null-functions";
import defaultNullFunctions from "./default-null-functions";
import { mapIfArray } from "./map-if-array";
import { isListTypeNode, isNonNullTypeNode, isOperationDefinitionNode } from "./node-types";
import { Serializer } from "./serializer";
Expand All @@ -13,6 +15,7 @@ type ScalarApolloLinkParams = {
typesMap?: FunctionsMap;
validateEnums?: boolean;
removeTypenameFromInputs?: boolean;
nullFunctions?: NullFunctions;
};

export class ScalarApolloLink extends ApolloLink {
Expand All @@ -22,17 +25,19 @@ export class ScalarApolloLink extends ApolloLink {
public readonly removeTypenameFromInputs: boolean;
public readonly functionsMap: FunctionsMap;
public readonly serializer: Serializer;
public readonly nullFunctions: NullFunctions;

constructor(pars: ScalarApolloLinkParams) {
super();
this.schema = pars.schema;
this.typesMap = pars.typesMap || {};
this.validateEnums = pars.validateEnums || false;
this.removeTypenameFromInputs = pars.removeTypenameFromInputs || false;
this.nullFunctions = pars.nullFunctions || defaultNullFunctions;

const leafTypesMap = pickBy(this.schema.getTypeMap(), isLeafType);
this.functionsMap = { ...leafTypesMap, ...this.typesMap };
this.serializer = new Serializer(this.schema, this.functionsMap, this.removeTypenameFromInputs);
this.serializer = new Serializer(this.schema, this.functionsMap, this.removeTypenameFromInputs, this.nullFunctions);
}

// ApolloLink code based on https://github.com/with-heart/apollo-link-response-resolver
Expand Down Expand Up @@ -72,6 +77,7 @@ export class ScalarApolloLink extends ApolloLink {
functionsMap: this.functionsMap,
schema: this.schema,
validateEnums: this.validateEnums,
nullFunctions: this.nullFunctions,
});
}

Expand Down
30 changes: 29 additions & 1 deletion src/lib/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import reduce from "lodash.reduce";
import { FunctionsMap } from "..";
import { MutOrRO } from "../types/mut-or-ro";
import { NullFunctions } from "../types/null-functions";
import { isNone } from "./is-none";
import { ReducedFieldNode } from "./node-types";

Expand All @@ -33,7 +34,12 @@ function ensureNullable(type: GraphQLOutputType | GraphQLInputType): GraphQLNull
}

export class Parser {
constructor(readonly schema: GraphQLSchema, readonly functionsMap: FunctionsMap, readonly validateEnums: boolean) {}
constructor(
readonly schema: GraphQLSchema,
readonly functionsMap: FunctionsMap,
readonly validateEnums: boolean,
readonly nullFunctions: NullFunctions
) {}

public parseObjectWithSelections(
data: Data,
Expand Down Expand Up @@ -61,7 +67,29 @@ export class Parser {
}

protected treatValue(value: any, givenType: GraphQLOutputType | GraphQLInputType, fieldNode: ReducedFieldNode): any {
if (isNonNullType(givenType)) {
return this.treatValueInternal(value, givenType, fieldNode);
} else {
return this.treatValueNullable(value, givenType, fieldNode);
}
}

protected treatValueNullable(
value: any,
givenType: GraphQLOutputType | GraphQLInputType,
fieldNode: ReducedFieldNode
): any {
const wrappedValue = this.treatValueInternal(value, givenType, fieldNode);
return this.nullFunctions.parseValue(wrappedValue);
}

protected treatValueInternal(
value: any,
givenType: GraphQLOutputType | GraphQLInputType,
fieldNode: ReducedFieldNode
): any {
const type = ensureNullable(givenType);

if (isNone(value)) return value;

if (isScalarType(type)) {
Expand Down

0 comments on commit c09f3a4

Please sign in to comment.