Skip to content
Open
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
32 changes: 32 additions & 0 deletions .changeset/plain-roses-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@graphql-mesh/utils': patch
---

Auto merge types from subscriptions in additional type defs

This means that the `sourceName` directive does not need to be provided to the `@resolveTo` directive, instead the resolver will automatically find the subgraph that requires fields available in the subscription event.

```diff
import { defineConfig, loadGraphQLHTTPSubgraph } from '@graphql-mesh/compose-cli'

export const composeConfig = defineConfig({
subgraphs: [
{
sourceHandler: loadGraphQLHTTPSubgraph('products', {
endpoint: `http://localhost:3000/graphql`
})
}
],
additionalTypeDefs: /* GraphQL */ `
extend schema {
subscription: Subscription
}
type Subscription {
newProduct: Product! @resolveTo(
pubsubTopic: "new_product"
- sourceName: "products"
)
}
`
})
```
5 changes: 5 additions & 0 deletions .changeset/puny-geckos-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-mesh/utils': patch
---

The context can be undefined while resolving additional resolvers
2 changes: 1 addition & 1 deletion e2e/subscriptions-type-merging/mesh.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const composeConfig = defineConfig({
subscription: Subscription
}
type Subscription {
newProduct: Product! @resolveTo(pubsubTopic: "new_product", sourceName: "products")
newProduct: Product! @resolveTo(pubsubTopic: "new_product")
}
`,
});
6 changes: 3 additions & 3 deletions e2e/subscriptions-type-merging/services/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ createServer(
type Query {
hello: String!
}
type Product @key(fields: "id") {
type Product @key(fields: "id") @key(fields: "name") {
id: ID!
name: String!
price: Float!
Expand All @@ -25,8 +25,8 @@ createServer(
},
Product: {
__resolveReference: ref => ({
id: ref.id,
name: `Roomba X${ref.id}`,
id: ref.id || 'noid',
name: `Roomba X${ref.id || 'noid'}`,
price: 100,
}),
},
Expand Down
91 changes: 49 additions & 42 deletions packages/legacy/utils/src/resolve-additional-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
GraphQLType,
SelectionSetNode,
} from 'graphql';
import { getNamedType, isAbstractType, isInterfaceType, isObjectType, Kind } from 'graphql';
import { getNamedType, isAbstractType, isInterfaceType, isObjectType, Kind, print } from 'graphql';
import lodashGet from 'lodash.get';
import toPath from 'lodash.topath';
import { process } from '@graphql-mesh/cross-helpers';
Expand All @@ -20,16 +20,12 @@ import {
type MeshPubSub,
type YamlConfig,
} from '@graphql-mesh/types';
import {
resolveExternalValue,
Subschema,
type MergedTypeResolver,
type StitchingInfo,
} from '@graphql-tools/delegate';
import type { MergedTypeResolver, StitchingInfo, Subschema } from '@graphql-tools/delegate';
import type { IResolvers, Maybe, MaybePromise } from '@graphql-tools/utils';
import { parseSelectionSet } from '@graphql-tools/utils';
import { handleMaybePromise } from '@whatwg-node/promise-helpers';
import { loadFromModuleExportExpression } from './load-from-module-export-expression.js';
import { containsSelectionSet, selectionSetOfData } from './selectionSet.js';
import { withFilter } from './with-filter.js';

function getTypeByPath(type: GraphQLType, path: string[]): GraphQLNamedType {
Expand Down Expand Up @@ -181,7 +177,7 @@ export function resolveAdditionalResolversWithoutImport(
): MaybePromise<AsyncIterator<any>> {
const resolverData = { root, args, context, info, env: process.env };
const topic = stringInterpolator.parse(pubsubTopic, resolverData);
const ps = context.pubsub || pubsub;
const ps = context?.pubsub || pubsub;
if (isHivePubSub(ps)) {
return ps.subscribe(topic)[Symbol.asyncIterator]();
}
Expand Down Expand Up @@ -210,48 +206,59 @@ export function resolveAdditionalResolversWithoutImport(
[additionalResolver.targetFieldName]: {
subscribe: subscribeFn,
resolve: (payload: any, _, ctx, info) => {
function handlePayload(payload: any) {
function resolvePayload(payload: any) {
if (baseOptions.valuesFromResults) {
return baseOptions.valuesFromResults(payload);
}
return payload;
}
if (additionalResolver.sourceName) {
const stitchingInfo = info?.schema.extensions?.stitchingInfo as Maybe<
StitchingInfo<any>
>;
if (!stitchingInfo) {
throw new Error(
`Stitching Information object not found in the resolve information, contact maintainers!`,
);
}
const returnTypeName = getNamedType(info.returnType).name;
const mergedTypeInfo = stitchingInfo?.mergedTypes?.[returnTypeName];
if (!mergedTypeInfo) {
throw new Error(
`This "${returnTypeName}" type is not a merged type, disable typeMerging in the config!`,
);
}
const subschema = Array.from(stitchingInfo.subschemaMap?.values() || []).find(
s => s.name === additionalResolver.sourceName,
);
if (!subschema) {
throw new Error(`The source "${additionalResolver.sourceName}" is not found`);
const stitchingInfo = info?.schema.extensions?.stitchingInfo as Maybe<
StitchingInfo<any>
>;
if (!stitchingInfo) {
return resolvePayload(payload); // no stitching, cannot be resolved anywhere else
}
const returnTypeName = getNamedType(info.returnType).name;
const mergedTypeInfo = stitchingInfo?.mergedTypes?.[returnTypeName];
if (!mergedTypeInfo) {
return resolvePayload(payload); // this type is not merged or resolvable
}

// find the best resolver by diffing the selection sets
const availableSelSet = selectionSetOfData(payload);
let resolver: MergedTypeResolver | null = null;
let subschema: Subschema | null = null;
for (const [requiredSubschema, requiredSelSet] of mergedTypeInfo.selectionSets) {
const matchResolver = mergedTypeInfo?.resolvers.get(requiredSubschema);
if (!matchResolver) {
// the subschema has no resolvers, nothing to search for
continue;
}
const resolver = mergedTypeInfo?.resolvers?.get(subschema);
if (!resolver) {
throw new Error(
`The type "${returnTypeName}" is not resolvable from the source "${additionalResolver.sourceName}", check your typeMerging configuration!`,
);
if (containsSelectionSet(requiredSelSet, availableSelSet)) {
// all of the fields of the requesting selection set is exist in the required selection set
resolver = matchResolver;
subschema = requiredSubschema;
break;
}
const selectionSet = info.fieldNodes[0].selectionSet;
return handleMaybePromise(
() =>
resolver(payload, ctx, info, subschema, selectionSet, undefined, info.returnType),
handlePayload,
);
}
return handlePayload(payload);
if (!resolver || !subschema) {
// the type cannot be resolved
return resolvePayload(payload);
}

return handleMaybePromise(
() =>
resolver(
payload,
ctx,
info,
subschema,
info.fieldNodes[0].selectionSet,
undefined,
info.returnType,
),
resolvePayload,
);
},
},
},
Expand Down
109 changes: 109 additions & 0 deletions packages/legacy/utils/src/selectionSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* eslint-disable no-labels */
import { Kind, type SelectionNode, type SelectionSetNode } from 'graphql';

/**
* Checks recursively whether all of the fields of {@link requiredSelSet} exist in {@link selSet}
*/
export function containsSelectionSet(
requiredSelSet: SelectionSetNode,
selSet: SelectionSetNode,
): boolean {
ReqLoop: for (const reqSel of requiredSelSet.selections) {
switch (reqSel.kind) {
case Kind.FIELD: {
for (const sel of selSet.selections) {
// required field exists in the selection set
if (sel.kind === Kind.FIELD && sel.name.value === reqSel.name.value) {
if (!reqSel.selectionSet && !sel.selectionSet) {
// they're both scalar fields, continue to next required field
continue ReqLoop;
}
if (reqSel.selectionSet && !sel.selectionSet) {
// required field is an object, but the selection under the same name is scalar
return false;
}
if (!reqSel.selectionSet && sel.selectionSet) {
// required field is a scalar, but the selection under the same name is object
return false;
}
// they're both objects
if (containsSelectionSet(reqSel.selectionSet!, sel.selectionSet!)) {
// and they recursively contain all required fields
continue ReqLoop;
}
}
}
// no matches found
return false;
}
case Kind.INLINE_FRAGMENT: {
for (const sel of selSet.selections) {
if (sel.kind !== Kind.INLINE_FRAGMENT) {
continue;
}
if (
sel.typeCondition?.name.value &&
reqSel.typeCondition?.name.value &&
sel.typeCondition.name.value === reqSel.typeCondition.name.value
) {
// both have matching type conditions
if (containsSelectionSet(reqSel.selectionSet, sel.selectionSet)) {
// and they recursively contain all required fields
continue ReqLoop;
}
}
if (!sel.typeCondition?.name.value && !reqSel.typeCondition?.name.value) {
// neither have a type condition, just check the selection sets
if (containsSelectionSet(reqSel.selectionSet, sel.selectionSet)) {
// and they recursively contain all required fields
continue ReqLoop;
}
}
}
return false;
}
default:
// no other field kind is supported, like fragment spreads or definitions
return false;
}
}
// all fields matched
return true;
}

export function selectionSetOfData(data: Record<string, unknown>): SelectionSetNode {
const selSet = {
kind: Kind.SELECTION_SET,
selections: [] as SelectionNode[],
} as const;
for (const fieldName of Object.keys(data)) {
const fieldValue = data[fieldName];
const selNode: SelectionNode = {
kind: Kind.FIELD,
name: { kind: Kind.NAME, value: fieldName },
};
if (fieldValue && typeof fieldValue === 'object') {
if (Array.isArray(fieldValue)) {
// we assume that all items in the array are of the same shape, so we look at the first one
const firstItem = fieldValue[0];
if (firstItem && typeof firstItem === 'object') {
selSet.selections.push({
...selNode,
selectionSet: selectionSetOfData(firstItem as Record<string, unknown>),
});
} else {
// is an array of scalars
selSet.selections.push(selNode);
}
} else {
selSet.selections.push({
...selNode,
selectionSet: selectionSetOfData(fieldValue as Record<string, unknown>),
});
}
} else {
selSet.selections.push(selNode);
}
}
return selSet;
}
Loading
Loading