Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve @resolve directive to handle arrays #7

Merged
merged 1 commit into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dry-spiders-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@frontside/hydraphql": minor
---

Improve @resolve directive to handle arrays
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@graphql-tools/merge": "^9.0.0",
"@graphql-tools/schema": "^10.0.0",
"@graphql-tools/utils": "^10.0.0",
"graphql-relay": "^0.10.0",
"lodash": "^4.17.21",
"pascal-case": "^3.1.2",
"reflect-metadata": "^0.1.13",
Expand Down
4 changes: 2 additions & 2 deletions src/__snapshots__/schema.graphql.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ directive @discriminates(opaqueType: String, with: _DirectiveArgument_) on INTER

directive @discriminationAlias(type: String!, value: String!) repeatable on INTERFACE

directive @field(at: _DirectiveArgument_!, default: _DirectiveArgument_) on FIELD_DEFINITION
directive @field(at: _DirectiveArgument_, default: _DirectiveArgument_) on FIELD_DEFINITION

directive @implements(interface: String!) on INTERFACE | OBJECT

directive @resolve(at: _DirectiveArgument_, from: String) on FIELD_DEFINITION
directive @resolve(at: _DirectiveArgument_, from: String, nodeType: String) on FIELD_DEFINITION

interface Connection {
count: Int
Expand Down
3 changes: 2 additions & 1 deletion src/__snapshots__/types.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export type DiscriminationAliasDirectiveArgs = {
export type DiscriminationAliasDirectiveResolver<Result, Parent, ContextType = any, Args = DiscriminationAliasDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;

export type FieldDirectiveArgs = {
at: Scalars['_DirectiveArgument_']['input'];
at?: Maybe<Scalars['_DirectiveArgument_']['input']>;
default?: Maybe<Scalars['_DirectiveArgument_']['input']>;
};

Expand All @@ -191,6 +191,7 @@ export type ImplementsDirectiveResolver<Result, Parent, ContextType = any, Args
export type ResolveDirectiveArgs = {
at?: Maybe<Scalars['_DirectiveArgument_']['input']>;
from?: Maybe<Scalars['String']['input']>;
nodeType?: Maybe<Scalars['String']['input']>;
};

export type ResolveDirectiveResolver<Result, Parent, ContextType = any, Args = ResolveDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;
Expand Down
4 changes: 2 additions & 2 deletions src/core/core.graphql
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
directive @field(
at: _DirectiveArgument_!
at: _DirectiveArgument_
default: _DirectiveArgument_
) on FIELD_DEFINITION
directive @discriminates(
Expand All @@ -11,7 +11,7 @@ directive @discriminationAlias(
type: String!
) repeatable on INTERFACE
directive @implements(interface: String!) on OBJECT | INTERFACE
directive @resolve(at: _DirectiveArgument_, from: String) on FIELD_DEFINITION
directive @resolve(at: _DirectiveArgument_, nodeType: String, from: String) on FIELD_DEFINITION

scalar _DirectiveArgument_

Expand Down
8 changes: 5 additions & 3 deletions src/core/fieldDirectiveMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ResolverContext } from "../types.js";
import { id } from "../helpers.js";

export function fieldDirectiveMapper(
_fieldName: string,
fieldName: string,
field: GraphQLFieldConfig<
{ id: string },
ResolverContext,
Expand Down Expand Up @@ -34,8 +34,10 @@ export function fieldDirectiveMapper(
const entity = await loader.load(id);
if (!entity) return null;
const source =
(_.get(entity, directive.at as string | string[]) as unknown) ??
directive.default;
(_.get(
entity,
(directive.at as undefined | string | string[]) ?? fieldName,
) as unknown) ?? directive.default;
return fieldResolve(source, args, context, info);
};
}
157 changes: 132 additions & 25 deletions src/core/resolveDirectiveMapper.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import _ from "lodash";
import type { GraphQLFieldConfig } from "graphql";
import type { ResolverContext } from "../types.js";
import { decodeId, encodeId, unboxNamedType } from "../helpers.js";
import { connectionFromArray, ConnectionArguments } from "graphql-relay";
import {
GraphQLInputObjectType,
type GraphQLFieldConfig,
type GraphQLInterfaceType,
GraphQLInt,
GraphQLString,
} from "graphql";
import type { DirectiveMapperAPI, ResolverContext } from "../types.js";
import {
createConnectionType,
decodeId,
encodeId,
getNoteTypeForConnection,
isConnectionType,
isNamedListType,
unboxNamedType,
} from "../helpers.js";

export function resolveDirectiveMapper(
fieldName: string,
Expand All @@ -11,6 +26,7 @@ export function resolveDirectiveMapper(
Record<string, unknown> | undefined
>,
directive: Record<string, unknown>,
api: DirectiveMapperAPI & { typeName: string },
) {
if (
"at" in directive &&
Expand All @@ -23,32 +39,123 @@ export function resolveDirectiveMapper(
);
}

field.resolve = async ({ id }, args, { loader }) => {
if (directive.at === "id") return { id };
if (isConnectionType(field.type)) {
if (directive.nodeType && typeof directive.nodeType === "string") {
const nodeType = getNoteTypeForConnection(
directive.nodeType,
(name) => api.typeMap[name],
(name, type) => (api.typeMap[name] = type),
);

const node = await loader.load(id);
if (nodeType) field.type = createConnectionType(nodeType, field.type);
} else {
field.type = createConnectionType(
api.typeMap.Node as GraphQLInterfaceType,
field.type,
);
}

if (field.args && Object.keys(field.args).length > 0) {
const argsType = new GraphQLInputObjectType({
name: `${api.typeName}${fieldName[0].toUpperCase()}${fieldName.slice(
1,
)}Args`,
fields: { ...field.args },
});
field.args = { args: { type: argsType } };
}

field.args = {
...field.args,
first: { type: GraphQLInt },
after: { type: GraphQLString },
last: { type: GraphQLInt },
before: { type: GraphQLString },
};

field.resolve = async ({ id }, args, { loader }) => {
if (directive.at === "id") return { id };

const source =
(directive.from as string | undefined) ?? decodeId(id).source;
const typename = unboxNamedType(field.type).name;
const ref: unknown = _.get(node, directive.at as string | string[]);
const node = await loader.load(id);

if (directive.at) {
if (!ref) {
return null;
} else if (typeof ref !== "string") {
throw new Error(
`The "at" argument of @resolve directive for "${fieldName}" field must be resolved to a string, but got "${typeof ref}"`,
);
const source =
(directive.from as string | undefined) ?? decodeId(id).source;
const typename = unboxNamedType(field.type).name;
const ref: unknown = _.get(node, directive.at as string | string[]);

if (directive.at) {
if (!ref) {
return null;
} else if (
!Array.isArray(ref) ||
ref.some((r) => typeof r !== "string")
) {
throw new Error(
`The "at" argument of @resolve directive for "${fieldName}" field must be resolved to an array of strings`,
);
}
}
}

return {
id: encodeId({
source,
typename,
query: { ref: ref as string | undefined, args },
}),
const ids = ((ref ?? []) as string[]).map((r) => ({
id: encodeId({
source,
typename,
query: {
ref: r as string | undefined,
args: (args as { args: Record<string, unknown> }).args,
},
}),
}));

return {
...connectionFromArray(ids, args as ConnectionArguments),
count: ids.length,
};
};
} else {
field.resolve = async ({ id }, args, { loader }) => {
if (directive.at === "id") return { id };

const node = await loader.load(id);

const source =
(directive.from as string | undefined) ?? decodeId(id).source;
const typename = unboxNamedType(field.type).name;
const isListType = isNamedListType(field.type);
const ref: unknown = _.get(node, directive.at as string | string[]);

if (directive.at) {
if (!ref) {
return null;
} else if (
isListType &&
(!Array.isArray(ref) || ref.some((r) => typeof r !== "string"))
) {
throw new Error(
`The "at" argument of @resolve directive for "${fieldName}" field must be resolved to an array of strings`,
);
} else if (!isListType && typeof ref !== "string") {
throw new Error(
`The "at" argument of @resolve directive for "${fieldName}" field must be resolved to a string, but got "${typeof ref}"`,
);
}
}

return isListType
? ((ref ?? []) as string[]).map((r) => ({
id: encodeId({
source,
typename,
query: { ref: r as string | undefined, args },
}),
}))
: {
id: encodeId({
source,
typename,
query: { ref: ref as string | undefined, args },
}),
};
};
};
}
}
122 changes: 121 additions & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
import { isListType, isNonNullType } from "graphql";
import {
isInterfaceType,
isListType,
isNonNullType,
GraphQLObjectType,
GraphQLNonNull,
GraphQLList,
isInputType,
isUnionType,
GraphQLID,
GraphQLInterfaceType,
isObjectType,
} from "graphql";
import type { GraphQLNamedType, GraphQLOutputType } from "graphql";
import type { NodeId, NodeQuery } from "./types.js";

Expand Down Expand Up @@ -28,6 +40,114 @@ export function unboxNamedType(type: GraphQLOutputType): GraphQLNamedType {
return type;
}

export function isNamedListType(type: GraphQLOutputType): boolean {
if (isNonNullType(type)) {
return isListType(type.ofType);
}
return isListType(type);
}

export function isConnectionType(type: unknown): type is GraphQLInterfaceType {
return (
(isInterfaceType(type) && type.name === "Connection") ||
(isNonNullType(type) && isConnectionType(type.ofType))
);
}

export function createConnectionType(
nodeType: GraphQLInterfaceType | GraphQLObjectType,
fieldType: GraphQLInterfaceType,
): GraphQLObjectType {
const wrappedEdgeType = fieldType.getFields().edges.type as GraphQLNonNull<
GraphQLList<GraphQLNonNull<GraphQLInterfaceType>>
>;
const edgeType = wrappedEdgeType.ofType.ofType.ofType;

return new GraphQLObjectType({
name: `${nodeType.name}Connection`,
fields: {
...fieldType.toConfig().fields,
edges: {
type: new GraphQLNonNull(
new GraphQLList(
new GraphQLNonNull(
new GraphQLObjectType({
name: `${nodeType.name}Edge`,
fields: {
...edgeType.toConfig().fields,
node: {
type: new GraphQLNonNull(nodeType),
},
},
interfaces: [edgeType],
}),
),
),
),
},
},
interfaces: [fieldType],
});
}

export function getNoteTypeForConnection(
typeName: string,
getType: (name: string) => GraphQLNamedType | undefined,
setType: (name: string, type: GraphQLNamedType) => void,
): GraphQLInterfaceType | GraphQLObjectType {
const nodeType = getType(typeName);

if (!nodeType) {
throw new Error(`The interface "${typeName}" is not defined in the schema`);
}
if (isInputType(nodeType)) {
throw new Error(
`The interface "${typeName}" is an input type and can't be used in a Connection`,
);
}
if (isUnionType(nodeType)) {
const resolveType = nodeType.resolveType;
if (resolveType)
throw new Error(
`The "resolveType" function has already been implemented for "${nodeType.name}" union which may lead to undefined behavior`,
);
const iface = new GraphQLInterfaceType({
name: typeName,
interfaces: [getType("Node") as GraphQLInterfaceType],
fields: { id: { type: new GraphQLNonNull(GraphQLID) } },
resolveType: (...args) =>
(getType("Node") as GraphQLInterfaceType).resolveType?.(...args),
});
setType(typeName, iface);
nodeType
.getTypes()
.map((type) => getType(type.name))
.forEach((type) => {
if (isInterfaceType(type)) {
setType(
type.name,
new GraphQLInterfaceType({
...type.toConfig(),
interfaces: [...type.getInterfaces(), iface],
}),
);
}
if (isObjectType(type)) {
setType(
type.name,
new GraphQLObjectType({
...type.toConfig(),
interfaces: [...type.getInterfaces(), iface],
}),
);
}
});
return iface;
} else {
return nodeType;
}
}

function isNodeQuery(obj: unknown): obj is NodeQuery {
return !!obj && typeof obj === "object";
}
Expand Down
Loading
Loading