Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(graphcache): allow for defining inline-fragment/fragment-definition client controlled nullability directives (#3502)

Co-authored-by: Phil Pluckthun <phil@kitten.sh>

authored by

Jovi De Croock
Phil Pluckthun
and committed by
GitHub
ceeb73bb 973da7ff

+394 -2
+5
.changeset/happy-peas-sin.md
··· 1 + --- 2 + '@urql/exchange-graphcache': minor 3 + --- 4 + 5 + Allow `@_optional` and `@_required` to be placed on fragment definitions and inline fragments
+16
exchanges/graphcache/src/ast/traversal.ts
··· 93 93 94 94 return false; 95 95 }; 96 + 97 + /** Resolves @_optional and @_required directive to determine whether the fields in a fragment are conaidered optional. */ 98 + export const isOptional = ( 99 + node: FormattedNode<FragmentSpreadNode | InlineFragmentNode> 100 + ): boolean | undefined => { 101 + const { optional, required } = getDirectives(node); 102 + if (required) { 103 + return false; 104 + } 105 + 106 + if (optional) { 107 + return true; 108 + } 109 + 110 + return undefined; 111 + };
+346
exchanges/graphcache/src/cacheExchange.test.ts
··· 827 827 }); 828 828 }); 829 829 830 + it('Does not return partial data for nested selections', () => { 831 + const client = createClient({ 832 + url: 'http://0.0.0.0', 833 + exchanges: [], 834 + }); 835 + const { source: ops$, next } = makeSubject<Operation>(); 836 + 837 + const query = gql` 838 + { 839 + todo { 840 + ... on Todo @_optional { 841 + id 842 + text 843 + author { 844 + id 845 + name 846 + } 847 + } 848 + } 849 + } 850 + `; 851 + 852 + const operation = client.createRequestOperation('query', { 853 + key: 1, 854 + query, 855 + variables: undefined, 856 + }); 857 + 858 + const queryResult: OperationResult = { 859 + ...queryResponse, 860 + operation, 861 + data: { 862 + __typename: 'Query', 863 + todo: { 864 + id: '1', 865 + text: 'learn urql', 866 + __typename: 'Todo', 867 + author: { 868 + __typename: 'Author', 869 + }, 870 + }, 871 + }, 872 + }; 873 + 874 + const reexecuteOperation = vi 875 + .spyOn(client, 'reexecuteOperation') 876 + .mockImplementation(next); 877 + 878 + const response = vi.fn((forwardOp: Operation): OperationResult => { 879 + if (forwardOp.key === 1) return queryResult; 880 + return undefined as any; 881 + }); 882 + 883 + const result = vi.fn(); 884 + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); 885 + 886 + pipe( 887 + cacheExchange({})({ forward, client, dispatchDebug })(ops$), 888 + tap(result), 889 + publish 890 + ); 891 + 892 + next(operation); 893 + 894 + expect(response).toHaveBeenCalledTimes(1); 895 + expect(result).toHaveBeenCalledTimes(1); 896 + expect(reexecuteOperation).toHaveBeenCalledTimes(0); 897 + expect(result.mock.calls[0][0].data).toEqual(null); 898 + }); 899 + 900 + it('returns partial results when an inline-fragment is marked as optional', () => { 901 + const client = createClient({ 902 + url: 'http://0.0.0.0', 903 + exchanges: [], 904 + }); 905 + const { source: ops$, next } = makeSubject<Operation>(); 906 + 907 + const query = gql` 908 + { 909 + todos { 910 + id 911 + text 912 + ... on Todo @_optional { 913 + completed 914 + } 915 + } 916 + } 917 + `; 918 + 919 + const operation = client.createRequestOperation('query', { 920 + key: 1, 921 + query, 922 + variables: undefined, 923 + }); 924 + 925 + const queryResult: OperationResult = { 926 + ...queryResponse, 927 + operation, 928 + data: { 929 + __typename: 'Query', 930 + todos: [ 931 + { 932 + id: '1', 933 + text: 'learn urql', 934 + __typename: 'Todo', 935 + }, 936 + ], 937 + }, 938 + }; 939 + 940 + const reexecuteOperation = vi 941 + .spyOn(client, 'reexecuteOperation') 942 + .mockImplementation(next); 943 + 944 + const response = vi.fn((forwardOp: Operation): OperationResult => { 945 + if (forwardOp.key === 1) return queryResult; 946 + return undefined as any; 947 + }); 948 + 949 + const result = vi.fn(); 950 + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); 951 + 952 + pipe( 953 + cacheExchange({})({ forward, client, dispatchDebug })(ops$), 954 + tap(result), 955 + publish 956 + ); 957 + 958 + next(operation); 959 + 960 + expect(response).toHaveBeenCalledTimes(1); 961 + expect(result).toHaveBeenCalledTimes(1); 962 + expect(reexecuteOperation).toHaveBeenCalledTimes(0); 963 + expect(result.mock.calls[0][0].data).toEqual({ 964 + todos: [ 965 + { 966 + completed: null, 967 + id: '1', 968 + text: 'learn urql', 969 + }, 970 + ], 971 + }); 972 + }); 973 + 974 + it('does not return partial results when an inline-fragment is marked as optional with a required child fragment', () => { 975 + const client = createClient({ 976 + url: 'http://0.0.0.0', 977 + exchanges: [], 978 + }); 979 + const { source: ops$, next } = makeSubject<Operation>(); 980 + 981 + const query = gql` 982 + { 983 + todos { 984 + id 985 + ... on Todo @_optional { 986 + text 987 + ... on Todo @_required { 988 + completed 989 + } 990 + } 991 + } 992 + } 993 + `; 994 + 995 + const operation = client.createRequestOperation('query', { 996 + key: 1, 997 + query, 998 + variables: undefined, 999 + }); 1000 + 1001 + const queryResult: OperationResult = { 1002 + ...queryResponse, 1003 + operation, 1004 + data: { 1005 + __typename: 'Query', 1006 + todos: [ 1007 + { 1008 + id: '1', 1009 + text: 'learn urql', 1010 + __typename: 'Todo', 1011 + }, 1012 + ], 1013 + }, 1014 + }; 1015 + 1016 + const reexecuteOperation = vi 1017 + .spyOn(client, 'reexecuteOperation') 1018 + .mockImplementation(next); 1019 + 1020 + const response = vi.fn((forwardOp: Operation): OperationResult => { 1021 + if (forwardOp.key === 1) return queryResult; 1022 + return undefined as any; 1023 + }); 1024 + 1025 + const result = vi.fn(); 1026 + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); 1027 + 1028 + pipe( 1029 + cacheExchange({})({ forward, client, dispatchDebug })(ops$), 1030 + tap(result), 1031 + publish 1032 + ); 1033 + 1034 + next(operation); 1035 + 1036 + expect(response).toHaveBeenCalledTimes(1); 1037 + expect(result).toHaveBeenCalledTimes(1); 1038 + expect(reexecuteOperation).toHaveBeenCalledTimes(0); 1039 + expect(result.mock.calls[0][0].data).toEqual(null); 1040 + }); 1041 + 1042 + it('does not return partial results when an inline-fragment is marked as optional with a required field', () => { 1043 + const client = createClient({ 1044 + url: 'http://0.0.0.0', 1045 + exchanges: [], 1046 + }); 1047 + const { source: ops$, next } = makeSubject<Operation>(); 1048 + 1049 + const query = gql` 1050 + { 1051 + todos { 1052 + id 1053 + ... on Todo @_optional { 1054 + text 1055 + completed @_required 1056 + } 1057 + } 1058 + } 1059 + `; 1060 + 1061 + const operation = client.createRequestOperation('query', { 1062 + key: 1, 1063 + query, 1064 + variables: undefined, 1065 + }); 1066 + 1067 + const queryResult: OperationResult = { 1068 + ...queryResponse, 1069 + operation, 1070 + data: { 1071 + __typename: 'Query', 1072 + todos: [ 1073 + { 1074 + id: '1', 1075 + text: 'learn urql', 1076 + __typename: 'Todo', 1077 + }, 1078 + ], 1079 + }, 1080 + }; 1081 + 1082 + const reexecuteOperation = vi 1083 + .spyOn(client, 'reexecuteOperation') 1084 + .mockImplementation(next); 1085 + 1086 + const response = vi.fn((forwardOp: Operation): OperationResult => { 1087 + if (forwardOp.key === 1) return queryResult; 1088 + return undefined as any; 1089 + }); 1090 + 1091 + const result = vi.fn(); 1092 + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); 1093 + 1094 + pipe( 1095 + cacheExchange({})({ forward, client, dispatchDebug })(ops$), 1096 + tap(result), 1097 + publish 1098 + ); 1099 + 1100 + next(operation); 1101 + 1102 + expect(response).toHaveBeenCalledTimes(1); 1103 + expect(result).toHaveBeenCalledTimes(1); 1104 + expect(reexecuteOperation).toHaveBeenCalledTimes(0); 1105 + expect(result.mock.calls[0][0].data).toEqual(null); 1106 + }); 1107 + 1108 + it('returns partial results when a fragment-definition is marked as optional', () => { 1109 + const client = createClient({ 1110 + url: 'http://0.0.0.0', 1111 + exchanges: [], 1112 + }); 1113 + const { source: ops$, next } = makeSubject<Operation>(); 1114 + 1115 + const query = gql` 1116 + { 1117 + todos { 1118 + id 1119 + text 1120 + ...Fields 1121 + } 1122 + } 1123 + 1124 + fragment Fields on Todo @_optional { 1125 + completed 1126 + } 1127 + `; 1128 + 1129 + const operation = client.createRequestOperation('query', { 1130 + key: 1, 1131 + query, 1132 + variables: undefined, 1133 + }); 1134 + 1135 + const queryResult: OperationResult = { 1136 + ...queryResponse, 1137 + operation, 1138 + data: { 1139 + __typename: 'Query', 1140 + todos: [ 1141 + { 1142 + id: '1', 1143 + text: 'learn urql', 1144 + __typename: 'Todo', 1145 + }, 1146 + ], 1147 + }, 1148 + }; 1149 + 1150 + const reexecuteOperation = vi 1151 + .spyOn(client, 'reexecuteOperation') 1152 + .mockImplementation(next); 1153 + 1154 + const response = vi.fn((forwardOp: Operation): OperationResult => { 1155 + if (forwardOp.key === 1) return queryResult; 1156 + return undefined as any; 1157 + }); 1158 + 1159 + const result = vi.fn(); 1160 + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); 1161 + 1162 + pipe( 1163 + cacheExchange({})({ forward, client, dispatchDebug })(ops$), 1164 + tap(result), 1165 + publish 1166 + ); 1167 + 1168 + next(operation); 1169 + 1170 + expect(response).toHaveBeenCalledTimes(1); 1171 + expect(result).toHaveBeenCalledTimes(1); 1172 + expect(reexecuteOperation).toHaveBeenCalledTimes(0); 1173 + expect(result.mock.calls[0][0].data).toEqual(null); 1174 + }); 1175 + 830 1176 it('does not return missing required fields', () => { 831 1177 const client = createClient({ 832 1178 url: 'http://0.0.0.0',
+8 -1
exchanges/graphcache/src/operations/query.ts
··· 43 43 updateContext, 44 44 getFieldError, 45 45 deferRef, 46 + optionalRef, 46 47 } from './shared'; 47 48 48 49 import { ··· 145 146 entityKey, 146 147 entityKey, 147 148 deferRef, 149 + undefined, 148 150 select, 149 151 ctx 150 152 ); ··· 389 391 typename, 390 392 entityKey, 391 393 deferRef, 394 + undefined, 392 395 select, 393 396 ctx 394 397 ); ··· 529 532 !deferRef && 530 533 dataFieldValue === undefined && 531 534 (directives.optional || 535 + (optionalRef && !directives.required) || 532 536 !!getFieldError(ctx) || 533 537 (store.schema && 534 538 isFieldNullable(store.schema, typename, fieldName, ctx.store.logger))) ··· 536 540 // The field is uncached or has errored, so it'll be set to null and skipped 537 541 ctx.partial = true; 538 542 dataFieldValue = null; 539 - } else if (dataFieldValue === null && directives.required) { 543 + } else if ( 544 + dataFieldValue === null && 545 + (directives.required || optionalRef === false) 546 + ) { 540 547 if ( 541 548 ctx.store.logger && 542 549 process.env.NODE_ENV !== 'production' &&
+5
exchanges/graphcache/src/operations/shared.test.ts
··· 34 34 'Query', 35 35 'Query', 36 36 false, 37 + undefined, 37 38 selection, 38 39 ctx 39 40 ); ··· 93 94 'Query', 94 95 'Query', 95 96 false, 97 + undefined, 96 98 selection, 97 99 ctx 98 100 ); ··· 123 125 'Query', 124 126 'Query', 125 127 false, 128 + undefined, 126 129 selection, 127 130 ctx 128 131 ); ··· 208 211 'Query', 209 212 'Query', 210 213 false, 214 + undefined, 211 215 selection, 212 216 ctx 213 217 ); ··· 243 247 'Query', 244 248 'Query', 245 249 true, 250 + undefined, 246 251 selection, 247 252 ctx 248 253 );
+13 -1
exchanges/graphcache/src/operations/shared.ts
··· 8 8 import { Kind } from '@0no-co/graphql.web'; 9 9 10 10 import type { SelectionSet } from '../ast'; 11 - import { isDeferred, getTypeCondition, getSelectionSet, getName } from '../ast'; 11 + import { 12 + isDeferred, 13 + getTypeCondition, 14 + getSelectionSet, 15 + getName, 16 + isOptional, 17 + } from '../ast'; 12 18 13 19 import { warn, pushDebugNode, popDebugNode } from '../helpers/help'; 14 20 import { hasField, currentOperation, currentOptimistic } from '../store/data'; ··· 49 55 50 56 export let contextRef: Context | null = null; 51 57 export let deferRef = false; 58 + export let optionalRef: boolean | undefined = undefined; 52 59 53 60 // Checks whether the current data field is a cache miss because of a GraphQLError 54 61 export const getFieldError = (ctx: Context): ErrorLike | undefined => ··· 160 167 typename: void | string, 161 168 entityKey: string, 162 169 defer: boolean, 170 + optional: boolean | undefined, 163 171 selectionSet: FormattedNode<SelectionSet>, 164 172 ctx: Context 165 173 ): SelectionIterator => { ··· 171 179 while (child || index < selectionSet.length) { 172 180 node = undefined; 173 181 deferRef = defer; 182 + optionalRef = optional; 174 183 if (child) { 175 184 if ((node = child())) { 176 185 return node; ··· 203 212 if (isMatching) { 204 213 if (process.env.NODE_ENV !== 'production') 205 214 pushDebugNode(typename, fragment); 215 + 216 + const isFragmentOptional = isOptional(select); 206 217 child = makeSelectionIterator( 207 218 typename, 208 219 entityKey, 209 220 defer || isDeferred(select, ctx.variables), 221 + isFragmentOptional, 210 222 getSelectionSet(fragment), 211 223 ctx 212 224 );
+1
exchanges/graphcache/src/operations/write.ts
··· 240 240 typename, 241 241 entityKey || typename, 242 242 deferRef, 243 + undefined, 243 244 select, 244 245 ctx 245 246 );