From dc072dd020101b47452ec154b4c53465a03e1bb9 Mon Sep 17 00:00:00 2001 From: Simon Zimmerman Date: Mon, 25 Nov 2024 21:40:59 +0000 Subject: [PATCH 1/4] refactor: add optional one to many relationship to model meta --- .../tanstack-query/tests/test-model-meta.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/plugins/tanstack-query/tests/test-model-meta.ts b/packages/plugins/tanstack-query/tests/test-model-meta.ts index 174abd1bd..1c59b956b 100644 --- a/packages/plugins/tanstack-query/tests/test-model-meta.ts +++ b/packages/plugins/tanstack-query/tests/test-model-meta.ts @@ -54,9 +54,46 @@ export const modelMeta: ModelMeta = { foreignKeyMapping: { id: 'ownerId' }, }, ownerId: { ...fieldDefaults, type: 'String', name: 'ownerId', isForeignKey: true }, + category: { + ...fieldDefaults, + type: 'Category', + name: 'category', + isDataModel: true, + isOptional: true, + isRelationOwner: true, + backLink: 'posts', + foreignKeyMapping: { id: 'categoryId' }, + }, + categoryId: { + ...fieldDefaults, + type: 'String', + name: 'categoryId', + isForeignKey: true, + relationField: 'category', + }, }, uniqueConstraints: { id: { name: 'id', fields: ['id'] } }, }, + category: { + name: 'category', + fields: { + id: { + ...fieldDefaults, + type: 'String', + isId: true, + name: 'id', + isOptional: false, + }, + name: { ...fieldDefaults, type: 'String', name: 'name' }, + posts: { + ...fieldDefaults, + type: 'Post', + isDataModel: true, + isArray: true, + name: 'posts', + }, + }, + }, }, deleteCascade: { user: ['Post'], From 0f0a66f751b377b3acbff7afc0359bd6afcab7e4 Mon Sep 17 00:00:00 2001 From: Simon Zimmerman Date: Mon, 25 Nov 2024 21:44:09 +0000 Subject: [PATCH 2/4] refactor: adds guards for fields with null values --- packages/runtime/src/cross/mutator.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/cross/mutator.ts b/packages/runtime/src/cross/mutator.ts index 4ec76cbd1..af56e6cdd 100644 --- a/packages/runtime/src/cross/mutator.ts +++ b/packages/runtime/src/cross/mutator.ts @@ -151,7 +151,7 @@ async function doApplyMutation( logging ); - if (r) { + if (r && typeof r === 'object') { if (!arrayCloned) { resultData = [...resultData]; arrayCloned = true; @@ -160,9 +160,12 @@ async function doApplyMutation( updated = true; } } - } else { + } else if (resultData !== null && typeof resultData === 'object') { + // Clone resultData to prevent mutations affecting the loop + const currentData = { ...resultData }; + // iterate over each field and apply mutation to nested data models - for (const [key, value] of Object.entries(resultData)) { + for (const [key, value] of Object.entries(currentData)) { const fieldInfo = modelFields[key]; if (!fieldInfo?.isDataModel) { continue; @@ -178,7 +181,7 @@ async function doApplyMutation( logging ); - if (r) { + if (r && typeof r === 'object') { resultData = { ...resultData, [key]: r }; updated = true; } From 9a31bcb511a38c352a7005bcd7f7cc73a9604975 Mon Sep 17 00:00:00 2001 From: Simon Zimmerman Date: Mon, 25 Nov 2024 21:44:41 +0000 Subject: [PATCH 3/4] test: tests for optimistic updates with optional relationships --- .../tests/react-hooks-v5.test.tsx | 348 +++++++++++++++++- 1 file changed, 347 insertions(+), 1 deletion(-) diff --git a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx index 0a1810ed0..7739e67e3 100644 --- a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx +++ b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx @@ -11,6 +11,8 @@ import { RequestHandlerContext, useInfiniteModelQuery, useModelMutation, useMode import { getQueryKey } from '../src/runtime/common'; import { modelMeta } from './test-model-meta'; +const BASE_URL = 'http://localhost'; + describe('Tanstack Query React Hooks V5 Test', () => { function createWrapper() { const queryClient = new QueryClient(); @@ -25,7 +27,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { } function makeUrl(model: string, operation: string, args?: unknown) { - let r = `http://localhost/api/model/${model}/${operation}`; + let r = `${BASE_URL}/api/model/${model}/${operation}`; if (args) { r += `?q=${encodeURIComponent(JSON.stringify(args))}`; } @@ -345,6 +347,350 @@ describe('Tanstack Query React Hooks V5 Test', () => { }); }); + it('optimistic create updating deeply nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + // populate the cache with a user + + const userData: any[] = [{ id: '1', name: 'user1', posts: [] }]; + + nock(BASE_URL) + .get('/api/model/User/findMany') + .query(true) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(userData)); + return { data: userData }; + }) + .persist(); + + const { result: userResult } = renderHook( + () => + useModelQuery( + 'User', + makeUrl('User', 'findMany'), + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(userResult.current.data).toHaveLength(1); + }); + + // pupulate the cache with a category + const categoryData: any[] = [{ id: '1', name: 'category1', posts: [] }]; + + nock(BASE_URL) + .get('/api/model/Category/findMany') + .query(true) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(categoryData)); + return { data: categoryData }; + }) + .persist(); + + const { result: categoryResult } = renderHook( + () => + useModelQuery( + 'Category', + makeUrl('Category', 'findMany'), + { include: { posts: true } }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(categoryResult.current.data).toHaveLength(1); + }); + + // create a post and connect it to the category + nock(BASE_URL) + .post('/api/model/Post/create') + .reply(200, () => { + console.log('Not mutating data'); + return { data: null }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'POST', makeUrl('Post', 'create'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => + mutationResult.current.mutate({ + data: { title: 'post1', owner: { connect: { id: '1' } }, category: { connect: { id: '1' } } }, + }) + ); + + // assert that the post was created and connected to the category + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'Category', + 'findMany', + { + include: { + posts: true, + }, + }, + { infinite: false, optimisticUpdate: true } + ) + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + console.log('category.posts', posts[0]); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'post1', + ownerId: '1', + }); + }); + + // assert that the post was created and connected to the user, and included the category + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { infinite: false, optimisticUpdate: true } + ) + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + console.log('user.posts', posts[0]); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'post1', + ownerId: '1', + categoryId: '1', + // TODO: should this include the category object and not just the foreign key? + // category: { $optimistic: true, id: '1', name: 'category1' }, + }); + }); + }); + + it('optimistic update with optional one-to-many relationship', async () => { + const { queryClient, wrapper } = createWrapper(); + + // populate the cache with a post, with an optional category relatonship + const postData: any = { + id: '1', + title: 'post1', + ownerId: '1', + categoryId: null, + category: null, + }; + + const data: any[] = [postData]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .query(true) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result: postResult } = renderHook( + () => + useModelQuery( + 'Post', + makeUrl('Post', 'findMany'), + { + include: { + category: true, + }, + }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(postResult.current.data).toHaveLength(1); + }); + + // mock a put request to update the post title + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => { + console.log('Mutating data'); + postData.title = 'postA'; + return { data: postData }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'PUT', makeUrl('Post', 'update'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); + + // assert that the post was updated despite the optional (null) category relationship + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'Post', + 'findMany', + { + include: { + category: true, + }, + }, + { infinite: false, optimisticUpdate: true } + ) + ); + const posts = cacheData; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'postA', + ownerId: '1', + categoryId: null, + category: null, + }); + }); + }); + + it('optimistic update with nested optional one-to-many relationship', async () => { + const { queryClient, wrapper } = createWrapper(); + + // populate the cache with a user and a post, with an optional category + const postData: any = { + id: '1', + title: 'post1', + ownerId: '1', + categoryId: null, + category: null, + }; + + const userData: any[] = [{ id: '1', name: 'user1', posts: [postData] }]; + + nock(BASE_URL) + .get('/api/model/User/findMany') + .query(true) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(userData)); + return { data: userData }; + }) + .persist(); + + const { result: userResult } = renderHook( + () => + useModelQuery( + 'User', + makeUrl('User', 'findMany'), + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(userResult.current.data).toHaveLength(1); + }); + + // mock a put request to update the post title + nock(BASE_URL) + .put('/api/model/Post/update') + .reply(200, () => { + console.log('Mutating data'); + postData.title = 'postA'; + return { data: postData }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'PUT', makeUrl('Post', 'update'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); + + // assert that the post was updated + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { infinite: false, optimisticUpdate: true } + ) + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + console.log('user.posts', posts[0]); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'postA', + ownerId: '1', + categoryId: null, + category: null, + }); + }); + }); + it('optimistic nested create updating query', async () => { const { queryClient, wrapper } = createWrapper(); From 4e0827161466f561e78105caa69f804c71d6e0e2 Mon Sep 17 00:00:00 2001 From: Simon Zimmerman Date: Tue, 26 Nov 2024 06:40:13 +0000 Subject: [PATCH 4/4] refactor: addresses some coderabbitai review, nitpick comments --- packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx | 4 ++-- packages/plugins/tanstack-query/tests/test-model-meta.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx index 7739e67e3..3559f4528 100644 --- a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx +++ b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx @@ -387,7 +387,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { expect(userResult.current.data).toHaveLength(1); }); - // pupulate the cache with a category + // populate the cache with a category const categoryData: any[] = [{ id: '1', name: 'category1', posts: [] }]; nock(BASE_URL) @@ -501,7 +501,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { it('optimistic update with optional one-to-many relationship', async () => { const { queryClient, wrapper } = createWrapper(); - // populate the cache with a post, with an optional category relatonship + // populate the cache with a post, with an optional category relationship const postData: any = { id: '1', title: 'post1', diff --git a/packages/plugins/tanstack-query/tests/test-model-meta.ts b/packages/plugins/tanstack-query/tests/test-model-meta.ts index 1c59b956b..6e08ce2ca 100644 --- a/packages/plugins/tanstack-query/tests/test-model-meta.ts +++ b/packages/plugins/tanstack-query/tests/test-model-meta.ts @@ -59,7 +59,6 @@ export const modelMeta: ModelMeta = { type: 'Category', name: 'category', isDataModel: true, - isOptional: true, isRelationOwner: true, backLink: 'posts', foreignKeyMapping: { id: 'categoryId' },