Skip to content
Draft
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/olive-queens-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Create mechanism to add experimental features to Apollo Client
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 8 additions & 1 deletion src/cache/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/cache/inmemory/inMemoryCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
9 changes: 9 additions & 0 deletions src/core/ApolloClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export declare namespace ApolloClient {
* queries.
*/
incrementalHandler?: Incremental.Handler<any>;

experiments?: ApolloClient.Experiment[];
}

interface DevtoolsOptions {
Expand Down Expand Up @@ -610,6 +612,10 @@ export declare namespace ApolloClient {
variables?: TVariables;
}
}

export interface Experiment {
(this: ApolloClient, options: ApolloClient.Options): void;
}
}

/**
Expand Down Expand Up @@ -708,6 +714,7 @@ export class ApolloClient {
dataMasking,
link,
incrementalHandler = new NotImplementedHandler(),
experiments = [],
} = options;

this.link = link;
Expand Down Expand Up @@ -759,6 +766,8 @@ export class ApolloClient {
}

if (this.devtoolsConfig.enabled) this.connectToDevTools();

experiments.forEach((experiment) => experiment.call(this, options));
}

private connectToDevTools() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
});
141 changes: 141 additions & 0 deletions src/experimental/identifyResolvedFragments/index.ts
Original file line number Diff line number Diff line change
@@ -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<T> = new (...args: any[]) => T;

/**
* Mixes a MixIn onto an existing instance by swapping its prototype.
*/
function mixOn<Base, Goal extends Base>(
instance: Base,
Mixin: (base: Constructor<Base>) => Constructor<Goal>
): 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<T extends FragmentDefinitionNode | InlineFragmentNode>(
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<string, Record<string, true>>;

fragmentMatches(...args: Parameters<PolicyBase["fragmentMatches"]>) {
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<InMemoryCacheBase["extract"]>) {
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);
}
}
);
};
8 changes: 6 additions & 2 deletions src/local-state/LocalState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion src/masking/maskDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading