Skip to content

Commit

Permalink
feat(response-cache): added getScope callback in buildResponseCacheKey
Browse files Browse the repository at this point in the history
  • Loading branch information
Dunk14 committed Apr 3, 2024
1 parent d7f6da0 commit 4f30aba
Show file tree
Hide file tree
Showing 6 changed files with 280 additions and 19 deletions.
5 changes: 5 additions & 0 deletions .changeset/sour-cars-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envelop/response-cache': minor
---

Added `getScope` callback in `buildResponseCacheKey` params
52 changes: 52 additions & 0 deletions packages/plugins/response-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,3 +818,55 @@ mutation SetNameMutation {
}
}
```

#### Get scope of the query

Useful for building a cache key that is shared across all sessions when `PUBLIC`.

```ts
import jsonStableStringify from 'fast-json-stable-stringify'
import { execute, parse, subscribe, validate } from 'graphql'
import { envelop } from '@envelop/core'
import { hashSHA256, useResponseCache } from '@envelop/response-cache'

const getEnveloped = envelop({
parse,
validate,
execute,
subscribe,
plugins: [
// ... other plugins ...
useResponseCache({
ttl: 2000,
session: request => getSessionId(request),
buildResponseCacheKey: ({
getScope,
sessionId,
documentString,
operationName,
variableValues
}) =>
// Use `getScope()` to put a unique key for every session when `PUBLIC`
hashSHA256(
[
getScope() === 'PUBLIC' ? 'PUBLIC' : sessionId,
documentString,
operationName ?? '',
jsonStableStringify(variableValues ?? {})
].join('|')
),
scopePerSchemaCoordinate: {
// Set scope for an entire query
'Query.getProfile': 'PRIVATE',
// Set scope for an entire type
PrivateProfile: 'PRIVATE',
// Set scope for a single field
'Profile.privateData': 'PRIVATE'
}
})
]
})
```

> Note: The use of this callback will increase the ram usage since it memoizes the scope for each
> query in a weak map.
104 changes: 104 additions & 0 deletions packages/plugins/response-cache/src/get-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
FieldNode,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLOutputType,
GraphQLSchema,
Kind,
parse,
SelectionNode,
visit,
} from 'graphql';
import { memoize1 } from '@graphql-tools/utils';
import { CacheControlDirective, isPrivate } from './plugin';

/** Parse the selected query fields */
function parseSelections(
selections: ReadonlyArray<SelectionNode> = [],
record: Record<string, any>,
) {
for (const selection of selections) {
if (selection.kind === Kind.FIELD) {
record[selection.name.value] = {};
parseSelections(selection.selectionSet?.selections, record[selection.name.value]);
}
}
}

/** Iterate over record and parse its fields with schema type */
function parseRecordWithSchemaType(
type: GraphQLOutputType,
record: Record<string, any>,
prefix?: string,
): Set<string> {
let fields: Set<string> = new Set();
if (type instanceof GraphQLNonNull || type instanceof GraphQLList) {
fields = new Set([...fields, ...parseRecordWithSchemaType(type.ofType, record, prefix)]);
}

if (type instanceof GraphQLObjectType) {
const newPrefixes = [...(prefix ?? []), type.name];
fields.add(newPrefixes.join('.'));

const typeFields = type.getFields();
for (const key of Object.keys(record)) {
const field = typeFields[key];
if (!field) {
continue;
}

fields.add([...newPrefixes, field.name].join('.'));
if (Object.keys(record[key]).length > 0) {
fields = new Set([...fields, ...parseRecordWithSchemaType(field.type, record[key])]);
}
}
}

return fields;
}

function getSchemaCoordinatesFromQuery(schema: GraphQLSchema, query: string): Set<string> {
const ast = parse(query);
let fields: Set<string> = new Set();

const visitField = (node: FieldNode) => {
const record: Record<string, any> = {};
const queryFields = schema.getQueryType()?.getFields()[node.name.value];

if (queryFields) {
record[node.name.value] = {};
parseSelections(node.selectionSet?.selections, record[node.name.value]);

fields.add(`Query.${node.name.value}`);
fields = new Set([
...fields,
...parseRecordWithSchemaType(queryFields.type, record[node.name.value]),
]);
}
};

// Launch the field visitor
visit(ast, {
Field: visitField,
});

return fields;
}

export const getScopeFromQuery = (
schema: GraphQLSchema,
query: string,
): NonNullable<CacheControlDirective['scope']> => {
const fn = memoize1(({ query }: { query: string }) => {
const schemaCoordinates = getSchemaCoordinatesFromQuery(schema, query);

for (const coordinate of schemaCoordinates) {
if (isPrivate(coordinate)) {
return 'PRIVATE';
}
}
return 'PUBLIC';
});
return fn({ query });
};
1 change: 1 addition & 0 deletions packages/plugins/response-cache/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './in-memory-cache.js';
export * from './plugin.js';
export * from './cache.js';
export * from './hash-sha256.js';
export * from './get-scope.js';
52 changes: 33 additions & 19 deletions packages/plugins/response-cache/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ExecutionArgs,
getOperationAST,
GraphQLDirective,
GraphQLSchema,
Kind,
print,
TypeInfo,
Expand All @@ -30,6 +31,7 @@ import {
mergeIncrementalResult,
} from '@graphql-tools/utils';
import type { Cache, CacheEntityRecord } from './cache.js';
import { getScopeFromQuery } from './get-scope.js';
import { hashSHA256 } from './hash-sha256.js';
import { createInMemoryCache } from './in-memory-cache.js';

Expand All @@ -47,6 +49,8 @@ export type BuildResponseCacheKeyFunction = (params: {
sessionId: Maybe<string>;
/** GraphQL Context */
context: ExecutionArgs['contextValue'];
/** Callback to get the scope */
getScope: () => NonNullable<CacheControlDirective['scope']>;
}) => Promise<string>;

export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string;
Expand Down Expand Up @@ -76,8 +80,8 @@ export type UseResponseCacheParameter<PluginContext extends Record<string, any>
* In the unusual case where you actually want to cache introspection query operations,
* you need to provide the value `{ 'Query.__schema': undefined }`.
*/
ttlPerSchemaCoordinate?: Record<string, number | undefined>;
scopePerSchemaCoordinate?: Record<string, 'PRIVATE' | 'PUBLIC' | undefined>;
ttlPerSchemaCoordinate?: Record<string, CacheControlDirective['maxAge']>;
scopePerSchemaCoordinate?: Record<string, CacheControlDirective['scope']>;
/**
* Allows to cache responses based on the resolved session id.
* Return a unique value for each session.
Expand Down Expand Up @@ -215,11 +219,11 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
ttlPerSchemaCoordinate,
}: {
invalidateViaMutation: boolean;
ttlPerSchemaCoordinate?: Record<string, number | undefined>;
ttlPerSchemaCoordinate?: Record<string, CacheControlDirective['maxAge']>;
},
schema: any,
idFieldByTypeName: Map<string, string>,
): [DocumentNode, number | undefined] {
): [DocumentNode, CacheControlDirective['maxAge']] {
const typeInfo = new TypeInfo(schema);
let ttl: number | undefined;
const visitor: ASTVisitor = {
Expand All @@ -238,7 +242,7 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
const parentType = typeInfo.getParentType();
if (parentType) {
const schemaCoordinate = `${parentType.name}.${fieldNode.name.value}`;
const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate] as unknown;
const maybeTtl = ttlPerSchemaCoordinate[schemaCoordinate];
ttl = calculateTtl(maybeTtl, ttl);
}
},
Expand Down Expand Up @@ -279,20 +283,37 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl];
});

type CacheControlDirective = {
export type CacheControlDirective = {
maxAge?: number;
scope?: 'PUBLIC' | 'PRIVATE';
};

let ttlPerSchemaCoordinate: Record<string, CacheControlDirective['maxAge']> = {};
let scopePerSchemaCoordinate: Record<string, CacheControlDirective['scope']> = {};

export function isPrivate(
typeName: string,
data?: Record<string, NonNullable<CacheControlDirective['scope']>>,
): boolean {
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
return true;
}
return data
? Object.keys(data).some(
fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE',
)
: false;
}

export function useResponseCache<PluginContext extends Record<string, any> = {}>({
cache = createInMemoryCache(),
ttl: globalTtl = Infinity,
session,
enabled,
ignoredTypes = [],
ttlPerType = {},
ttlPerSchemaCoordinate = {},
scopePerSchemaCoordinate = {},
ttlPerSchemaCoordinate: localTtlPerSchemaCoordinate = {},
scopePerSchemaCoordinate: localScopePerSchemaCoordinate = {},
idFields = ['id'],
invalidateViaMutation = true,
buildResponseCacheKey = defaultBuildResponseCacheKey,
Expand All @@ -308,22 +329,14 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
enabled = enabled ? memoize1(enabled) : enabled;

// never cache Introspections
ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...ttlPerSchemaCoordinate };
ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...localTtlPerSchemaCoordinate };
const documentMetadataOptions = {
queries: { invalidateViaMutation, ttlPerSchemaCoordinate },
mutations: { invalidateViaMutation }, // remove ttlPerSchemaCoordinate for mutations to skip TTL calculation
};
scopePerSchemaCoordinate = { ...localScopePerSchemaCoordinate };
const idFieldByTypeName = new Map<string, string>();
let schema: any;

function isPrivate(typeName: string, data: Record<string, unknown>): boolean {
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
return true;
}
return Object.keys(data).some(
fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE',
);
}
let schema: GraphQLSchema;

return {
onSchemaChange({ schema: newSchema }) {
Expand Down Expand Up @@ -522,6 +535,7 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
operationName: onExecuteParams.args.operationName,
sessionId,
context: onExecuteParams.args.contextValue,
getScope: () => getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body),
});

const cachedResponse = (await cache.get(cacheKey)) as ResponseCacheExecutionResult;
Expand Down
85 changes: 85 additions & 0 deletions packages/plugins/response-cache/test/response-cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3363,6 +3363,91 @@ describe('useResponseCache', () => {
expect(spy).toHaveBeenCalledTimes(2);
});

['query', 'field', 'subfield'].forEach(type => {
it(`should return PRIVATE scope in buildResponseCacheKey when putting @cacheControl scope on ${type}`, async () => {
jest.useFakeTimers();
const spy = jest.fn(() => [
{
id: 1,
name: 'User 1',
comments: [
{
id: 1,
text: 'Comment 1 of User 1',
},
],
},
{
id: 2,
name: 'User 2',
comments: [
{
id: 2,
text: 'Comment 2 of User 2',
},
],
},
]);

const schema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
${cacheControlDirective}
type Query {
users: [User!]! ${type === 'query' ? '@cacheControl(scope: PRIVATE)' : ''}
}
type User ${type === 'field' ? '@cacheControl(scope: PRIVATE)' : ''} {
id: ID!
name: String! ${type === 'subfield' ? '@cacheControl(scope: PRIVATE)' : ''}
comments: [Comment!]!
recentComment: Comment
}
type Comment {
id: ID!
text: String!
}
`,
resolvers: {
Query: {
users: spy,
},
},
});

const testInstance = createTestkit(
[
useResponseCache({
session: () => null,
buildResponseCacheKey: ({ getScope, ...rest }) => {
expect(getScope()).toEqual('PRIVATE');
return defaultBuildResponseCacheKey(rest);
},
ttl: 200,
}),
],
schema,
);

const query = /* GraphQL */ `
query test {
users {
id
name
comments {
id
text
}
}
}
`;

await testInstance.execute(query);

expect(spy).toHaveBeenCalledTimes(1);
});
});

it('should cache correctly for session with ttl being a valid number', async () => {
jest.useFakeTimers();
const spy = jest.fn(() => [
Expand Down

0 comments on commit 4f30aba

Please sign in to comment.