Skip to content

Commit

Permalink
Experimentation: query optimization
Browse files Browse the repository at this point in the history
  • Loading branch information
bloodyowl committed Mar 22, 2024
1 parent ee008dc commit c68d06a
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 3 deletions.
1 change: 0 additions & 1 deletion example/components/AccountMembership.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export const accountMembershipFragment = graphql(`
}
statusInfo {
__typename
status
... on AccountMembershipBindingUserErrorStatusInfo {
restrictedTo {
firstName
Expand Down
6 changes: 6 additions & 0 deletions src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export class ClientCache {
});
}

getFromCacheWithoutKey(cacheKey: symbol) {
return this.get(cacheKey).flatMap((entry) => {
return Option.Some(entry.value);
});
}

get(cacheKey: symbol): Option<CacheEntry> {
if (this.cache.has(cacheKey)) {
return Option.Some(this.cache.get(cacheKey) as CacheEntry);
Expand Down
182 changes: 181 additions & 1 deletion src/cache/read.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { DocumentNode, Kind, SelectionSetNode } from "@0no-co/graphql.web";
import {
DocumentNode,
InlineFragmentNode,
Kind,
OperationDefinitionNode,
SelectionNode,
SelectionSetNode,
} from "@0no-co/graphql.web";
import { Array, Option, Result } from "@swan-io/boxed";
import { match } from "ts-pattern";
import {
addIdIfPreviousSelected,
getFieldName,
getFieldNameWithArguments,
getSelectedKeys,
Expand All @@ -24,6 +32,15 @@ const getFromCacheOrReturnValue = (
: Option.Some(valueOrKey);
};

const getFromCacheOrReturnValueWithoutKeyFilter = (
cache: ClientCache,
valueOrKey: unknown,
): Option<unknown> => {
return typeof valueOrKey === "symbol"
? cache.getFromCacheWithoutKey(valueOrKey).flatMap(Option.fromNullable)
: Option.Some(valueOrKey);
};

const STABILITY_CACHE = new WeakMap<DocumentNode, Map<string, unknown>>();

export const readOperationFromCache = (
Expand Down Expand Up @@ -180,3 +197,166 @@ export const readOperationFromCache = (
}
});
};

export const optimizeQuery = (
cache: ClientCache,
document: DocumentNode,
variables: Record<string, unknown>,
): Option<DocumentNode> => {
const traverse = (
selections: SelectionSetNode,
data: Record<PropertyKey, unknown>,
parentSelectedKeys: Set<symbol>,
): Option<SelectionSetNode> => {
const nextSelections = Array.filterMap<SelectionNode, SelectionNode>(
selections.selections,
(selection) => {
return match(selection)
.with({ kind: Kind.FIELD }, (fieldNode) => {
const fieldNameWithArguments = getFieldNameWithArguments(
fieldNode,
variables,
);

if (data == undefined) {
return Option.Some(fieldNode);
}

const cacheHasKey = hasOwnProperty.call(
data,
fieldNameWithArguments,
);

if (!cacheHasKey) {
return Option.Some(fieldNode);
}

if (parentSelectedKeys.has(fieldNameWithArguments)) {
const valueOrKeyFromCache = data[fieldNameWithArguments];

const subFieldSelectedKeys = getSelectedKeys(
fieldNode,
variables,
);
if (Array.isArray(valueOrKeyFromCache)) {
return valueOrKeyFromCache.reduce((acc, valueOrKey) => {
const value = getFromCacheOrReturnValueWithoutKeyFilter(
cache,
valueOrKey,
);

if (value.isNone()) {
return Option.Some(fieldNode);
}

const originalSelectionSet = fieldNode.selectionSet;
if (originalSelectionSet != null) {
return traverse(
originalSelectionSet,
value.get() as Record<PropertyKey, unknown>,
subFieldSelectedKeys,
).map((selectionSet) => ({
...fieldNode,
selectionSet: addIdIfPreviousSelected(
originalSelectionSet,
selectionSet,
),
}));
} else {
return acc;
}
}, Option.None());
} else {
const value = getFromCacheOrReturnValueWithoutKeyFilter(
cache,
valueOrKeyFromCache,
);

if (value.isNone()) {
return Option.Some(fieldNode);
}

const originalSelectionSet = fieldNode.selectionSet;
if (originalSelectionSet != null) {
return traverse(
originalSelectionSet,
value.get() as Record<PropertyKey, unknown>,
subFieldSelectedKeys,
).map((selectionSet) => ({
...fieldNode,
selectionSet: addIdIfPreviousSelected(
originalSelectionSet,
selectionSet,
),
}));
} else {
return Option.None();
}
}
} else {
return Option.Some(fieldNode);
}
})
.with({ kind: Kind.INLINE_FRAGMENT }, (inlineFragmentNode) => {
return traverse(
inlineFragmentNode.selectionSet,
data as Record<PropertyKey, unknown>,
parentSelectedKeys,
).map(
(selectionSet) =>
({ ...inlineFragmentNode, selectionSet }) as InlineFragmentNode,
);
})
.with({ kind: Kind.FRAGMENT_SPREAD }, () => {
return Option.None();
})
.exhaustive();
},
);
if (nextSelections.length > 0) {
return Option.Some({ ...selections, selections: nextSelections });
} else {
return Option.None();
}
};

return Array.findMap(document.definitions, (definition) =>
definition.kind === Kind.OPERATION_DEFINITION
? Option.Some(definition)
: Option.None(),
)
.flatMap((operation) =>
getCacheKeyFromOperationNode(operation).map((cacheKey) => ({
operation,
cacheKey,
})),
)
.flatMap(({ operation, cacheKey }) => {
const selectedKeys = getSelectedKeys(operation, variables);
return cache
.getFromCache(cacheKey, selectedKeys)
.map((cache) => ({ cache, operation, selectedKeys }));
})
.flatMap(({ operation, cache, selectedKeys }) => {
return traverse(
operation.selectionSet,
cache as Record<PropertyKey, unknown>,
selectedKeys,
).map((selectionSet) => ({
...document,
definitions: [
{
...operation,
name:
operation.name != null
? {
...operation.name,
value: `${operation.name.value}__partial`,
}
: operation.name,
selectionSet,
} as OperationDefinitionNode,
],
}));
});
};
7 changes: 7 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ export class Client {

const variablesAsRecord = variables as Record<string, unknown>;

// TODO:
// optimizeQuery(
// this.cache,
// transformedDocument,
// variablesAsRecord,
// )

return this.makeRequest({
url: this.url,
headers: this.headers,
Expand Down
39 changes: 39 additions & 0 deletions src/graphql/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,42 @@ export const getExecutableOperationName = (document: DocumentNode) => {
}
});
};

const getIdFieldNode = (selection: SelectionNode): Option<SelectionNode> => {
return match(selection)
.with({ kind: Kind.FIELD }, (fieldNode) =>
fieldNode.name.value === "id" ? Option.Some(fieldNode) : Option.None(),
)
.with({ kind: Kind.INLINE_FRAGMENT }, (inlineFragmentNode) => {
return Array.findMap(
inlineFragmentNode.selectionSet.selections,
getIdFieldNode,
);
})
.otherwise(() => Option.None());
};

export const addIdIfPreviousSelected = (
oldSelectionSet: SelectionSetNode,
newSelectionSet: SelectionSetNode,
): SelectionSetNode => {
const idSelection = Array.findMap(oldSelectionSet.selections, getIdFieldNode);
const idSelectionInNew = Array.findMap(
newSelectionSet.selections,
getIdFieldNode,
);

if (idSelectionInNew.isSome()) {
return newSelectionSet;
}

return idSelection
.map((selection) => ({
...newSelectionSet,
selections: [
selection,
...newSelectionSet.selections,
] as readonly SelectionNode[],
}))
.getWithDefault(newSelectionSet);
};
12 changes: 11 additions & 1 deletion test/cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { Option, Result } from "@swan-io/boxed";
import { expect, test } from "vitest";
import { ClientCache } from "../src/cache/cache";
import { readOperationFromCache } from "../src/cache/read";
import { optimizeQuery, readOperationFromCache } from "../src/cache/read";
import { writeOperationToCache } from "../src/cache/write";
import { addTypenames, inlineFragments } from "../src/graphql/ast";
import { print } from "../src/graphql/print";
import {
appQuery,
bindAccountMembershipMutation,
bindMembershipMutationRejectionResponse,
bindMembershipMutationSuccessResponse,
getAppQueryResponse,
otherAppQuery,
} from "./data";

test("Write & read in cache", () => {
Expand Down Expand Up @@ -50,6 +52,8 @@ test("Write & read in cache", () => {
addTypenames(bindAccountMembershipMutation),
);

const preparedOtherAppQuery = inlineFragments(addTypenames(otherAppQuery));

writeOperationToCache(
cache,
preparedBindAccountMembershipMutation,
Expand Down Expand Up @@ -152,4 +156,10 @@ test("Write & read in cache", () => {
} else {
expect(true).toBe(false);
}

console.log(
optimizeQuery(cache, preparedOtherAppQuery, { id: "1" })
.map(print)
.getWithDefault("no delta"),
);
});
51 changes: 51 additions & 0 deletions test/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ const UserInfo = graphql(
[IdentificationLevels],
);

const CompleteUserInfo = graphql(
`
fragment CompleteUserInfo on User {
id
firstName
lastName
birthDate
mobilePhoneNumber
}
`,
[IdentificationLevels],
);

export const appQuery = graphql(
`
query App($id: ID!) {
Expand Down Expand Up @@ -60,6 +73,44 @@ export const appQuery = graphql(
[UserInfo],
);

export const otherAppQuery = graphql(
`
query App($id: ID!) {
accountMembership(id: $id) {
id
user {
id
...CompleteUserInfo
}
}
accountMemberships(first: 2) {
edges {
node {
id
account {
name
}
membershipUser: user {
id
lastName
}
}
}
}
supportingDocumentCollection(id: "e8d38e87-9862-47ef-b749-212ed566b955") {
__typename
supportingDocuments {
__typename
id
createdAt
}
id
}
}
`,
[CompleteUserInfo],
);

export const getAppQueryResponse = ({
user2LastName,
user1IdentificationLevels,
Expand Down

0 comments on commit c68d06a

Please sign in to comment.