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 51f7de64f..0a1810ed0 100644 --- a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx +++ b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx @@ -281,6 +281,124 @@ describe('Tanstack Query React Hooks V5 Test', () => { }); }); + it('optimistic create updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = [{ id: '1', name: 'user1', posts: [] }]; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => + useModelQuery( + 'User', + makeUrl('User', 'findMany'), + { include: { posts: true } }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('Post', 'create')) + .post(/.*/) + .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' } } } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { include: { posts: true } }, + { infinite: false, optimisticUpdate: true } + ) + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ $optimistic: true, id: expect.any(String), title: 'post1', ownerId: '1' }); + }); + }); + + it('optimistic nested create updating query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => useModelQuery('Post', makeUrl('Post', 'findMany'), undefined, { optimisticUpdate: true }), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data: null }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('User', 'POST', makeUrl('User', 'create'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ data: { name: 'user1', posts: { create: { title: 'post1' } } } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }) + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].title).toBe('post1'); + }); + }); + it('optimistic create many', async () => { const { queryClient, wrapper } = createWrapper(); @@ -420,7 +538,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { }); }); - it('optimistic update', async () => { + it('optimistic update simple', async () => { const { queryClient, wrapper } = createWrapper(); const queryArgs = { where: { id: '1' } }; @@ -472,7 +590,121 @@ describe('Tanstack Query React Hooks V5 Test', () => { }); }); - it('optimistic upsert - create', async () => { + it('optimistic update updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' }, include: { posts: true } }; + const data = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => useModelQuery('User', makeUrl('User', 'findUnique'), queryArgs, { optimisticUpdate: true }), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return data; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'PUT', makeUrl('Post', 'update'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => + mutationResult.current.mutate({ + where: { id: 'p1' }, + data: { title: 'post2', owner: { connect: { id: '2' } } }, + }) + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }) + ); + expect(cacheData.posts[0]).toMatchObject({ title: 'post2', $optimistic: true, ownerId: '2' }); + }); + }); + + it('optimistic nested update updating query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: 'p1' } }; + const data = { id: 'p1', title: 'post1' }; + + nock(makeUrl('Post', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => useModelQuery('Post', makeUrl('Post', 'findUnique'), queryArgs, { optimisticUpdate: true }), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ title: 'post1' }); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return data; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('User', 'PUT', makeUrl('User', 'update'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + data: { posts: { update: { where: { id: 'p1' }, data: { title: 'post2' } } } }, + }) + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }) + ); + expect(cacheData).toMatchObject({ title: 'post2', $optimistic: true }); + }); + }); + + it('optimistic upsert - create simple', async () => { const { queryClient, wrapper } = createWrapper(); const data: any[] = []; @@ -526,13 +758,141 @@ describe('Tanstack Query React Hooks V5 Test', () => { getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }) ); expect(cacheData).toHaveLength(1); - expect(cacheData[0].$optimistic).toBe(true); - expect(cacheData[0].id).toBeTruthy(); - expect(cacheData[0].name).toBe('foo'); + expect(cacheData[0]).toMatchObject({ id: '1', name: 'foo', $optimistic: true }); }); }); - it('optimistic upsert - update', async () => { + it('optimistic upsert - create updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; + + nock(makeUrl('User', 'findUnique')) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => + useModelQuery( + 'User', + makeUrl('User', 'findUnique'), + { where: { id: '1' } }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ id: '1' }); + }); + + nock(makeUrl('Post', 'upsert')) + .post(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data: null }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'POST', makeUrl('Post', 'upsert'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => + mutationResult.current.mutate({ + where: { id: 'p2' }, + create: { id: 'p2', title: 'post2', owner: { connect: { id: '1' } } }, + update: { title: 'post3' }, + }) + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }) + ); + const posts = cacheData.posts; + expect(posts).toHaveLength(2); + expect(posts[0]).toMatchObject({ id: 'p2', title: 'post2', ownerId: '1', $optimistic: true }); + }); + }); + + it('optimistic upsert - nested create updating query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = [{ id: 'p1', title: 'post1' }]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => useModelQuery('Post', makeUrl('Post', 'findMany'), undefined, { optimisticUpdate: true }), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'update')) + .post(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data: null }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('User', 'PUT', makeUrl('User', 'update'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + data: { + posts: { + upsert: { + where: { id: 'p2' }, + create: { id: 'p2', title: 'post2', owner: { connect: { id: '1' } } }, + update: { title: 'post3', owner: { connect: { id: '2' } } }, + }, + }, + }, + }) + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }) + ); + expect(cacheData).toHaveLength(2); + expect(cacheData[0]).toMatchObject({ id: 'p2', title: 'post2', ownerId: '1', $optimistic: true }); + }); + }); + + it('optimistic upsert - update simple', async () => { const { queryClient, wrapper } = createWrapper(); const queryArgs = { where: { id: '1' } }; @@ -584,6 +944,136 @@ describe('Tanstack Query React Hooks V5 Test', () => { }); }); + it('optimistic upsert - update updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; + + nock(makeUrl('User', 'findUnique')) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => + useModelQuery( + 'User', + makeUrl('User', 'findUnique'), + { where: { id: '1' } }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ id: '1' }); + }); + + nock(makeUrl('Post', 'upsert')) + .post(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data: null }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'POST', makeUrl('Post', 'upsert'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => + mutationResult.current.mutate({ + where: { id: 'p1' }, + create: { id: 'p1', title: 'post1' }, + update: { title: 'post2' }, + }) + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }) + ); + const posts = cacheData.posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); + }); + }); + + it('optimistic upsert - nested update updating query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = [{ id: 'p1', title: 'post1' }]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => useModelQuery('Post', makeUrl('Post', 'findMany'), undefined, { optimisticUpdate: true }), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'update')) + .post(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data: null }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('User', 'PUT', makeUrl('User', 'update'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + data: { + posts: { + upsert: { + where: { id: 'p1' }, + create: { id: 'p1', title: 'post1' }, + update: { title: 'post2' }, + }, + }, + }, + }) + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }) + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); + }); + }); + it('delete and invalidation', async () => { const { queryClient, wrapper } = createWrapper(); @@ -627,7 +1117,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { }); }); - it('optimistic delete', async () => { + it('optimistic delete simple', async () => { const { queryClient, wrapper } = createWrapper(); const data: any[] = [{ id: '1', name: 'foo' }]; @@ -678,6 +1168,122 @@ describe('Tanstack Query React Hooks V5 Test', () => { }); }); + it('optimistic delete nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; + + nock(makeUrl('User', 'findFirst')) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => + useModelQuery( + 'User', + makeUrl('User', 'findFirst'), + { include: { posts: true } }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ id: '1' }); + }); + + nock(makeUrl('Post', 'delete')) + .delete(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'DELETE', makeUrl('Post', 'delete'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: 'p1' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findFirst', + { include: { posts: true } }, + { infinite: false, optimisticUpdate: true } + ) + ); + expect(cacheData.posts).toHaveLength(0); + }); + }); + + it('optimistic nested delete update query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = [ + { id: 'p1', title: 'post1' }, + { id: 'p2', title: 'post2' }, + ]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result } = renderHook( + () => useModelQuery('Post', makeUrl('Post', 'findMany'), undefined, { optimisticUpdate: true }), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(2); + }); + + nock(makeUrl('User', 'update')) + .delete(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('User', 'PUT', makeUrl('User', 'update'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { delete: { id: 'p1' } } } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }) + ); + expect(cacheData).toHaveLength(1); + }); + }); + it('top-level mutation and nested-read invalidation', async () => { const { queryClient, wrapper } = createWrapper(); diff --git a/packages/plugins/tanstack-query/tests/test-model-meta.ts b/packages/plugins/tanstack-query/tests/test-model-meta.ts index 001d773a9..174abd1bd 100644 --- a/packages/plugins/tanstack-query/tests/test-model-meta.ts +++ b/packages/plugins/tanstack-query/tests/test-model-meta.ts @@ -45,8 +45,15 @@ export const modelMeta: ModelMeta = { isOptional: false, }, title: { ...fieldDefaults, type: 'String', name: 'title' }, - owner: { ...fieldDefaults, type: 'User', name: 'owner', isDataModel: true, isRelationOwner: true }, - ownerId: { ...fieldDefaults, type: 'User', name: 'owner', isForeignKey: true }, + owner: { + ...fieldDefaults, + type: 'User', + name: 'owner', + isDataModel: true, + isRelationOwner: true, + foreignKeyMapping: { id: 'ownerId' }, + }, + ownerId: { ...fieldDefaults, type: 'String', name: 'ownerId', isForeignKey: true }, }, uniqueConstraints: { id: { name: 'id', fields: ['id'] } }, }, diff --git a/packages/runtime/src/cross/mutator.ts b/packages/runtime/src/cross/mutator.ts index e38570399..4ec76cbd1 100644 --- a/packages/runtime/src/cross/mutator.ts +++ b/packages/runtime/src/cross/mutator.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { v4 as uuid } from 'uuid'; import { - ModelDataVisitor, + FieldInfo, NestedWriteVisitor, enumerate, getFields, @@ -34,18 +34,37 @@ export async function applyMutation( modelMeta: ModelMeta, logging: boolean ) { - if (['count', 'aggregate', 'groupBy'].includes(queryOp)) { + if (!queryData || (typeof queryData !== 'object' && !Array.isArray(queryData))) { + return undefined; + } + + if (!queryOp.startsWith('find')) { // only findXXX results are applicable return undefined; } + return await doApplyMutation(queryModel, queryData, mutationModel, mutationOp, mutationArgs, modelMeta, logging); +} + +async function doApplyMutation( + queryModel: string, + queryData: any, + mutationModel: string, + mutationOp: PrismaWriteActionType, + mutationArgs: any, + modelMeta: ModelMeta, + logging: boolean +) { let resultData = queryData; let updated = false; const visitor = new NestedWriteVisitor(modelMeta, { create: (model, args) => { - if (model === queryModel) { - const r = createMutate(queryModel, queryOp, resultData, args, modelMeta, logging); + if ( + model === queryModel && + Array.isArray(resultData) // "create" mutation is only relevant for arrays + ) { + const r = createMutate(queryModel, resultData, args, modelMeta, logging); if (r) { resultData = r; updated = true; @@ -54,9 +73,13 @@ export async function applyMutation( }, createMany: (model, args) => { - if (model === queryModel && args?.data) { + if ( + model === queryModel && + args?.data && + Array.isArray(resultData) // "createMany" mutation is only relevant for arrays + ) { for (const oneArg of enumerate(args.data)) { - const r = createMutate(queryModel, queryOp, resultData, oneArg, modelMeta, logging); + const r = createMutate(queryModel, resultData, oneArg, modelMeta, logging); if (r) { resultData = r; updated = true; @@ -66,7 +89,10 @@ export async function applyMutation( }, update: (model, args) => { - if (model === queryModel) { + if ( + model === queryModel && + !Array.isArray(resultData) // array elements will be handled with recursion + ) { const r = updateMutate(queryModel, resultData, model, args, modelMeta, logging); if (r) { resultData = r; @@ -77,25 +103,10 @@ export async function applyMutation( upsert: (model, args) => { if (model === queryModel && args?.where && args?.create && args?.update) { - // first see if a matching update can be applied - const updateResult = updateMutate( - queryModel, - resultData, - model, - { where: args.where, data: args.update }, - modelMeta, - logging - ); - if (updateResult) { - resultData = updateResult; + const r = upsertMutate(queryModel, resultData, model, args, modelMeta, logging); + if (r) { + resultData = r; updated = true; - } else { - // if not, try to apply a create - const createResult = createMutate(queryModel, queryOp, resultData, args.create, modelMeta, logging); - if (createResult) { - resultData = createResult; - updated = true; - } } } }, @@ -113,25 +124,75 @@ export async function applyMutation( await visitor.visit(mutationModel, mutationOp, mutationArgs); + const modelFields = getFields(modelMeta, queryModel); + + if (Array.isArray(resultData)) { + // try to apply mutation to each item in the array, replicate the entire + // array if any item is updated + + let arrayCloned = false; + for (let i = 0; i < resultData.length; i++) { + const item = resultData[i]; + if ( + !item || + typeof item !== 'object' || + item.$optimistic // skip items already optimistically updated + ) { + continue; + } + + const r = await doApplyMutation( + queryModel, + item, + mutationModel, + mutationOp, + mutationArgs, + modelMeta, + logging + ); + + if (r) { + if (!arrayCloned) { + resultData = [...resultData]; + arrayCloned = true; + } + resultData[i] = r; + updated = true; + } + } + } else { + // iterate over each field and apply mutation to nested data models + for (const [key, value] of Object.entries(resultData)) { + const fieldInfo = modelFields[key]; + if (!fieldInfo?.isDataModel) { + continue; + } + + const r = await doApplyMutation( + fieldInfo.type, + value, + mutationModel, + mutationOp, + mutationArgs, + modelMeta, + logging + ); + + if (r) { + resultData = { ...resultData, [key]: r }; + updated = true; + } + } + } + return updated ? resultData : undefined; } -function createMutate( - queryModel: string, - queryOp: string, - currentData: any, - newData: any, - modelMeta: ModelMeta, - logging: boolean -) { +function createMutate(queryModel: string, currentData: any, newData: any, modelMeta: ModelMeta, logging: boolean) { if (!newData) { return undefined; } - if (queryOp !== 'findMany') { - return undefined; - } - const modelFields = getFields(modelMeta, queryModel); if (!modelFields) { return undefined; @@ -141,12 +202,14 @@ function createMutate( const newDataFields = Object.keys(newData); Object.entries(modelFields).forEach(([name, field]) => { - if (field.isDataModel) { - // only include scalar fields + if (field.isDataModel && newData[name]) { + // deal with "connect" + assignForeignKeyFields(field, insert, newData[name]); return; } + if (newDataFields.includes(name)) { - insert[name] = newData[name]; + insert[name] = clone(newData[name]); } else { const defaultAttr = field.attributes?.find((attr) => attr.name === '@default'); if (field.type === 'DateTime') { @@ -197,35 +260,115 @@ function updateMutate( modelMeta: ModelMeta, logging: boolean ) { - if (!currentData) { + if (!currentData || typeof currentData !== 'object') { + return undefined; + } + + if (!mutateArgs?.where || typeof mutateArgs.where !== 'object') { + return undefined; + } + + if (!mutateArgs?.data || typeof mutateArgs.data !== 'object') { return undefined; } - if (!mutateArgs?.where || !mutateArgs?.data) { + if (!idFieldsMatch(mutateModel, currentData, mutateArgs.where, modelMeta)) { + return undefined; + } + + const modelFields = getFields(modelMeta, queryModel); + if (!modelFields) { return undefined; } let updated = false; + let resultData = currentData; - for (const item of enumerate(currentData)) { - const visitor = new ModelDataVisitor(modelMeta); - visitor.visit(queryModel, item, (model, _data, scalarData) => { - if (model === mutateModel && idFieldsMatch(model, scalarData, mutateArgs.where, modelMeta)) { - Object.keys(item).forEach((k) => { - if (mutateArgs.data[k] !== undefined) { - item[k] = mutateArgs.data[k]; - } - }); - item.$optimistic = true; + for (const [key, value] of Object.entries(mutateArgs.data)) { + const fieldInfo = modelFields[key]; + if (!fieldInfo) { + continue; + } + + if (fieldInfo.isDataModel && !value?.connect) { + // relation field but without "connect" + continue; + } + + if (!updated) { + // clone + resultData = { ...currentData }; + } + + if (fieldInfo.isDataModel) { + // deal with "connect" + assignForeignKeyFields(fieldInfo, resultData, value); + } else { + resultData[key] = clone(value); + } + resultData.$optimistic = true; + updated = true; + + if (logging) { + console.log(`Optimistic update for ${queryModel}:`, resultData); + } + } + + return updated ? resultData : undefined; +} + +function upsertMutate( + queryModel: string, + currentData: any, + model: string, + args: { where: object; create: any; update: any }, + modelMeta: ModelMeta, + logging: boolean +) { + let updated = false; + let resultData = currentData; + + if (Array.isArray(resultData)) { + // check if we should create or update + const foundIndex = resultData.findIndex((x) => idFieldsMatch(model, x, args.where, modelMeta)); + if (foundIndex >= 0) { + const updateResult = updateMutate( + queryModel, + resultData[foundIndex], + model, + { where: args.where, data: args.update }, + modelMeta, + logging + ); + if (updateResult) { + // replace the found item with updated item + resultData = [...resultData.slice(0, foundIndex), updateResult, ...resultData.slice(foundIndex + 1)]; + updated = true; + } + } else { + const createResult = createMutate(queryModel, resultData, args.create, modelMeta, logging); + if (createResult) { + resultData = createResult; updated = true; - if (logging) { - console.log(`Optimistic update for ${queryModel}:`, item); - } } - }); + } + } else { + // try update only + const updateResult = updateMutate( + queryModel, + resultData, + model, + { where: args.where, data: args.update }, + modelMeta, + logging + ); + if (updateResult) { + resultData = updateResult; + updated = true; + } } - return updated ? clone(currentData) /* ensures new object identity */ : undefined; + return updated ? resultData : undefined; } function deleteMutate( @@ -282,3 +425,21 @@ function idFieldsMatch(model: string, x: any, y: any, modelMeta: ModelMeta) { } return idFields.every((f) => x[f.name] === y[f.name]); } + +function assignForeignKeyFields(field: FieldInfo, resultData: any, mutationData: any) { + // convert "connect" like `{ connect: { id: '...' } }` to foreign key fields + // assignment: `{ userId: '...' }` + if (!mutationData?.connect) { + return; + } + + if (!field.foreignKeyMapping) { + return; + } + + for (const [idField, fkField] of Object.entries(field.foreignKeyMapping)) { + if (idField in mutationData.connect) { + resultData[fkField] = mutationData.connect[idField]; + } + } +}