Skip to content

Commit 0fe732d

Browse files
zthfacebook-github-bot
authored andcommittedSep 3, 2020
Add @appendNode and @prependNode directives for updating the store (#3155)
Summary: This adds two new directives, `appendNode` and `prependNode`, for updating the store declaratively. The directives will create an edge for the annotated object and prepend/append that edge to the provided connections. It works with single nodes as well as lists of nodes. Example: ``` mutation AddComments( $input: AddCommentsInput!, $connections: [String!]!, $edgeTypeName: String! ) { addComments(input: $input) { # It works for lists of nodes as well as for single nodes comments appendNode(connections: $connections, edgeTypeName: $edgeTypeName) { id } # This mutation naturally doesn't make sense, but is just here to illustrate that this works for a single node as well comment appendNode(connections: $connections, edgeTypeName: $edgeTypeName) { id } } } ``` Pull Request resolved: #3155 Reviewed By: kassens Differential Revision: D23390489 Pulled By: tyao1 fbshipit-source-id: 31d23d455651c0b05d46b2ac8b7edb5665b88265
1 parent be51276 commit 0fe732d

File tree

11 files changed

+1199
-39
lines changed

11 files changed

+1199
-39
lines changed
 

‎packages/relay-compiler/codegen/__tests__/__snapshots__/compileRelayArtifacts-test.js.snap

+154
Original file line numberDiff line numberDiff line change
@@ -2085,6 +2085,160 @@ mutation CommentCreateMutation(
20852085
20862086
`;
20872087

2088+
exports[`compileRelayArtifacts matches expected output: append-node.graphql 1`] = `
2089+
~~~~~~~~~~ INPUT ~~~~~~~~~~
2090+
mutation CommentCreateMutation(
2091+
$connections: [String!]!
2092+
$edgeTypeName: String!
2093+
$input: CommentCreateInput
2094+
) {
2095+
commentCreate(input: $input) {
2096+
comment
2097+
@appendNode(connections: $connections, edgeTypeName: $edgeTypeName) {
2098+
id
2099+
}
2100+
}
2101+
}
2102+
2103+
~~~~~~~~~~ OUTPUT ~~~~~~~~~~
2104+
Request {
2105+
fragment: Fragment {
2106+
argumentDefinitions: [
2107+
LocalArgument {
2108+
defaultValue: null,
2109+
name: "connections",
2110+
},
2111+
LocalArgument {
2112+
defaultValue: null,
2113+
name: "edgeTypeName",
2114+
},
2115+
LocalArgument {
2116+
defaultValue: null,
2117+
name: "input",
2118+
},
2119+
],
2120+
metadata: null,
2121+
name: "CommentCreateMutation",
2122+
selections: [
2123+
LinkedField {
2124+
alias: null,
2125+
args: [
2126+
Variable {
2127+
name: "input",
2128+
variableName: "input",
2129+
},
2130+
],
2131+
concreteType: "CommentCreateResponsePayload",
2132+
name: "commentCreate",
2133+
plural: false,
2134+
selections: [
2135+
LinkedField {
2136+
alias: null,
2137+
args: null,
2138+
concreteType: "Comment",
2139+
name: "comment",
2140+
plural: false,
2141+
selections: [
2142+
ScalarField {
2143+
alias: null,
2144+
args: null,
2145+
name: "id",
2146+
storageKey: null,
2147+
},
2148+
],
2149+
storageKey: null,
2150+
},
2151+
],
2152+
storageKey: null,
2153+
},
2154+
],
2155+
type: "Mutation",
2156+
abstractKey: null,
2157+
},
2158+
operation: Operation {
2159+
argumentDefinitions: [
2160+
LocalArgument {
2161+
defaultValue: null,
2162+
name: "connections",
2163+
},
2164+
LocalArgument {
2165+
defaultValue: null,
2166+
name: "edgeTypeName",
2167+
},
2168+
LocalArgument {
2169+
defaultValue: null,
2170+
name: "input",
2171+
},
2172+
],
2173+
name: "CommentCreateMutation",
2174+
selections: [
2175+
LinkedField {
2176+
alias: null,
2177+
args: [
2178+
Variable {
2179+
name: "input",
2180+
variableName: "input",
2181+
},
2182+
],
2183+
concreteType: "CommentCreateResponsePayload",
2184+
name: "commentCreate",
2185+
plural: false,
2186+
selections: [
2187+
LinkedField {
2188+
alias: null,
2189+
args: null,
2190+
concreteType: "Comment",
2191+
name: "comment",
2192+
plural: false,
2193+
selections: [
2194+
ScalarField {
2195+
alias: null,
2196+
args: null,
2197+
name: "id",
2198+
storageKey: null,
2199+
},
2200+
],
2201+
storageKey: null,
2202+
},
2203+
LinkedHandle {
2204+
alias: null,
2205+
args: null,
2206+
filters: null,
2207+
handle: "appendNode",
2208+
key: "",
2209+
name: "comment",
2210+
handleArgs: [
2211+
Variable {
2212+
name: "connections",
2213+
variableName: "connections",
2214+
},
2215+
Variable {
2216+
name: "edgeTypeName",
2217+
variableName: "edgeTypeName",
2218+
},
2219+
],
2220+
},
2221+
],
2222+
storageKey: null,
2223+
},
2224+
],
2225+
},
2226+
}
2227+
2228+
QUERY:
2229+
2230+
mutation CommentCreateMutation(
2231+
$input: CommentCreateInput
2232+
) {
2233+
commentCreate(input: $input) {
2234+
comment {
2235+
id
2236+
}
2237+
}
2238+
}
2239+
2240+
`;
2241+
20882242
exports[`compileRelayArtifacts matches expected output: client-conditions.graphql 1`] = `
20892243
~~~~~~~~~~ INPUT ~~~~~~~~~~
20902244
fragment Foo_user on User {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
mutation CommentCreateMutation(
2+
$connections: [String!]!
3+
$edgeTypeName: String!
4+
$input: CommentCreateInput
5+
) {
6+
commentCreate(input: $input) {
7+
comment
8+
@appendNode(connections: $connections, edgeTypeName: $edgeTypeName) {
9+
id
10+
}
11+
}
12+
}

‎packages/relay-compiler/transforms/DeclarativeConnectionMutationTransform.js

+105-38
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ const {ConnectionInterface} = require('relay-runtime');
2020
const DELETE_RECORD = 'deleteRecord';
2121
const APPEND_EDGE = 'appendEdge';
2222
const PREPEND_EDGE = 'prependEdge';
23-
const LINKED_FIELD_DIRECTIVES = [APPEND_EDGE, PREPEND_EDGE];
23+
const APPEND_NODE = 'appendNode';
24+
const PREPEND_NODE = 'prependNode';
25+
const EDGE_LINKED_FIELD_DIRECTIVES = [APPEND_EDGE, PREPEND_EDGE];
26+
const NODE_LINKED_FIELD_DIRECTIVES = [APPEND_NODE, PREPEND_NODE];
27+
const LINKED_FIELD_DIRECTIVES = [
28+
...EDGE_LINKED_FIELD_DIRECTIVES,
29+
...NODE_LINKED_FIELD_DIRECTIVES,
30+
];
2431

2532
const SCHEMA_EXTENSION = `
2633
directive @${DELETE_RECORD} on FIELD
@@ -30,6 +37,14 @@ const SCHEMA_EXTENSION = `
3037
directive @${PREPEND_EDGE}(
3138
connections: [String!]!
3239
) on FIELD
40+
directive @${APPEND_NODE}(
41+
connections: [String!]!
42+
edgeTypeName: String!
43+
) on FIELD
44+
directive @${PREPEND_NODE}(
45+
connections: [String!]!
46+
edgeTypeName: String!
47+
) on FIELD
3348
`;
3449

3550
import type CompilerContext from '../core/CompilerContext';
@@ -103,55 +118,107 @@ function visitLinkedField(field: LinkedField): LinkedField {
103118
);
104119
}
105120
const edgeDirective = transformedField.directives.find(
106-
directive => LINKED_FIELD_DIRECTIVES.indexOf(directive.name) > -1,
121+
directive => EDGE_LINKED_FIELD_DIRECTIVES.indexOf(directive.name) > -1,
122+
);
123+
const nodeDirective = transformedField.directives.find(
124+
directive => NODE_LINKED_FIELD_DIRECTIVES.indexOf(directive.name) > -1,
107125
);
108-
if (edgeDirective == null) {
126+
127+
if (edgeDirective == null && nodeDirective == null) {
109128
return transformedField;
110129
}
111-
const connectionsArg = edgeDirective.args.find(
130+
if (edgeDirective != null && nodeDirective != null) {
131+
throw createUserError(
132+
`Invalid use of @${edgeDirective.name} and @${nodeDirective.name} on field '${transformedField.name}' - these directives cannot be used together.`,
133+
[edgeDirective.loc],
134+
);
135+
}
136+
const targetDirective = edgeDirective ?? nodeDirective;
137+
const connectionsArg = targetDirective.args.find(
112138
arg => arg.name === 'connections',
113139
);
114140
if (connectionsArg == null) {
115141
throw createUserError(
116-
`Expected the 'connections' argument to be defined on @${edgeDirective.name}.`,
117-
[edgeDirective.loc],
142+
`Expected the 'connections' argument to be defined on @${targetDirective.name}.`,
143+
[targetDirective.loc],
118144
);
119145
}
120146
const schema = this.getContext().getSchema();
121-
const fields = schema.getFields(transformedField.type);
122-
let cursorFieldID;
123-
let nodeFieldID;
124-
for (const fieldID of fields) {
125-
const fieldName = schema.getFieldName(fieldID);
126-
if (fieldName === ConnectionInterface.get().CURSOR) {
127-
cursorFieldID = fieldID;
128-
} else if (fieldName === ConnectionInterface.get().NODE) {
129-
nodeFieldID = fieldID;
147+
if (edgeDirective) {
148+
const fields = schema.getFields(transformedField.type);
149+
let cursorFieldID;
150+
let nodeFieldID;
151+
for (const fieldID of fields) {
152+
const fieldName = schema.getFieldName(fieldID);
153+
if (fieldName === ConnectionInterface.get().CURSOR) {
154+
cursorFieldID = fieldID;
155+
} else if (fieldName === ConnectionInterface.get().NODE) {
156+
nodeFieldID = fieldID;
157+
}
130158
}
159+
160+
// Edge
161+
if (cursorFieldID != null && nodeFieldID != null) {
162+
const handle: Handle = {
163+
name: edgeDirective.name,
164+
key: '',
165+
dynamicKey: null,
166+
filters: null,
167+
handleArgs: [connectionsArg],
168+
};
169+
return {
170+
...transformedField,
171+
directives: transformedField.directives.filter(
172+
directive => directive !== edgeDirective,
173+
),
174+
handles: transformedField.handles
175+
? [...transformedField.handles, handle]
176+
: [handle],
177+
};
178+
}
179+
throw createUserError(
180+
`Unsupported use of @${edgeDirective.name} on field '${transformedField.name}', expected an edge field (a field with 'cursor' and 'node' selection).`,
181+
[targetDirective.loc],
182+
);
183+
} else {
184+
// Node
185+
const edgeTypeNameArg = nodeDirective.args.find(
186+
arg => arg.name === 'edgeTypeName',
187+
);
188+
if (!edgeTypeNameArg) {
189+
throw createUserError(
190+
`Unsupported use of @${nodeDirective.name} on field '${transformedField.name}', 'edgeTypeName' argument must be provided.`,
191+
[targetDirective.loc],
192+
);
193+
}
194+
const rawType = schema.getRawType(transformedField.type);
195+
if (schema.canHaveSelections(rawType)) {
196+
const handle: Handle = {
197+
name: nodeDirective.name,
198+
key: '',
199+
dynamicKey: null,
200+
filters: null,
201+
handleArgs: [connectionsArg, edgeTypeNameArg],
202+
};
203+
return {
204+
...transformedField,
205+
directives: transformedField.directives.filter(
206+
directive => directive !== nodeDirective,
207+
),
208+
handles: transformedField.handles
209+
? [...transformedField.handles, handle]
210+
: [handle],
211+
};
212+
}
213+
throw createUserError(
214+
`Unsupported use of @${nodeDirective.name} on field '${
215+
transformedField.name
216+
}'. Expected an object, union or interface, but got '${schema.getTypeString(
217+
transformedField.type,
218+
)}'.`,
219+
[nodeDirective.loc],
220+
);
131221
}
132-
// Edge
133-
if (cursorFieldID != null && nodeFieldID != null) {
134-
const handle: Handle = {
135-
name: edgeDirective.name,
136-
key: '',
137-
dynamicKey: null,
138-
filters: null,
139-
handleArgs: [connectionsArg],
140-
};
141-
return {
142-
...transformedField,
143-
directives: transformedField.directives.filter(
144-
directive => directive !== edgeDirective,
145-
),
146-
handles: transformedField.handles
147-
? [...transformedField.handles, handle]
148-
: [handle],
149-
};
150-
}
151-
throw createUserError(
152-
`Unsupported use of @${edgeDirective.name} on field '${transformedField.name}', expected an edge field (a field with 'cursor' and 'node' selection).`,
153-
[edgeDirective.loc],
154-
);
155222
}
156223

157224
module.exports = {

‎packages/relay-compiler/transforms/__tests__/__snapshots__/DeclarativeConnectionMutationTransform-test.js.snap

+87
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,93 @@ Source: GraphQL request (7:12)
6060
6161
`;
6262
63+
exports[`matches expected output: append-node.graphql 1`] = `
64+
~~~~~~~~~~ INPUT ~~~~~~~~~~
65+
mutation CommentCreateMutation(
66+
$connections: [String!]!
67+
$edgeTypeName: String!
68+
$input: CommentCreateInput
69+
) {
70+
commentCreate(input: $input) {
71+
comment
72+
@appendNode(connections: $connections, edgeTypeName: $edgeTypeName) {
73+
id
74+
}
75+
}
76+
}
77+
78+
~~~~~~~~~~ OUTPUT ~~~~~~~~~~
79+
mutation CommentCreateMutation(
80+
$connections: [String!]!
81+
$edgeTypeName: String!
82+
$input: CommentCreateInput
83+
) {
84+
commentCreate(input: $input) {
85+
comment @__clientField(handle: "appendNode", handleArgs: (connections: $connections, edgeTypeName: $edgeTypeName)) {
86+
id
87+
}
88+
}
89+
}
90+
91+
`;
92+
93+
exports[`matches expected output: append-node-edge-literal.graphql 1`] = `
94+
~~~~~~~~~~ INPUT ~~~~~~~~~~
95+
mutation CommentCreateMutation(
96+
$connections: [String!]!
97+
$input: CommentCreateInput
98+
) {
99+
commentCreate(input: $input) {
100+
comment
101+
@appendNode(connections: $connections, edgeTypeName: "CommentEdge") {
102+
id
103+
}
104+
}
105+
}
106+
107+
~~~~~~~~~~ OUTPUT ~~~~~~~~~~
108+
mutation CommentCreateMutation(
109+
$connections: [String!]!
110+
$input: CommentCreateInput
111+
) {
112+
commentCreate(input: $input) {
113+
comment @__clientField(handle: "appendNode", handleArgs: (connections: $connections, edgeTypeName: "CommentEdge")) {
114+
id
115+
}
116+
}
117+
}
118+
119+
`;
120+
121+
exports[`matches expected output: append-node-unsupported.invalid.graphql 1`] = `
122+
~~~~~~~~~~ INPUT ~~~~~~~~~~
123+
# expected-to-throw
124+
mutation CommentCreateMutation(
125+
$connections: [String!]!
126+
$edgeTypeName: String!
127+
$input: CommentCreateInput
128+
) {
129+
commentCreate(input: $input) {
130+
viewer {
131+
__typename
132+
@appendNode(connections: $connections, edgeTypeName: $edgeTypeName)
133+
}
134+
}
135+
}
136+
137+
~~~~~~~~~~ OUTPUT ~~~~~~~~~~
138+
THROWN EXCEPTION:
139+
140+
Invalid use of @appendNode on scalar field '__typename'
141+
142+
Source: GraphQL request (10:9)
143+
9: __typename
144+
10: @appendNode(connections: $connections, edgeTypeName: $edgeTypeName)
145+
^
146+
11: }
147+
148+
`;
149+
63150
exports[`matches expected output: delete-from-store.graphql 1`] = `
64151
~~~~~~~~~~ INPUT ~~~~~~~~~~
65152
mutation CommentDeleteMutation($input: CommentDeleteInput) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
mutation CommentCreateMutation(
2+
$connections: [String!]!
3+
$input: CommentCreateInput
4+
) {
5+
commentCreate(input: $input) {
6+
comment
7+
@appendNode(connections: $connections, edgeTypeName: "CommentEdge") {
8+
id
9+
}
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# expected-to-throw
2+
mutation CommentCreateMutation(
3+
$connections: [String!]!
4+
$edgeTypeName: String!
5+
$input: CommentCreateInput
6+
) {
7+
commentCreate(input: $input) {
8+
viewer {
9+
__typename
10+
@appendNode(connections: $connections, edgeTypeName: $edgeTypeName)
11+
}
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
mutation CommentCreateMutation(
2+
$connections: [String!]!
3+
$edgeTypeName: String!
4+
$input: CommentCreateInput
5+
) {
6+
commentCreate(input: $input) {
7+
comment
8+
@appendNode(connections: $connections, edgeTypeName: $edgeTypeName) {
9+
id
10+
}
11+
}
12+
}

‎packages/relay-runtime/handlers/RelayDefaultHandlerProvider.js

+4
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ function RelayDefaultHandlerProvider(handle: string): Handler {
3030
return MutationHandlers.AppendEdgeHandler;
3131
case 'prependEdge':
3232
return MutationHandlers.PrependEdgeHandler;
33+
case 'appendNode':
34+
return MutationHandlers.AppendNodeHandler;
35+
case 'prependNode':
36+
return MutationHandlers.PrependNodeHandler;
3337
}
3438
invariant(
3539
false,

‎packages/relay-runtime/handlers/connection/MutationHandlers.js

+71
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ const PrependEdgeHandler: Handler = {
5252
update: edgeUpdater(ConnectionHandler.insertEdgeBefore),
5353
};
5454

55+
const AppendNodeHandler: Handler = {
56+
update: nodeUpdater(ConnectionHandler.insertEdgeAfter),
57+
};
58+
59+
const PrependNodeHandler: Handler = {
60+
update: nodeUpdater(ConnectionHandler.insertEdgeBefore),
61+
};
62+
5563
function edgeUpdater(
5664
insertFn: (RecordProxy, RecordProxy, ?string) => void,
5765
): (RecordSourceProxy, HandleFieldPayload) => void {
@@ -89,8 +97,71 @@ function edgeUpdater(
8997
};
9098
}
9199

100+
function nodeUpdater(
101+
insertFn: (RecordProxy, RecordProxy, ?string) => void,
102+
): (RecordSourceProxy, HandleFieldPayload) => void {
103+
return (store: RecordSourceProxy, payload: HandleFieldPayload) => {
104+
const record = store.get(payload.dataID);
105+
if (record == null) {
106+
return;
107+
}
108+
const {connections, edgeTypeName} = payload.handleArgs;
109+
invariant(
110+
connections != null,
111+
'MutationHandlers: Expected connection IDs to be specified.',
112+
);
113+
invariant(
114+
edgeTypeName != null,
115+
'MutationHandlers: Expected edge typename to be specified.',
116+
);
117+
let singleServerNode;
118+
let serverNodes;
119+
try {
120+
singleServerNode = record.getLinkedRecord(payload.fieldKey, payload.args);
121+
} catch {}
122+
if (!singleServerNode) {
123+
try {
124+
serverNodes = record.getLinkedRecords(payload.fieldKey, payload.args);
125+
} catch {}
126+
}
127+
invariant(
128+
singleServerNode != null || serverNodes != null,
129+
'MutationHandlers: Expected target node to exist.',
130+
);
131+
const serverNodeList = serverNodes ?? [singleServerNode];
132+
for (const serverNode of serverNodeList) {
133+
if (serverNode == null) {
134+
continue;
135+
}
136+
for (const connectionID of connections) {
137+
const connection = store.get(connectionID);
138+
if (connection == null) {
139+
warning(
140+
false,
141+
`[Relay][Mutation] The connection with id '${connectionID}' doesn't exist.`,
142+
);
143+
continue;
144+
}
145+
const clientEdge = ConnectionHandler.createEdge(
146+
store,
147+
connection,
148+
serverNode,
149+
edgeTypeName,
150+
);
151+
invariant(
152+
clientEdge != null,
153+
'MutationHandlers: Failed to build the edge.',
154+
);
155+
insertFn(connection, clientEdge);
156+
}
157+
}
158+
};
159+
}
160+
92161
module.exports = {
93162
AppendEdgeHandler,
94163
DeleteRecordHandler,
95164
PrependEdgeHandler,
165+
AppendNodeHandler,
166+
PrependNodeHandler,
96167
};

‎packages/relay-runtime/store/__tests__/RelayModernEnvironment-ExecuteMutationWithDeclarativeMutation-test.js

+715-1
Large diffs are not rendered by default.

‎packages/relay-test-utils-internal/testschema.graphql

+15
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ type Mutation {
106106
input: ApplicationRequestDeleteAllInput
107107
): ApplicationRequestDeleteAllResponsePayload
108108
commentCreate(input: CommentCreateInput): CommentCreateResponsePayload
109+
commentsCreate(input: CommentsCreateInput): CommentsCreateResponsePayload
109110
commentDelete(input: CommentDeleteInput): CommentDeleteResponsePayload
110111
commentsDelete(input: CommentsDeleteInput): CommentsDeleteResponsePayload
111112
feedbackLike(input: FeedbackLikeInput): FeedbackLikeResponsePayload
@@ -149,6 +150,12 @@ input CommentCreateInput {
149150
feedback: CommentfeedbackFeedback
150151
}
151152

153+
input CommentsCreateInput {
154+
clientMutationId: String
155+
feedbackId: ID
156+
feedback: [CommentfeedbackFeedback]
157+
}
158+
152159
input CommentfeedbackFeedback {
153160
comment: FeedbackcommentComment
154161
}
@@ -312,6 +319,14 @@ type CommentCreateResponsePayload {
312319
viewer: Viewer
313320
}
314321

322+
type CommentsCreateResponsePayload {
323+
clientMutationId: String
324+
comments: [Comment]
325+
feedback: [Feedback]
326+
feedbackCommentEdges: [CommentsEdge]
327+
viewer: Viewer
328+
}
329+
315330
type CommentDeleteResponsePayload {
316331
clientMutationId: String
317332
deletedCommentId: ID

0 commit comments

Comments
 (0)
Please sign in to comment.