Skip to content

Commit

Permalink
Support dynamic switching of read vs exec time resolvers in reader
Browse files Browse the repository at this point in the history
Summary:
This diff add runtime support for swapping between exec-time and read-time resolvers in the reader. It adds a flag to the operation in the artifact which indicates if the query is an exec-time query or not. This flag is then looked up in the reader and, if it is found, the reader ignores the resolver fields and treats the ReaderAST nodes as standard nodes (either using a standard scalar field in the case of a scalar resolver or a linked field in the case of a strong object edge resolver).

Note that this does not fix the issue of a single resolver being used in both read-time and exec-time queries. This will be addressed in a follow up diff.

Reviewed By: captbaritone

Differential Revision: D65639792

fbshipit-source-id: 68a13f9a2f8d668af5fee5863e12269261df2ba2
  • Loading branch information
evanyeung authored and facebook-github-bot committed Nov 15, 2024
1 parent dfa68c3 commit 023b875
Show file tree
Hide file tree
Showing 14 changed files with 1,189 additions and 12 deletions.
10 changes: 10 additions & 0 deletions compiler/crates/relay-codegen/src/build_ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,10 @@ impl<'schema, 'builder, 'config> CodegenBuilder<'schema, 'builder, 'config> {
.named(*EXEC_TIME_RESOLVERS)
.is_some(),
};
let exec_time_resolvers_field = ObjectEntry {
key: "use_exec_time_resolvers".intern(),
value: Primitive::Bool(context.has_exec_time_resolvers_directive),
};
match operation.directives.named(*DIRECTIVE_SPLIT_OPERATION) {
Some(_split_directive) => {
let metadata = Primitive::Key(self.object(vec![]));
Expand All @@ -389,6 +393,9 @@ impl<'schema, 'builder, 'config> CodegenBuilder<'schema, 'builder, 'config> {
name: Primitive::String(operation.name.item.0),
selections: selections,
};
if context.has_exec_time_resolvers_directive {
fields.push(exec_time_resolvers_field);
}
if !operation.variable_definitions.is_empty() {
let argument_definitions =
self.build_operation_variable_definitions(&operation.variable_definitions);
Expand All @@ -412,6 +419,9 @@ impl<'schema, 'builder, 'config> CodegenBuilder<'schema, 'builder, 'config> {
name: Primitive::String(operation.name.item.0),
selections: selections,
};
if context.has_exec_time_resolvers_directive {
fields.push(exec_time_resolvers_field);
}
if let Some(client_abstract_types) =
self.maybe_build_client_abstract_types(operation)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,6 @@ extend type User {
],
"storageKey": null
}
]
],
"use_exec_time_resolvers": true
}
9 changes: 9 additions & 0 deletions compiler/crates/relay-schema/src/relay-extensions.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,12 @@ directive @static on ARGUMENT_DEFINITION
Used for printing a query, need to be used with `debug_transform`
"""
directive @__debug on QUERY | MUTATION | SUBSCRIPTION | FRAGMENT_DEFINITION

"""
(Relay Only)
If added to a query, resolvers in that query to run at exec-time, rather than read-time.
This means the resolvers are run when the query data is requested rather than when the
query is used (i.e. when the network request is made instead of at render time).
"""
directive @exec_time_resolvers on QUERY
31 changes: 28 additions & 3 deletions packages/relay-runtime/store/RelayReader.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ class RelayReader {
_isWithinUnmatchedTypeRefinement: boolean;
_errorResponseFields: ?ErrorResponseFields;
_owner: RequestDescriptor;
// Exec time resolvers are run before reaching the Relay store so the store already contains
// the normalized data; the same as if the data were sent from the server. However, since a
// resolver could be used at read time or exec time in different queries, the reader AST for
// a resolver is the read time AST. At runtime, this flag is used to ignore the extra
// information in the read time resolver AST and use the "standard", non-resolver read paths
_useExecTimeResolvers: boolean;
_recordSource: RecordSource;
_seenRecords: DataIDSet;
_updatedDataIDs: DataIDSet;
Expand All @@ -124,6 +130,8 @@ class RelayReader {
this._isWithinUnmatchedTypeRefinement = false;
this._errorResponseFields = null;
this._owner = selector.owner;
this._useExecTimeResolvers =
this._owner.node.operation.use_exec_time_resolvers ?? false;
this._recordSource = recordSource;
this._seenRecords = new Set();
this._selector = selector;
Expand Down Expand Up @@ -550,7 +558,11 @@ class RelayReader {
}
case 'RelayLiveResolver':
case 'RelayResolver': {
this._readResolverField(selection, record, data);
if (this._useExecTimeResolvers) {
this._readScalar(selection, record, data);
} else {
this._readResolverField(selection, record, data);
}
break;
}
case 'FragmentSpread':
Expand Down Expand Up @@ -609,7 +621,20 @@ class RelayReader {
break;
case 'ClientEdgeToClientObject':
case 'ClientEdgeToServerObject':
this._readClientEdge(selection, record, data);
if (
this._useExecTimeResolvers &&
(selection.backingField.kind === 'RelayResolver' ||
selection.backingField.kind === 'RelayLiveResolver')
) {
const {linkedField} = selection;
if (linkedField.plural) {
this._readPluralLink(linkedField, record, data);
} else {
this._readLink(linkedField, record, data);
}
} else {
this._readClientEdge(selection, record, data);
}
break;
default:
(selection: empty);
Expand Down Expand Up @@ -1059,7 +1084,7 @@ class RelayReader {
}

_readScalar(
field: ReaderScalarField,
field: ReaderScalarField | ReaderRelayResolver | ReaderRelayLiveResolver,
record: Record,
data: SelectorData,
): ?mixed {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
* @oncall relay
*/

'use strict';

import type {IdOf} from '../..';
import type {DataID} from 'relay-runtime/util/RelayRuntimeTypes';

const {graphql} = require('../../query/GraphQLTag');
const {
createOperationDescriptor,
} = require('../RelayModernOperationDescriptor');
const RelayModernStore = require('../RelayModernStore');
const {read} = require('../RelayReader');
const RelayRecordSource = require('../RelayRecordSource');
const {
LiveResolverCache,
} = require('relay-runtime/store/live-resolvers/LiveResolverCache');

const UserMap = new Map([
['1', 'Alice'],
['2', 'Bob'],
['3', 'Claire'],
['4', 'Dennis'],
]);

export type TReaderTestUser = {
name: ?string,
};

const modelMock = jest.fn();
/**
* @RelayResolver RelayReaderExecResolversTestUser
*/
export function RelayReaderExecResolversTestUser(id: DataID): TReaderTestUser {
modelMock();
return {
name: UserMap.get(id),
};
}

const nameMock = jest.fn();
/**
* @RelayResolver RelayReaderExecResolversTestUser.name: String
*/
export function name(user: TReaderTestUser): ?string {
nameMock();
return user.name;
}

const bestFriendMock = jest.fn();
/**
* @RelayResolver RelayReaderExecResolversTestUser.best_friend: RelayReaderExecResolversTestUser
*/
export function best_friend(
user: TReaderTestUser,
): IdOf<'RelayReaderExecResolversTestUser'> {
bestFriendMock();
return {id: '2'};
}

const friendsMock = jest.fn();
/**
* @RelayResolver RelayReaderExecResolversTestUser.friends: [RelayReaderExecResolversTestUser]
*/
export function friends(
user: TReaderTestUser,
): Array<IdOf<'RelayReaderExecResolversTestUser'>> {
friendsMock();
return [{id: '2'}, {id: '3'}, {id: '4'}];
}

const user_oneMock = jest.fn();
/**
* @RelayResolver Query.RelayReaderExecResolversTest_user_one: RelayReaderExecResolversTestUser
*/
export function RelayReaderExecResolversTest_user_one(): IdOf<'RelayReaderExecResolversTestUser'> {
user_oneMock();
return {id: '1'};
}

/**
* Note that the reading of exec time resolvers is expected to be the same as
* the reading of standard server queries. The main purpose of testing is to ensure
* that resolvers marked as exec time are executed as standard server queries and
* not as read time resolver queries.
*/
describe('RelayReaderExecResolvers', () => {
it('reads exec_time_resolvers without calling the resolvers', () => {
const Query = graphql`
query RelayReaderExecResolversTestRunsQuery @exec_time_resolvers {
RelayReaderExecResolversTest_user_one {
name
best_friend {
name
}
friends {
name
}
}
}
`;
const operation = createOperationDescriptor(Query, {});
const source = new RelayRecordSource({
'client:root': {
__id: 'client:root',
__typename: '__Root',
RelayReaderExecResolversTest_user_one: {__ref: '1'},
},
'1': {
__id: '1',
name: 'Alice',
friends: {__refs: ['2', '3', '4']},
},
'2': {
__id: '2',
name: 'Bob',
},
'3': {
__id: '3',
name: 'Claire',
},
'4': {
__id: '4',
name: 'Dennis',
},
});
const resolverStore = new RelayModernStore(source);
const {data} = read(
source,
operation.fragment,
new LiveResolverCache(() => source, resolverStore),
);

expect(modelMock).not.toBeCalled();
expect(nameMock).not.toBeCalled();
expect(user_oneMock).not.toBeCalled();
expect(friendsMock).not.toBeCalled();
expect(data).toEqual({
RelayReaderExecResolversTest_user_one: {
name: 'Alice',
friends: [{name: 'Bob'}, {name: 'Claire'}, {name: 'Dennis'}],
},
});
});
});
55 changes: 47 additions & 8 deletions packages/relay-runtime/store/__tests__/RelayReader-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,31 +406,47 @@ describe('RelayReader', () => {
});

it('reads data when the root is deleted', () => {
const UserQuery = graphql`
query RelayReaderTestReadsDataWhenTheRootIsDeletedQuery {
me {
...RelayReaderTestReadsDataWhenTheRootIsDeletedUserProfile
}
}
`;
const UserProfile = graphql`
fragment RelayReaderTestReadsDataWhenTheRootIsDeletedUserProfile on User {
name
}
`;
source = RelayRecordSource.create();
source.delete('4');
const owner = createOperationDescriptor(UserQuery, {});
const {data, seenRecords} = read(
source,
createReaderSelector(UserProfile, '4', {}),
createReaderSelector(UserProfile, '4', {}, owner.request),
);
expect(data).toBe(null);
expect(Array.from(seenRecords.values()).sort()).toEqual(['4']);
});

it('reads data when the root is unfetched', () => {
const UserQuery = graphql`
query RelayReaderTestReadsDataWhenTheRootIsUnfetchedQuery {
me {
...RelayReaderTestReadsDataWhenTheRootIsDeletedUserProfile
}
}
`;
const UserProfile = graphql`
fragment RelayReaderTestReadsDataWhenTheRootIsUnfetchedUserProfile on User {
name
}
`;
source = RelayRecordSource.create();
const owner = createOperationDescriptor(UserQuery, {});
const {data, seenRecords} = read(
source,
createReaderSelector(UserProfile, '4', {}),
createReaderSelector(UserProfile, '4', {}, owner.request),
);
expect(data).toBe(undefined);
expect(Array.from(seenRecords.values()).sort()).toEqual(['4']);
Expand Down Expand Up @@ -791,9 +807,10 @@ describe('RelayReader', () => {
},
};
source = RelayRecordSource.create(storeData);
const owner = createOperationDescriptor(BarQuery, {});
const {data, seenRecords, isMissingData} = read(
source,
createReaderSelector(BarFragment, '1', {}),
createReaderSelector(BarFragment, '1', {}, owner.request),
);
expect(data).toEqual({
id: '1',
Expand All @@ -817,9 +834,10 @@ describe('RelayReader', () => {
},
};
source = RelayRecordSource.create(storeData);
const owner = createOperationDescriptor(BarQuery, {});
const {data, seenRecords, isMissingData} = read(
source,
createReaderSelector(BarFragment, '1', {}),
createReaderSelector(BarFragment, '1', {}, owner.request),
);
expect(data).toEqual({
id: '1',
Expand Down Expand Up @@ -1106,9 +1124,17 @@ describe('RelayReader', () => {
}
}
`;
const UserQuery = graphql`
query RelayReaderTestShouldHaveIsmissingdataTrueIfDataIsMissingAddressQuery {
me {
...RelayReaderTestShouldHaveIsmissingdataTrueIfDataIsMissingAddress
}
}
`;
const owner = createOperationDescriptor(UserQuery);
const {data, isMissingData} = read(
source,
createReaderSelector(Address, '1', {}),
createReaderSelector(Address, '1', {}, owner.request),
);
expect(data.id).toBe('1');
expect(data.address).not.toBeDefined();
Expand All @@ -1124,11 +1150,24 @@ describe('RelayReader', () => {
}
}
`;
const UserQuery = graphql`
query RelayReaderTestShouldHaveIsmissingdataTrueIfDataIsMissingVariablesProfilePictureQuery {
me {
id # Note that this does not include the fragment so the variable data should be missing
}
}
`;
const owner = createOperationDescriptor(UserQuery);
const {data, isMissingData} = read(
source,
createReaderSelector(ProfilePicture, '1', {
size: 48,
}),
createReaderSelector(
ProfilePicture,
'1',
{
size: 48,
},
owner.request,
),
);
expect(data.id).toBe('1');
expect(data.profilePicture).not.toBeDefined();
Expand Down
Loading

0 comments on commit 023b875

Please sign in to comment.