diff --git a/.changeset/olive-queens-fold.md b/.changeset/olive-queens-fold.md new file mode 100644 index 00000000000..ea3abc75a0c --- /dev/null +++ b/.changeset/olive-queens-fold.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Create mechanism to add experimental features to Apollo Client diff --git a/package.json b/package.json index 2f6cdda2d5f..85989a19776 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "development": "./src/utilities/environment/index.development.ts", "default": "./src/utilities/environment/index.ts" }, + "./experimental/identifyResolvedFragments": "./src/experimental/identifyResolvedFragments/index.ts", "./v4-migration": "./src/v4-migration.ts" }, "scripts": { diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index ac80f48f6cd..b2b201b9c5b 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -168,7 +168,14 @@ export abstract class ApolloCache { // condition for a union or interface matches a particular type. public abstract fragmentMatches( fragment: InlineFragmentNode | FragmentDefinitionNode, - typename: string + typename: string, + {}: { + unnormalizedResult: + | { + [key: string]: unknown; + } + | undefined; + } ): boolean; // Function used to lookup a fragment when a fragment definition is not part diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index f7d90e1169e..ff267921e13 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -521,7 +521,10 @@ export class InMemoryCache extends ApolloCache { public fragmentMatches( fragment: InlineFragmentNode | FragmentDefinitionNode, - typename: string + typename: string, + _: { + unnormalizedResult: { [key: string]: unknown } | undefined; + } ): boolean { return this.policies.fragmentMatches(fragment, typename); } diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 7e71b1769d7..85d2fe8bd5f 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -132,6 +132,8 @@ export declare namespace ApolloClient { * queries. */ incrementalHandler?: Incremental.Handler; + + experiments?: ApolloClient.Experiment[]; } interface DevtoolsOptions { @@ -610,6 +612,10 @@ export declare namespace ApolloClient { variables?: TVariables; } } + + export interface Experiment { + (this: ApolloClient, options: ApolloClient.Options): void; + } } /** @@ -708,6 +714,7 @@ export class ApolloClient { dataMasking, link, incrementalHandler = new NotImplementedHandler(), + experiments = [], } = options; this.link = link; @@ -759,6 +766,8 @@ export class ApolloClient { } if (this.devtoolsConfig.enabled) this.connectToDevTools(); + + experiments.forEach((experiment) => experiment.call(this, options)); } private connectToDevTools() { diff --git a/src/experimental/identifyResolvedFragments/__tests__/identifyResolvedFragments.test.ts b/src/experimental/identifyResolvedFragments/__tests__/identifyResolvedFragments.test.ts new file mode 100644 index 00000000000..6f97a73f6c4 --- /dev/null +++ b/src/experimental/identifyResolvedFragments/__tests__/identifyResolvedFragments.test.ts @@ -0,0 +1,120 @@ +import { makeExecutableSchema } from "@graphql-tools/schema"; +import { print } from "graphql"; +import { tap } from "rxjs"; + +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { identifyResolvedFragments } from "@apollo/client/experimental/identifyResolvedFragments"; +import { SchemaLink } from "@apollo/client/link/schema"; + +const typeDefs = gql` + interface Pet { + id: ID! + name: String! + vaccinated: Boolean! + owner: Owner + } + type Dog implements Pet { + id: ID! + name: String! + vaccinated: Boolean! + barkVolume: Int! + owner: Owner + } + type Cat implements Pet { + id: ID! + name: String! + vaccinated: Boolean! + meowVolume: Int! + owner: Owner + } + interface Owner { + id: ID! + name: String! + } + type Person implements Owner { + id: ID! + name: String! + age: Int! + } + type Shelter implements Owner { + id: ID! + name: String! + operationType: String! + } + type Query { + pets: [Pet!]! + } + `; + +const somePets = [ + { + __typename: "Dog", + id: "1", + name: "Odie", + barkVolume: 3, + vaccinated: true, + owner: { __typename: "Person", id: "2", name: "Jon", age: 35 }, + }, + { + __typename: "Cat", + id: "3", + name: "Garfield", + meowVolume: 2, + vaccinated: false, + owner: { + __typename: "Shelter", + id: "4", + name: "Paws and Claws", + operationType: "Non-profit", + }, + }, +]; + +test("basic test", async () => { + const query = gql` + query Pets { + pets { + ...on Dog { + barkVolume + } + ... on Cat { + meowVolume + } + ...VetInfo + owner { + ...OwnerInfo + } + } + } + + fragment VetInfo on Pet { + vaccinated + } + fragment OwnerInfo on Owner { + ...on Person { + name + age + } + ...on Shelter { + name + operationType + } + }`; + + const schema = makeExecutableSchema({ + typeDefs, + }); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink((operation, forward) => { + return forward(operation).pipe( + tap((v) => console.dir(v, { depth: null })) + ); + }).concat(new SchemaLink({ schema, rootValue: { pets: somePets } })), + experiments: [identifyResolvedFragments], + }); + console.log(print(client.documentTransform.transformDocument(query))); + console.dir(await client.query({ query }), { depth: null }); + console.log(client.extract()); +}); diff --git a/src/experimental/identifyResolvedFragments/index.ts b/src/experimental/identifyResolvedFragments/index.ts new file mode 100644 index 00000000000..d3d692e5a5e --- /dev/null +++ b/src/experimental/identifyResolvedFragments/index.ts @@ -0,0 +1,141 @@ +import type { + FieldNode, + FragmentDefinitionNode, + InlineFragmentNode, +} from "graphql"; +import { Kind, visit } from "graphql"; + +import type { ApolloClient } from "@apollo/client"; +import type { InMemoryCache as InMemoryCacheBase } from "@apollo/client"; +import { DocumentTransform, InMemoryCache } from "@apollo/client"; +import type { Policies as PolicyBase } from "@apollo/client/cache"; +import { invariant } from "@apollo/client/utilities/invariant"; + +type Constructor = new (...args: any[]) => T; + +/** + * Mixes a MixIn onto an existing instance by swapping its prototype. + */ +function mixOn( + instance: Base, + Mixin: (base: Constructor) => Constructor +): Goal { + Object.setPrototypeOf( + instance, + Mixin(Object.getPrototypeOf(instance).constructor).prototype + ); + return instance as unknown as Goal; +} + +const prefix = "__ac_match_"; +const inferredSuperTypeMap = Symbol(); + +function identificationFieldName( + fragment: FragmentDefinitionNode | InlineFragmentNode +) { + return ( + fragment.typeCondition && `${prefix}${fragment.typeCondition.name.value}` + ); +} + +const transform = new DocumentTransform((document) => { + function handle( + fragment: T + ): T { + const name = identificationFieldName(fragment); + if (!name) return fragment; + return { + ...fragment, + selectionSet: { + ...fragment.selectionSet, + selections: [ + ...fragment.selectionSet.selections, + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: `__typename`, + }, + alias: { + kind: Kind.NAME, + value: name, + }, + } satisfies FieldNode, + ], + }, + }; + } + return visit(document, { + FragmentDefinition: handle, + InlineFragment: handle, + }); +}); + +export const identifyResolvedFragments: ApolloClient.Experiment = function () { + invariant(this.cache instanceof InMemoryCache); + + const qm = this["queryManager"] as { documentTransform: DocumentTransform }; + const parentTransform = qm.documentTransform; + qm.documentTransform = parentTransform.concat(transform); + + const extendedPolicies = mixOn( + this.cache.policies, + (Base) => + class Policies extends Base { + declare [inferredSuperTypeMap]?: Record>; + + fragmentMatches(...args: Parameters) { + const [fragment, __typename, result] = args; + const supertype = fragment.typeCondition?.name.value; + + if (__typename && __typename === supertype) { + return true; + } + + if (result && supertype) { + const name = identificationFieldName(fragment); + if (name && name in result) { + const subtype = result[name] as string; + this[inferredSuperTypeMap] ??= {}; + this[inferredSuperTypeMap][subtype] ??= {}; + this[inferredSuperTypeMap][subtype][supertype] = true; + return true; + } + } + + if ( + __typename && + supertype && + this[inferredSuperTypeMap]?.[__typename]?.[supertype] + ) { + return true; + } + + return super.fragmentMatches(...args); + } + } + ); + + mixOn( + this.cache, + (InMemoryCache) => + class extends InMemoryCache { + declare policies: typeof extendedPolicies; + public extract(...args: Parameters) { + return { + ...super.extract(...args), + __ac_inferredPossibleTypes: this.policies[inferredSuperTypeMap], + }; + } + public restore( + ...[{ __ac_inferredPossibleTypes = {}, ...rest }]: Parameters< + InMemoryCacheBase["restore"] + > + ) { + this.policies[inferredSuperTypeMap] = + __ac_inferredPossibleTypes as any; + return super.restore(rest); + } + } + ); +}; diff --git a/src/local-state/LocalState.ts b/src/local-state/LocalState.ts index f25c7c9c873..b321f6105a1 100644 --- a/src/local-state/LocalState.ts +++ b/src/local-state/LocalState.ts @@ -561,7 +561,9 @@ export class LocalState< selection.kind === Kind.INLINE_FRAGMENT && selection.typeCondition && rootValue?.__typename && - cache.fragmentMatches(selection, rootValue.__typename) + cache.fragmentMatches(selection, rootValue.__typename, { + unnormalizedResult: rootValue, + }) ) { const fragmentResult = await this.resolveSelectionSet( selection.selectionSet, @@ -587,7 +589,9 @@ export class LocalState< const matches = typename === typeCondition || - cache.fragmentMatches(fragment, typename ?? ""); + cache.fragmentMatches(fragment, typename ?? "", { + unnormalizedResult: rootValue || undefined, + }); if (matches) { const fragmentResult = await this.resolveSelectionSet( diff --git a/src/masking/maskDefinition.ts b/src/masking/maskDefinition.ts index 96015a44894..13611f12c37 100644 --- a/src/masking/maskDefinition.ts +++ b/src/masking/maskDefinition.ts @@ -150,7 +150,9 @@ function maskSelectionSet( if ( selection.kind === Kind.INLINE_FRAGMENT && (!selection.typeCondition || - context.cache.fragmentMatches(selection, data.__typename)) + context.cache.fragmentMatches(selection, data.__typename, { + unnormalizedResult: data, + })) ) { value = maskSelectionSet( data, diff --git a/src/utilities/internal/checkDocument.ts b/src/utilities/internal/checkDocument.ts index 0081f5c4040..2a71bfd6f8d 100644 --- a/src/utilities/internal/checkDocument.ts +++ b/src/utilities/internal/checkDocument.ts @@ -15,8 +15,6 @@ import { import { defaultCacheSizes } from "../../utilities/caching/sizes.js"; import { cacheSizes } from "../caching/sizes.js"; -import { getOperationName } from "./getOperationName.js"; - /** * Checks the document for errors and throws an exception if there is an error. * @@ -84,13 +82,13 @@ string in a "gql" tag? http://docs.apollostack.com/apollo-client/core.html#gql` } fieldPath.splice(-1, 1, field.name.value); - throw newInvariantError( - '`%s` is a forbidden field alias name in the selection set for field `%s` in %s "%s".', - field.alias.value, - fieldPath.join("."), - operations[0].operation, - getOperationName(doc, "(anonymous)") - ); + // throw newInvariantError( + // '`%s` is a forbidden field alias name in the selection set for field `%s` in %s "%s".', + // field.alias.value, + // fieldPath.join("."), + // operations[0].operation, + // getOperationName(doc, "(anonymous)") + // ); } }, });