From 79110fc03aaa8db4b539c68e3275ac4c9d087c53 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 17:11:58 -0600 Subject: [PATCH 01/23] Add function to determine if a document is a mutation. --- src/utilities/graphql/__tests__/getFromAST.ts | 43 +++++++++++++++++++ src/utilities/graphql/getFromAST.ts | 11 +++++ 2 files changed, 54 insertions(+) diff --git a/src/utilities/graphql/__tests__/getFromAST.ts b/src/utilities/graphql/__tests__/getFromAST.ts index 87b6a96fda3..5173649b904 100644 --- a/src/utilities/graphql/__tests__/getFromAST.ts +++ b/src/utilities/graphql/__tests__/getFromAST.ts @@ -4,6 +4,7 @@ import { FragmentDefinitionNode, OperationDefinitionNode } from 'graphql'; import { checkDocument, + isMutation, getFragmentDefinitions, getQueryDefinition, getDefaultValues, @@ -247,3 +248,45 @@ describe('AST utility functions', () => { }); }); }); + +describe('isMutation', () => { + it('returns true when document is a mutation', () => { + const document = gql` + mutation { + updateSomething + } + `; + + expect(isMutation(document)).toBe(true); + }); + + it('returns false when document is a subscription', () => { + const document = gql` + subscription { + count + } + `; + + expect(isMutation(document)).toBe(false); + }); + + it('returns false when document is a query', () => { + const document = gql` + query { + count + } + `; + + expect(isMutation(document)).toBe(false); + }); + + it('returns false when document is a fragment', () => { + const document = gql` + fragment Frag on Query { + count + } + `; + + expect(isMutation(document)).toBe(false); + }); +}); diff --git a/src/utilities/graphql/getFromAST.ts b/src/utilities/graphql/getFromAST.ts index 0f04204311a..e5d9b74d4f5 100644 --- a/src/utilities/graphql/getFromAST.ts +++ b/src/utilities/graphql/getFromAST.ts @@ -2,7 +2,9 @@ import { invariant, InvariantError } from '../globals'; import { DocumentNode, + Kind, OperationDefinitionNode, + OperationTypeNode, FragmentDefinitionNode, ValueNode, } from 'graphql'; @@ -165,3 +167,12 @@ export function getDefaultValues( } return defaultValues; } + +export function isMutation(document: DocumentNode) { + const definition = getMainDefinition(document); + + return ( + definition.kind === Kind.OPERATION_DEFINITION && + definition.operation === OperationTypeNode.MUTATION + ); +} From 670ade3cfba886b3c9c438672decab91bc1f562f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 17:58:57 -0600 Subject: [PATCH 02/23] Add type helpers to deep omit a property --- src/utilities/types/DeepOmit.ts | 17 +++++++++++++++++ src/utilities/types/Primitive.ts | 8 ++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/utilities/types/DeepOmit.ts create mode 100644 src/utilities/types/Primitive.ts diff --git a/src/utilities/types/DeepOmit.ts b/src/utilities/types/DeepOmit.ts new file mode 100644 index 00000000000..e660c53dfe4 --- /dev/null +++ b/src/utilities/types/DeepOmit.ts @@ -0,0 +1,17 @@ +import { Primitive } from './Primitive'; + +export type DeepOmitArray = { + [P in keyof T]: DeepOmit; +}; + +export type DeepOmit = T extends Primitive + ? T + : { + [P in Exclude]: T[P] extends infer TP + ? TP extends Primitive + ? TP + : TP extends any[] + ? DeepOmitArray + : DeepOmit + : never; + }; diff --git a/src/utilities/types/Primitive.ts b/src/utilities/types/Primitive.ts new file mode 100644 index 00000000000..5238e83f55a --- /dev/null +++ b/src/utilities/types/Primitive.ts @@ -0,0 +1,8 @@ +export type Primitive = + | string + | Function + | number + | boolean + | Symbol + | undefined + | null; From d516ecbab5d863ff4140a54711d129afed06f16b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 18:46:34 -0600 Subject: [PATCH 03/23] Create an omitDeep helper to deep omit keys from an object. --- .prettierignore | 11 ++++ src/utilities/common/__tests__/omitDeep.ts | 71 ++++++++++++++++++++++ src/utilities/common/omitDeep.ts | 20 ++++++ 3 files changed, 102 insertions(+) create mode 100644 src/utilities/common/__tests__/omitDeep.ts create mode 100644 src/utilities/common/omitDeep.ts diff --git a/.prettierignore b/.prettierignore index ca60ce7b53e..62b176ec318 100644 --- a/.prettierignore +++ b/.prettierignore @@ -36,6 +36,17 @@ src/react/* !src/utilities/ src/utilities/* !src/utilities/promises/ +!src/utilities/types/ +src/utilities/types/* +!src/utilities/types/DeepOmit.ts +!src/utilities/types/Primitive.ts +!src/utilities/common +src/utilities/common/* +!src/utilities/common/stripTypename.ts +!src/utilities/common/omitDeep.ts +!src/utilities/common/__tests__/ +src/utilities/common/__tests__/* +!src/utilities/common/__tests__/omitDeep.ts ## Allowed React Hooks !src/react/hooks/ diff --git a/src/utilities/common/__tests__/omitDeep.ts b/src/utilities/common/__tests__/omitDeep.ts new file mode 100644 index 00000000000..cc0c789e3ff --- /dev/null +++ b/src/utilities/common/__tests__/omitDeep.ts @@ -0,0 +1,71 @@ +import { omitDeep } from '../omitDeep'; + +test('omits the key from a shallow object', () => { + expect(omitDeep({ omit: 'a', keep: 'b', other: 'c' }, 'omit')).toEqual({ + keep: 'b', + other: 'c', + }); +}); + +test('omits the key from arbitrarily nested object', () => { + expect( + omitDeep( + { + omit: 'a', + keep: { + omit: 'a', + keep: 'b', + other: { omit: 'a', keep: 'b', other: 'c' }, + }, + }, + 'omit' + ) + ).toEqual({ + keep: { + keep: 'b', + other: { keep: 'b', other: 'c' }, + }, + }); +}); + +test('omits the key from arrays', () => { + expect( + omitDeep( + [ + { omit: 'a', keep: 'b', other: 'c' }, + { omit: 'a', keep: 'b', other: 'c' }, + ], + 'omit' + ) + ).toEqual([ + { keep: 'b', other: 'c' }, + { keep: 'b', other: 'c' }, + ]); +}); + +test('omits the key from arbitrarily nested arrays', () => { + expect( + omitDeep( + [ + [{ omit: 'a', keep: 'b', other: 'c' }], + [ + { omit: 'a', keep: 'b', other: 'c' }, + [{ omit: 'a', keep: 'b', other: 'c' }], + ], + ], + 'omit' + ) + ).toEqual([ + [{ keep: 'b', other: 'c' }], + [{ keep: 'b', other: 'c' }, [{ keep: 'b', other: 'c' }]], + ]); +}); + +test('returns primitives unchanged', () => { + expect(omitDeep('a', 'ignored')).toBe('a'); + expect(omitDeep(1, 'ignored')).toBe(1); + expect(omitDeep(true, 'ignored')).toBe(true); + expect(omitDeep(null, 'ignored')).toBe(null); + expect(omitDeep(undefined, 'ignored')).toBe(undefined); + expect(omitDeep(Symbol.for('foo'), 'ignored')).toBe(Symbol.for('foo')); +}); diff --git a/src/utilities/common/omitDeep.ts b/src/utilities/common/omitDeep.ts new file mode 100644 index 00000000000..4f332ef2fd1 --- /dev/null +++ b/src/utilities/common/omitDeep.ts @@ -0,0 +1,20 @@ +import { DeepOmit } from '../types/DeepOmit'; +import { isNonNullObject } from './objects'; + +export function omitDeep(obj: T, key: K): DeepOmit { + if (Array.isArray(obj)) { + return obj.map((value) => omitDeep(value, key)) as DeepOmit; + } + + if (isNonNullObject(obj)) { + return Object.entries(obj).reduce((memo, [k, value]) => { + if (k === key) { + return memo; + } + + return { ...memo, [k]: omitDeep(value, key) }; + }, {}) as DeepOmit; + } + + return obj as DeepOmit; +} From 6754afd57a69ffcc1602db50957069dae37f5547 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 18:52:36 -0600 Subject: [PATCH 04/23] Create a helper to strip typename --- .prettierignore | 1 + .../common/__tests__/stripTypename.ts | 60 +++++++++++++++++++ src/utilities/common/stripTypename.ts | 5 ++ 3 files changed, 66 insertions(+) create mode 100644 src/utilities/common/__tests__/stripTypename.ts create mode 100644 src/utilities/common/stripTypename.ts diff --git a/.prettierignore b/.prettierignore index 62b176ec318..12816cc855f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -47,6 +47,7 @@ src/utilities/common/* !src/utilities/common/__tests__/ src/utilities/common/__tests__/* !src/utilities/common/__tests__/omitDeep.ts +!src/utilities/common/__tests__/stripTypename.ts ## Allowed React Hooks !src/react/hooks/ diff --git a/src/utilities/common/__tests__/stripTypename.ts b/src/utilities/common/__tests__/stripTypename.ts new file mode 100644 index 00000000000..6475c986754 --- /dev/null +++ b/src/utilities/common/__tests__/stripTypename.ts @@ -0,0 +1,60 @@ +import { stripTypename } from '../stripTypename'; + +test('omits __typename from a shallow object', () => { + expect( + stripTypename({ __typename: 'Person', firstName: 'Foo', lastName: 'Bar' }) + ).toEqual({ firstName: 'Foo', lastName: 'Bar' }); +}); + +test('omits __typename from arbitrarily nested object', () => { + expect( + stripTypename({ + __typename: 'Profile', + user: { + __typename: 'User', + firstName: 'Foo', + lastName: 'Bar', + location: { + __typename: 'Location', + city: 'Denver', + country: 'USA', + }, + }, + }) + ).toEqual({ + user: { + firstName: 'Foo', + lastName: 'Bar', + location: { + city: 'Denver', + country: 'USA', + }, + }, + }); +}); + +test('omits the __typename from arrays', () => { + expect( + stripTypename([ + { __typename: 'Todo', name: 'Take out trash' }, + { __typename: 'Todo', name: 'Clean room' }, + ]) + ).toEqual([{ name: 'Take out trash' }, { name: 'Clean room' }]); +}); + +test('omits __typename from arbitrarily nested arrays', () => { + expect( + stripTypename([ + [{ __typename: 'Foo', foo: 'foo' }], + [{ __typename: 'Bar', bar: 'bar' }, [{ __typename: 'Baz', baz: 'baz' }]], + ]) + ).toEqual([[{ foo: 'foo' }], [{ bar: 'bar' }, [{ baz: 'baz' }]]]); +}); + +test('returns primitives unchanged', () => { + expect(stripTypename('a')).toBe('a'); + expect(stripTypename(1)).toBe(1); + expect(stripTypename(true)).toBe(true); + expect(stripTypename(null)).toBe(null); + expect(stripTypename(undefined)).toBe(undefined); +}); diff --git a/src/utilities/common/stripTypename.ts b/src/utilities/common/stripTypename.ts new file mode 100644 index 00000000000..f3f28526ff8 --- /dev/null +++ b/src/utilities/common/stripTypename.ts @@ -0,0 +1,5 @@ +import { omitDeep } from './omitDeep'; + +export function stripTypename(value: T) { + return omitDeep(value, '__typename'); +} From 38d9f3a5edde7379a6d67c0d9aebfb3d8507298f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 22:12:19 -0600 Subject: [PATCH 05/23] Strip __typename when sending variables --- src/link/http/__tests__/HttpLink.ts | 258 +++++++++++++++++++++- src/link/http/selectHttpOptionsAndBody.ts | 3 +- 2 files changed, 259 insertions(+), 2 deletions(-) diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index 8b24a8f54bd..29afddec980 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -1083,7 +1083,263 @@ describe('HttpLink', () => { expect(errorHandler).toHaveBeenCalledWith( new Error('HttpLink: Trying to send a client-only query to the server. To send to the server, ensure a non-client field is added to the query or set the `transformOptions.removeClientFields` option to `true`.') ); - }) + }); + + it('strips __typename from object argument when sending a mutation', async () => { + fetchMock.mock('https://example.com/graphql', { + status: 200, + body: JSON.stringify({ + data: { + __typename: 'Mutation', + updateTodo: { + __typename: 'Todo', + id: 1, + name: 'Take out trash', + completed: true + } + } + }), + headers: { 'content-type': 'application/json' } + }); + + const query = gql` + mutation UpdateTodo($todo: TodoInput!) { + updateTodo(todo: $todo) { + id + name + completed + } + } + `; + + const link = createHttpLink({ uri: 'https://example.com/graphql' }); + + const todo = { + __typename: 'Todo', + id: 1, + name: 'Take out trash', + completed: true, + } + + await new Promise((resolve, reject) => { + execute(link, { query, variables: { todo } }).subscribe({ + next: resolve, + error: reject + }); + }); + + const [, options] = fetchMock.lastCall()!; + const { body } = options! + + expect(JSON.parse(body!.toString())).toEqual({ + operationName: 'UpdateTodo', + query: print(query), + variables: { + todo: { + id: 1, + name: 'Take out trash', + completed: true, + } + } + }); + }); + + it('strips __typename from array argument when sending a mutation', async () => { + fetchMock.mock('https://example.com/graphql', { + status: 200, + body: JSON.stringify({ + data: { + __typename: 'Mutation', + updateTodos: [ + { + __typename: 'Todo', + id: 1, + name: 'Take out trash', + completed: true + }, + { + __typename: 'Todo', + id: 2, + name: 'Clean room', + completed: true + }, + ] + } + }), + headers: { 'content-type': 'application/json' } + }); + + const query = gql` + mutation UpdateTodos($todos: [TodoInput!]!) { + updateTodos(todos: $todos) { + id + name + completed + } + } + `; + + const link = createHttpLink({ uri: 'https://example.com/graphql' }); + + const todos = [ + { + __typename: 'Todo', + id: 1, + name: 'Take out trash', + completed: true, + }, + { + __typename: 'Todo', + id: 2, + name: 'Clean room', + completed: true, + }, + ]; + + await new Promise((resolve, reject) => { + execute(link, { query, variables: { todos } }).subscribe({ + next: resolve, + error: reject + }); + }); + + const [, options] = fetchMock.lastCall()!; + const { body } = options! + + expect(JSON.parse(body!.toString())).toEqual({ + operationName: 'UpdateTodos', + query: print(query), + variables: { + todos: [ + { + id: 1, + name: 'Take out trash', + completed: true, + }, + { + id: 2, + name: 'Clean room', + completed: true, + }, + ] + } + }); + }); + + it('strips __typename from mixed argument when sending a mutation', async () => { + fetchMock.mock('https://example.com/graphql', { + status: 200, + body: JSON.stringify({ + data: { + __typename: 'Mutation', + updateProfile: { + __typename: 'Profile', + id: 1, + }, + } + }), + headers: { 'content-type': 'application/json' } + }); + + const query = gql` + mutation UpdateProfile($profile: ProfileInput!) { + updateProfile(profile: $profile) { + id + } + } + `; + + const link = createHttpLink({ uri: 'https://example.com/graphql' }); + + const profile = { + __typename: 'Profile', + id: 1, + interests: [ + { __typename: 'Interest', name: 'Hiking' }, + { __typename: 'Interest', name: 'Nature' } + ], + avatar: { + __typename: 'Avatar', + url: 'https://example.com/avatar.jpg', + } + }; + + await new Promise((resolve, reject) => { + execute(link, { query, variables: { profile } }).subscribe({ + next: resolve, + error: reject + }); + }); + + const [, options] = fetchMock.lastCall()!; + const { body } = options! + + expect(JSON.parse(body!.toString())).toEqual({ + operationName: 'UpdateProfile', + query: print(query), + variables: { + profile: { + id: 1, + interests: [ + { name: 'Hiking' }, + { name: 'Nature' } + ], + avatar: { + url: 'https://example.com/avatar.jpg', + }, + }, + } + }); + }); + }); + + it('strips __typename when sending a query', async () => { + fetchMock.mock('https://example.com/graphql', { + status: 200, + body: JSON.stringify({ + data: { + __typename: 'Query', + searchTodos: [] + } + }), + headers: { 'content-type': 'application/json' } + }); + + const query = gql` + query SearchTodos($filter: TodoFilter!) { + searchTodos(filter: $filter) { + id + name + } + } + `; + + const link = createHttpLink({ uri: 'https://example.com/graphql' }); + + const filter = { + __typename: 'Filter', + completed: true, + }; + + await new Promise((resolve, reject) => { + execute(link, { query, variables: { filter } }).subscribe({ + next: resolve, + error: reject + }); + }); + + const [, options] = fetchMock.lastCall()!; + const { body } = options! + + expect(JSON.parse(body!.toString())).toEqual({ + operationName: 'SearchTodos', + query: print(query), + variables: { + filter: { + completed: true, + }, + }, + }); }); describe('Dev warnings', () => { diff --git a/src/link/http/selectHttpOptionsAndBody.ts b/src/link/http/selectHttpOptionsAndBody.ts index 76601a9eb7c..4479bec3edb 100644 --- a/src/link/http/selectHttpOptionsAndBody.ts +++ b/src/link/http/selectHttpOptionsAndBody.ts @@ -1,4 +1,5 @@ import { ASTNode, print } from 'graphql'; +import { stripTypename } from '../../utilities/common/stripTypename'; import { Operation } from '../core'; @@ -179,7 +180,7 @@ export function selectHttpOptionsAndBodyInternal( //The body depends on the http options const { operationName, extensions, variables, query } = operation; - const body: Body = { operationName, variables }; + const body: Body = { operationName, variables: stripTypename(variables) }; if (http.includeExtensions) (body as any).extensions = extensions; From 31887418c8d49059f37dcba1c273dea394c487ae Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 23:14:37 -0600 Subject: [PATCH 06/23] Ensure omitDeep handles circular references --- src/utilities/common/__tests__/omitDeep.ts | 15 ++++++++ src/utilities/common/omitDeep.ts | 42 ++++++++++++++++------ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/utilities/common/__tests__/omitDeep.ts b/src/utilities/common/__tests__/omitDeep.ts index cc0c789e3ff..4526c021969 100644 --- a/src/utilities/common/__tests__/omitDeep.ts +++ b/src/utilities/common/__tests__/omitDeep.ts @@ -69,3 +69,18 @@ test('returns primitives unchanged', () => { expect(omitDeep(undefined, 'ignored')).toBe(undefined); expect(omitDeep(Symbol.for('foo'), 'ignored')).toBe(Symbol.for('foo')); }); + +test('handles circular references', () => { + let b: any; + const a = { omit: 'foo', b }; + b = { a, omit: 'foo' }; + a.b = b; + + const variables = { a, b, omit: 'foo' }; + + const result = omitDeep(variables, 'omit'); + + expect(result).not.toHaveProperty('omit'); + expect(result.a).not.toHaveProperty('omit'); + expect(result.b).not.toHaveProperty('omit'); +}); diff --git a/src/utilities/common/omitDeep.ts b/src/utilities/common/omitDeep.ts index 4f332ef2fd1..9d79b0c88ed 100644 --- a/src/utilities/common/omitDeep.ts +++ b/src/utilities/common/omitDeep.ts @@ -1,20 +1,42 @@ import { DeepOmit } from '../types/DeepOmit'; import { isNonNullObject } from './objects'; -export function omitDeep(obj: T, key: K): DeepOmit { - if (Array.isArray(obj)) { - return obj.map((value) => omitDeep(value, key)) as DeepOmit; +export function omitDeep(value: T, key: K) { + return __omitDeep(value, key); +} + +function __omitDeep( + value: T, + key: K, + known = new Map() +): DeepOmit { + if (known.has(value)) { + return known.get(value); } - if (isNonNullObject(obj)) { - return Object.entries(obj).reduce((memo, [k, value]) => { - if (k === key) { - return memo; + if (Array.isArray(value)) { + const array: any[] = []; + known.set(value, array); + + value.forEach((value, index) => { + array[index] = __omitDeep(value, key, known); + }); + + return array as DeepOmit; + } + + if (isNonNullObject(value)) { + const obj = Object.create(Object.getPrototypeOf(value)); + known.set(value, obj); + + Object.keys(value).forEach((k) => { + if (k !== key) { + obj[k] = __omitDeep(value[k], key, known); } + }); - return { ...memo, [k]: omitDeep(value, key) }; - }, {}) as DeepOmit; + return obj; } - return obj as DeepOmit; + return value as DeepOmit; } From e1e5110628e5e44e4aeaded3646c51d22dbc787d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 23:21:38 -0600 Subject: [PATCH 07/23] Add test to validate __typename is stripped in selectHttpOptionsAndBody --- .../__tests__/selectHttpOptionsAndBody.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/link/http/__tests__/selectHttpOptionsAndBody.ts b/src/link/http/__tests__/selectHttpOptionsAndBody.ts index 3b023eeebcd..3f8bcd8af79 100644 --- a/src/link/http/__tests__/selectHttpOptionsAndBody.ts +++ b/src/link/http/__tests__/selectHttpOptionsAndBody.ts @@ -104,4 +104,25 @@ describe('selectHttpOptionsAndBody', () => { expect(body.query).toBe('query SampleQuery{stub{id}}'); }); + + it('strips __typename from variables', () => { + const operation = createOperation( + {}, + { + query, + variables: { + __typename: 'Test', + nested: { __typename: 'Nested', foo: 'bar' }, + array: [{ __typename: 'Item', baz: 'foo' }] + }, + } + ); + + const { body } = selectHttpOptionsAndBody(operation, {}); + + expect(body.variables).toEqual({ + nested: { foo: 'bar' }, + array: [{ baz: 'foo' }], + }); + }) }); From 02e45ab67c96e8142cae34f1e06ef23b7e06cd35 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 23:22:27 -0600 Subject: [PATCH 08/23] Remove unused isMutation helper --- src/utilities/graphql/__tests__/getFromAST.ts | 43 ------------------- src/utilities/graphql/getFromAST.ts | 11 ----- 2 files changed, 54 deletions(-) diff --git a/src/utilities/graphql/__tests__/getFromAST.ts b/src/utilities/graphql/__tests__/getFromAST.ts index 5173649b904..87b6a96fda3 100644 --- a/src/utilities/graphql/__tests__/getFromAST.ts +++ b/src/utilities/graphql/__tests__/getFromAST.ts @@ -4,7 +4,6 @@ import { FragmentDefinitionNode, OperationDefinitionNode } from 'graphql'; import { checkDocument, - isMutation, getFragmentDefinitions, getQueryDefinition, getDefaultValues, @@ -248,45 +247,3 @@ describe('AST utility functions', () => { }); }); }); - -describe('isMutation', () => { - it('returns true when document is a mutation', () => { - const document = gql` - mutation { - updateSomething - } - `; - - expect(isMutation(document)).toBe(true); - }); - - it('returns false when document is a subscription', () => { - const document = gql` - subscription { - count - } - `; - - expect(isMutation(document)).toBe(false); - }); - - it('returns false when document is a query', () => { - const document = gql` - query { - count - } - `; - - expect(isMutation(document)).toBe(false); - }); - - it('returns false when document is a fragment', () => { - const document = gql` - fragment Frag on Query { - count - } - `; - - expect(isMutation(document)).toBe(false); - }); -}); diff --git a/src/utilities/graphql/getFromAST.ts b/src/utilities/graphql/getFromAST.ts index e5d9b74d4f5..0f04204311a 100644 --- a/src/utilities/graphql/getFromAST.ts +++ b/src/utilities/graphql/getFromAST.ts @@ -2,9 +2,7 @@ import { invariant, InvariantError } from '../globals'; import { DocumentNode, - Kind, OperationDefinitionNode, - OperationTypeNode, FragmentDefinitionNode, ValueNode, } from 'graphql'; @@ -167,12 +165,3 @@ export function getDefaultValues( } return defaultValues; } - -export function isMutation(document: DocumentNode) { - const definition = getMainDefinition(document); - - return ( - definition.kind === Kind.OPERATION_DEFINITION && - definition.operation === OperationTypeNode.MUTATION - ); -} From 5967b3f0dc4d65336ac3a20a57b680e9f9243264 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 23:35:15 -0600 Subject: [PATCH 09/23] Add changeset --- .changeset/cyan-insects-love.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cyan-insects-love.md diff --git a/.changeset/cyan-insects-love.md b/.changeset/cyan-insects-love.md new file mode 100644 index 00000000000..353fdcea66d --- /dev/null +++ b/.changeset/cyan-insects-love.md @@ -0,0 +1,5 @@ +--- +'@apollo/client': patch +--- + +Automatically strips `__typename` fields from `variables` sent to the server. This allows data returned from a query to subsequently be used as an argument to a mutation without the need to strip the `__typename` in user-space. From e62d787284bb9ccfc3f65813ed1855b4445bde0a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 4 Apr 2023 23:35:20 -0600 Subject: [PATCH 10/23] Increase bundlesize --- config/bundlesize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/bundlesize.ts b/config/bundlesize.ts index 051e19e43c6..cfdcff77e9e 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("34.5KB"); +const gzipBundleByteLengthLimit = bytes("34.57KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; From f8f07c3873610218393b5953044957f1089c8ef5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 5 Apr 2023 00:10:51 -0600 Subject: [PATCH 11/23] Strip __typename from variables sent to subscriptions --- src/link/subscriptions/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts index 198948bc3a9..bf15e783255 100644 --- a/src/link/subscriptions/index.ts +++ b/src/link/subscriptions/index.ts @@ -34,6 +34,7 @@ import type { Client } from "graphql-ws"; import { ApolloLink, Operation, FetchResult } from "../core"; import { isNonNullObject, Observable } from "../../utilities"; import { ApolloError } from "../../errors"; +import { stripTypename } from "../../utilities/common/stripTypename"; interface LikeCloseEvent { /** Returns the WebSocket connection close code provided by the server. */ @@ -55,7 +56,11 @@ export class GraphQLWsLink extends ApolloLink { public request(operation: Operation): Observable { return new Observable((observer) => { return this.client.subscribe( - { ...operation, query: print(operation.query) }, + { + ...operation, + query: print(operation.query), + variables: stripTypename(operation.variables) + }, { next: observer.next.bind(observer), complete: observer.complete.bind(observer), From 317ea7aee5e7e3c2f931b584a2c1afc0fbb0ae1c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 5 Apr 2023 11:42:48 -0600 Subject: [PATCH 12/23] Move Primitive type over to DeepOmit --- .prettierignore | 1 - src/utilities/types/DeepOmit.ts | 10 +++++++++- src/utilities/types/Primitive.ts | 8 -------- 3 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 src/utilities/types/Primitive.ts diff --git a/.prettierignore b/.prettierignore index 12816cc855f..28b33ddc4a8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -39,7 +39,6 @@ src/utilities/* !src/utilities/types/ src/utilities/types/* !src/utilities/types/DeepOmit.ts -!src/utilities/types/Primitive.ts !src/utilities/common src/utilities/common/* !src/utilities/common/stripTypename.ts diff --git a/src/utilities/types/DeepOmit.ts b/src/utilities/types/DeepOmit.ts index e660c53dfe4..ec0b7409370 100644 --- a/src/utilities/types/DeepOmit.ts +++ b/src/utilities/types/DeepOmit.ts @@ -1,4 +1,12 @@ -import { Primitive } from './Primitive'; +// DeepOmit primitives include functions and symbols since these are unmodified. +type Primitive = + | string + | Function + | number + | boolean + | Symbol + | undefined + | null; export type DeepOmitArray = { [P in keyof T]: DeepOmit; diff --git a/src/utilities/types/Primitive.ts b/src/utilities/types/Primitive.ts deleted file mode 100644 index 5238e83f55a..00000000000 --- a/src/utilities/types/Primitive.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type Primitive = - | string - | Function - | number - | boolean - | Symbol - | undefined - | null; From 9719d9189590eefc3bbbcdde4b397a0eddebc6e0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 5 Apr 2023 11:47:12 -0600 Subject: [PATCH 13/23] Export omitDeep, stripTypename, and DeepOmit from utilities/index to prevent reaching into another package-scoped bundle. --- src/link/http/selectHttpOptionsAndBody.ts | 2 +- src/link/subscriptions/index.ts | 3 +-- src/utilities/index.ts | 4 ++++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/link/http/selectHttpOptionsAndBody.ts b/src/link/http/selectHttpOptionsAndBody.ts index 4479bec3edb..82aa11dc9ce 100644 --- a/src/link/http/selectHttpOptionsAndBody.ts +++ b/src/link/http/selectHttpOptionsAndBody.ts @@ -1,5 +1,5 @@ import { ASTNode, print } from 'graphql'; -import { stripTypename } from '../../utilities/common/stripTypename'; +import { stripTypename } from '../../utilities'; import { Operation } from '../core'; diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts index bf15e783255..d99d24c939f 100644 --- a/src/link/subscriptions/index.ts +++ b/src/link/subscriptions/index.ts @@ -32,9 +32,8 @@ import { print } from "graphql"; import type { Client } from "graphql-ws"; import { ApolloLink, Operation, FetchResult } from "../core"; -import { isNonNullObject, Observable } from "../../utilities"; +import { isNonNullObject, stripTypename, Observable } from "../../utilities"; import { ApolloError } from "../../errors"; -import { stripTypename } from "../../utilities/common/stripTypename"; interface LikeCloseEvent { /** Returns the WebSocket connection close code provided by the server. */ diff --git a/src/utilities/index.ts b/src/utilities/index.ts index a5ff2844501..709130325ca 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -99,4 +99,8 @@ export * from './common/mergeOptions'; export * from './common/responseIterator'; export * from './common/incrementalResult'; +export { omitDeep } from './common/omitDeep'; +export { stripTypename } from './common/stripTypename'; + export * from './types/IsStrictlyAny'; +export { DeepOmit } from './types/DeepOmit'; From e57ae31be42ffd1d4177c8fdeb2bd48a46128643 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 5 Apr 2023 13:49:08 -0600 Subject: [PATCH 14/23] Update snapshot test with newly exported utils --- src/__tests__/__snapshots__/exports.ts.snap | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 88ac07435c3..7801a9da611 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -417,6 +417,7 @@ Array [ "mergeIncrementalData", "mergeOptions", "offsetLimitPagination", + "omitDeep", "relayStylePagination", "removeArgumentsFromDocument", "removeClientSetsFromDocument", @@ -427,6 +428,7 @@ Array [ "shouldInclude", "storeKeyNameFromField", "stringifyForDisplay", + "stripTypename", "valueToObjectRepresentation", ] `; From 72c7d9873c4ba7313bea4f0063583fba0a955b93 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 5 Apr 2023 16:07:14 -0600 Subject: [PATCH 15/23] Return unmodified subtrees when key is not found --- src/utilities/common/__tests__/omitDeep.ts | 32 ++++++++++++++++++++++ src/utilities/common/omitDeep.ts | 17 ++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/utilities/common/__tests__/omitDeep.ts b/src/utilities/common/__tests__/omitDeep.ts index 4526c021969..7ea61c3019d 100644 --- a/src/utilities/common/__tests__/omitDeep.ts +++ b/src/utilities/common/__tests__/omitDeep.ts @@ -84,3 +84,35 @@ test('handles circular references', () => { expect(result.a).not.toHaveProperty('omit'); expect(result.b).not.toHaveProperty('omit'); }); + +test('returns same object unmodified if key is not found', () => { + const obj = { + a: 'a', + b: 'b', + c: { d: 'd', e: 'e' }, + }; + + const arr = [{ a: 'a', b: 'b', c: 'c' }, { foo: 'bar' }]; + + expect(omitDeep(obj, 'omit')).toBe(obj); + expect(omitDeep(arr, 'omit')).toBe(arr); +}); + +test('returns unmodified subtrees for subtrees that do not contain the key', () => { + const original = { + omit: 'true', + a: 'a', + foo: { bar: 'true' }, + baz: [{ foo: 'bar' }], + omitOne: [{ foo: 'bar', omit: true }, { foo: 'bar' }], + }; + + const result = omitDeep(original, 'omit'); + + expect(result).not.toBe(original); + expect(result.foo).toBe(original.foo); + expect(result.baz).toBe(original.baz); + expect(result.omitOne).not.toBe(original.omitOne); + expect(result.omitOne[0]).not.toBe(original.omitOne[0]); + expect(result.omitOne[1]).toBe(original.omitOne[1]); +}); diff --git a/src/utilities/common/omitDeep.ts b/src/utilities/common/omitDeep.ts index 9d79b0c88ed..ecd995ae8c8 100644 --- a/src/utilities/common/omitDeep.ts +++ b/src/utilities/common/omitDeep.ts @@ -14,15 +14,22 @@ function __omitDeep( return known.get(value); } + let modified = false; + if (Array.isArray(value)) { const array: any[] = []; known.set(value, array); value.forEach((value, index) => { - array[index] = __omitDeep(value, key, known); + const result = __omitDeep(value, key, known); + modified ||= result !== value; + + array[index] = result; }); - return array as DeepOmit; + if (modified) { + return array as DeepOmit; + } } if (isNonNullObject(value)) { @@ -32,10 +39,14 @@ function __omitDeep( Object.keys(value).forEach((k) => { if (k !== key) { obj[k] = __omitDeep(value[k], key, known); + } else { + modified = true; } }); - return obj; + if (modified) { + return obj; + } } return value as DeepOmit; From e0bd4a9cd86819fb7c127d0dd3430d6c89f948f4 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 5 Apr 2023 16:18:46 -0600 Subject: [PATCH 16/23] Update the wording on the changeset to be more clear on when the `__typename` is stripped --- .changeset/cyan-insects-love.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cyan-insects-love.md b/.changeset/cyan-insects-love.md index 353fdcea66d..6250c7d510d 100644 --- a/.changeset/cyan-insects-love.md +++ b/.changeset/cyan-insects-love.md @@ -2,4 +2,4 @@ '@apollo/client': patch --- -Automatically strips `__typename` fields from `variables` sent to the server. This allows data returned from a query to subsequently be used as an argument to a mutation without the need to strip the `__typename` in user-space. +Automatically strips `__typename` fields from `variables` sent to the server when using [`HttpLink`](https://www.apollographql.com/docs/react/api/link/apollo-link-http), [`BatchHttpLink`](https://www.apollographql.com/docs/react/api/link/apollo-link-batch-http), or [`GraphQLWsLink`](https://www.apollographql.com/docs/react/api/link/apollo-link-subscriptions). This allows GraphQL data returned from a query to be used as an argument to a subsequent GraphQL operation without the need to strip the `__typename` in user-space. From f8e71efe4a0436a1546c8761204ef21eb99c4ce8 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 5 Apr 2023 16:21:01 -0600 Subject: [PATCH 17/23] Update bundlesize --- config/bundlesize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/bundlesize.ts b/config/bundlesize.ts index cfdcff77e9e..54a73196a75 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("34.57KB"); +const gzipBundleByteLengthLimit = bytes("34.60KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; From c741fbfd8c16c120aeedf0ad996c47e003e687cc Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 5 Apr 2023 16:50:52 -0600 Subject: [PATCH 18/23] Fix issue with omitDeep when key is not found on first level of object. --- src/utilities/common/__tests__/omitDeep.ts | 1 - src/utilities/common/omitDeep.ts | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/utilities/common/__tests__/omitDeep.ts b/src/utilities/common/__tests__/omitDeep.ts index 7ea61c3019d..51f3f20c535 100644 --- a/src/utilities/common/__tests__/omitDeep.ts +++ b/src/utilities/common/__tests__/omitDeep.ts @@ -100,7 +100,6 @@ test('returns same object unmodified if key is not found', () => { test('returns unmodified subtrees for subtrees that do not contain the key', () => { const original = { - omit: 'true', a: 'a', foo: { bar: 'true' }, baz: [{ foo: 'bar' }], diff --git a/src/utilities/common/omitDeep.ts b/src/utilities/common/omitDeep.ts index ecd995ae8c8..c0e3b694331 100644 --- a/src/utilities/common/omitDeep.ts +++ b/src/utilities/common/omitDeep.ts @@ -30,17 +30,18 @@ function __omitDeep( if (modified) { return array as DeepOmit; } - } - - if (isNonNullObject(value)) { + } else if (isNonNullObject(value)) { const obj = Object.create(Object.getPrototypeOf(value)); known.set(value, obj); Object.keys(value).forEach((k) => { - if (k !== key) { - obj[k] = __omitDeep(value[k], key, known); - } else { + if (k === key) { modified = true; + } else { + const result = __omitDeep(value[k], key, known); + modified ||= result !== value[k]; + + obj[k] = result; } }); From 80f6f2f5b801f0a0dd20907b59fef089fb1fff81 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 12 Apr 2023 16:56:48 -0600 Subject: [PATCH 19/23] Ignore class instances when omitting properties via omitDeep --- src/utilities/common/__tests__/omitDeep.ts | 20 ++++++++++++++++++++ src/utilities/common/objects.ts | 9 +++++++++ src/utilities/common/omitDeep.ts | 4 ++-- src/utilities/types/DeepOmit.ts | 12 ++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/utilities/common/__tests__/omitDeep.ts b/src/utilities/common/__tests__/omitDeep.ts index 51f3f20c535..1eeec248fca 100644 --- a/src/utilities/common/__tests__/omitDeep.ts +++ b/src/utilities/common/__tests__/omitDeep.ts @@ -115,3 +115,23 @@ test('returns unmodified subtrees for subtrees that do not contain the key', () expect(result.omitOne[0]).not.toBe(original.omitOne[0]); expect(result.omitOne[1]).toBe(original.omitOne[1]); }); + +test('only considers plain objects and ignores class instances when omitting properties', () => { + class Thing { + foo = 'bar'; + omit = false; + } + + const thing = new Thing(); + const original = { thing }; + + const result = omitDeep(original, 'omit'); + + expect(result.thing).toBe(thing); + expect(result.thing).toHaveProperty('omit', false); + + const modifiedThing = omitDeep(thing, 'omit'); + + expect(modifiedThing).toBe(thing); + expect(modifiedThing).toHaveProperty('omit', false); +}); diff --git a/src/utilities/common/objects.ts b/src/utilities/common/objects.ts index 51aa2bd3584..09ef580cd90 100644 --- a/src/utilities/common/objects.ts +++ b/src/utilities/common/objects.ts @@ -1,3 +1,12 @@ export function isNonNullObject(obj: any): obj is Record { return obj !== null && typeof obj === 'object'; } + +export function isPlainObject(obj: any): obj is Record { + return ( + obj !== null && + typeof obj === 'object' && + (Object.getPrototypeOf(obj) === Object.prototype || + Object.getPrototypeOf(obj) === null) + ); +} diff --git a/src/utilities/common/omitDeep.ts b/src/utilities/common/omitDeep.ts index c0e3b694331..64d5eb0e04a 100644 --- a/src/utilities/common/omitDeep.ts +++ b/src/utilities/common/omitDeep.ts @@ -1,5 +1,5 @@ import { DeepOmit } from '../types/DeepOmit'; -import { isNonNullObject } from './objects'; +import { isPlainObject } from './objects'; export function omitDeep(value: T, key: K) { return __omitDeep(value, key); @@ -30,7 +30,7 @@ function __omitDeep( if (modified) { return array as DeepOmit; } - } else if (isNonNullObject(value)) { + } else if (isPlainObject(value)) { const obj = Object.create(Object.getPrototypeOf(value)); known.set(value, obj); diff --git a/src/utilities/types/DeepOmit.ts b/src/utilities/types/DeepOmit.ts index ec0b7409370..f1afa18bec9 100644 --- a/src/utilities/types/DeepOmit.ts +++ b/src/utilities/types/DeepOmit.ts @@ -12,6 +12,18 @@ export type DeepOmitArray = { [P in keyof T]: DeepOmit; }; +// Unfortunately there is one major flaw in this type: This will omit properties +// from class instances in the return type even though our omitDeep helper +// ignores class instances, therefore resulting in a type mismatch between +// the return value and the runtime value. +// +// It is not currently possible with TypeScript to distinguish between plain +// objects and class instances. +// https://github.com/microsoft/TypeScript/issues/29063 +// +// This should be fine as of the time of this writing until omitDeep gets +// broader use since this utility is only used to strip __typename from +// `variables`; a case in which class instances are invalid anyways. export type DeepOmit = T extends Primitive ? T : { From 7849aae3ae381ee1e6263e240afb5344bca12157 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 12 Apr 2023 17:03:56 -0600 Subject: [PATCH 20/23] Add isPlainObject export to snapshot test --- src/__tests__/__snapshots__/exports.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 7801a9da611..dbe940fef52 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -404,6 +404,7 @@ Array [ "isNodeResponse", "isNonEmptyArray", "isNonNullObject", + "isPlainObject", "isReadableStream", "isReference", "isStreamableBlob", From bbb31a12328d9fc864f60f31b4b0de61f8e73dcc Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 12 Apr 2023 17:04:21 -0600 Subject: [PATCH 21/23] Increase bundlesize --- config/bundlesize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/bundlesize.ts b/config/bundlesize.ts index 54a73196a75..a66e6b8e699 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("34.60KB"); +const gzipBundleByteLengthLimit = bytes("34.64KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; From cadf7c895eef8c19609b22405d1633be817618e2 Mon Sep 17 00:00:00 2001 From: alessia Date: Thu, 13 Apr 2023 17:25:30 -0400 Subject: [PATCH 22/23] bumps bundlesize to 34.96KB (was 34.82KB) --- config/bundlesize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/bundlesize.ts b/config/bundlesize.ts index 86764e55c02..60957e61d3e 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("34.82KB"); +const gzipBundleByteLengthLimit = bytes("34.96KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; From 32ff5e3e37a70ade731a405bf7c209dfa82f083c Mon Sep 17 00:00:00 2001 From: alessia Date: Thu, 13 Apr 2023 17:31:46 -0400 Subject: [PATCH 23/23] bumps bundlesize to 34.98KB (was 34.96KB) --- config/bundlesize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/bundlesize.ts b/config/bundlesize.ts index 60957e61d3e..8ee6f0b5051 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("34.96KB"); +const gzipBundleByteLengthLimit = bytes("34.98KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength;