diff --git a/packages/plugins/tanstack-query/src/generator.ts b/packages/plugins/tanstack-query/src/generator.ts index e290824b8..bc67452e6 100644 --- a/packages/plugins/tanstack-query/src/generator.ts +++ b/packages/plugins/tanstack-query/src/generator.ts @@ -78,14 +78,23 @@ function generateQueryHook( overrideReturnType?: string, overrideInputType?: string, overrideTypeParameters?: string[], - infinite = false + infinite = false, + optimisticUpdate = false ) { const capOperation = upperCaseFirst(operation); const argsType = overrideInputType ?? `Prisma.${model}${capOperation}Args`; const inputType = `Prisma.SelectSubset`; - const returnType = - overrideReturnType ?? (returnArray ? `Array>` : `Prisma.${model}GetPayload`); + + let defaultReturnType = `Prisma.${model}GetPayload`; + if (optimisticUpdate) { + defaultReturnType += '& { $optimistic?: boolean }'; + } + if (returnArray) { + defaultReturnType = `Array<${defaultReturnType}>`; + } + + const returnType = overrideReturnType ?? defaultReturnType; const optionsType = makeQueryOptions(target, returnType, infinite, version); const func = sf.addFunction({ @@ -100,6 +109,15 @@ function generateQueryHook( name: 'options?', type: optionsType, }, + ...(optimisticUpdate + ? [ + { + name: 'optimisticUpdate', + type: 'boolean', + initializer: 'true', + }, + ] + : []), ], isExported: true, }); @@ -113,7 +131,7 @@ function generateQueryHook( makeGetContext(target), `return ${infinite ? 'useInfiniteModelQuery' : 'useModelQuery'}('${model}', \`\${endpoint}/${lowerCaseFirst( model - )}/${operation}\`, args, options, fetch);`, + )}/${operation}\`, args, options, fetch${optimisticUpdate ? ', optimisticUpdate' : ''});`, ]); } @@ -154,6 +172,11 @@ function generateMutationHook( type: 'boolean', initializer: 'true', }, + { + name: 'optimisticUpdate', + type: 'boolean', + initializer: 'false', + }, ], }); @@ -170,7 +193,7 @@ function generateMutationHook( overrideReturnType ?? model }, ${checkReadBack}>('${model}', '${httpVerb.toUpperCase()}', \`\${endpoint}/${lowerCaseFirst( model - )}/${operation}\`, metadata, options, fetch, invalidateQueries, ${checkReadBack}) + )}/${operation}\`, metadata, options, fetch, invalidateQueries, ${checkReadBack}, optimisticUpdate) `, }, ], @@ -272,8 +295,6 @@ function generateModelHooks( // findMany if (mapping.findMany) { // regular findMany - generateQueryHook(target, version, sf, model.name, 'findMany', true, true); - // infinite findMany generateQueryHook( target, version, @@ -285,18 +306,60 @@ function generateModelHooks( undefined, undefined, undefined, + false, true ); + // infinite findMany + generateQueryHook( + target, + version, + sf, + model.name, + 'findMany', + true, + true, + undefined, + undefined, + undefined, + true, + false + ); } // findUnique if (mapping.findUnique) { - generateQueryHook(target, version, sf, model.name, 'findUnique', false, false); + generateQueryHook( + target, + version, + sf, + model.name, + 'findUnique', + false, + false, + undefined, + undefined, + undefined, + false, + true + ); } // findFirst if (mapping.findFirst) { - generateQueryHook(target, version, sf, model.name, 'findFirst', false, true); + generateQueryHook( + target, + version, + sf, + model.name, + 'findFirst', + false, + true, + undefined, + undefined, + undefined, + false, + true + ); } // update diff --git a/packages/plugins/tanstack-query/src/runtime-v5/react.ts b/packages/plugins/tanstack-query/src/runtime-v5/react.ts index e23acc8cb..12e6a2f03 100644 --- a/packages/plugins/tanstack-query/src/runtime-v5/react.ts +++ b/packages/plugins/tanstack-query/src/runtime-v5/react.ts @@ -19,6 +19,7 @@ import { makeUrl, marshal, setupInvalidation, + setupOptimisticUpdate, type APIContext, } from '../runtime/common'; @@ -50,6 +51,8 @@ export const Provider = RequestHandlerContext.Provider; * @param url The request URL. * @param args The request args object, URL-encoded and appended as "?q=" parameter * @param options The react-query options object + * @param fetch The fetch function to use for sending the HTTP request + * @param optimisticUpdate Whether to enable automatic optimistic update * @returns useQuery hook */ export function useModelQuery( @@ -57,11 +60,12 @@ export function useModelQuery( url: string, args?: unknown, options?: Omit, 'queryKey'>, - fetch?: FetchFn + fetch?: FetchFn, + optimisticUpdate = false ) { const reqUrl = makeUrl(url, args); return useQuery({ - queryKey: getQueryKey(model, url, args), + queryKey: getQueryKey(model, url, args, false, optimisticUpdate), queryFn: () => fetcher(reqUrl, undefined, fetch, false), ...options, }); @@ -74,6 +78,7 @@ export function useModelQuery( * @param url The request URL. * @param args The initial request args object, URL-encoded and appended as "?q=" parameter * @param options The react-query infinite query options object + * @param fetch The fetch function to use for sending the HTTP request * @returns useInfiniteQuery hook */ export function useInfiniteModelQuery( @@ -84,7 +89,7 @@ export function useInfiniteModelQuery( fetch?: FetchFn ) { return useInfiniteQuery({ - queryKey: getQueryKey(model, url, args), + queryKey: getQueryKey(model, url, args, true), queryFn: ({ pageParam }) => { return fetcher(makeUrl(url, pageParam ?? args), undefined, fetch, false); }, @@ -92,6 +97,19 @@ export function useInfiniteModelQuery( }); } +/** + * Creates a react-query mutation + * + * @param model The name of the model under mutation. + * @param method The HTTP method. + * @param url The request URL. + * @param modelMeta The model metadata. + * @param options The react-query options. + * @param fetch The fetch function to use for sending the HTTP request + * @param invalidateQueries Whether to invalidate queries after mutation. + * @param checkReadBack Whether to check for read back errors and return undefined if found. + * @param optimisticUpdate Whether to enable automatic optimistic update + */ export function useModelMutation( model: string, method: 'POST' | 'PUT' | 'DELETE', @@ -100,7 +118,8 @@ export function useModelMutation, 'mutationFn'>, fetch?: FetchFn, invalidateQueries = true, - checkReadBack?: C + checkReadBack?: C, + optimisticUpdate = false ) { const queryClient = useQueryClient(); const mutationFn = (data: any) => { @@ -118,10 +137,11 @@ export function useModelMutation queryClient.setQueryData(queryKey, data), + invalidateQueries ? (predicate) => queryClient.invalidateQueries({ predicate }) : undefined, + logging + ); + } } return useMutation(finalOptions); diff --git a/packages/plugins/tanstack-query/src/runtime-v5/svelte.ts b/packages/plugins/tanstack-query/src/runtime-v5/svelte.ts index ec658accf..706c6ef8f 100644 --- a/packages/plugins/tanstack-query/src/runtime-v5/svelte.ts +++ b/packages/plugins/tanstack-query/src/runtime-v5/svelte.ts @@ -22,6 +22,7 @@ import { makeUrl, marshal, setupInvalidation, + setupOptimisticUpdate, } from '../runtime/common'; export { APIContext as RequestHandlerContext } from '../runtime/common'; @@ -53,6 +54,8 @@ export function getHooksContext() { * @param url The request URL. * @param args The request args object, URL-encoded and appended as "?q=" parameter * @param options The svelte-query options object + * @param fetch The fetch function to use for sending the HTTP request + * @param optimisticUpdate Whether to enable automatic optimistic update * @returns useQuery hook */ export function useModelQuery( @@ -60,10 +63,11 @@ export function useModelQuery( url: string, args?: unknown, options?: StoreOrVal, 'queryKey'>>, - fetch?: FetchFn + fetch?: FetchFn, + optimisticUpdate = false ) { const reqUrl = makeUrl(url, args); - const queryKey = getQueryKey(model, url, args); + const queryKey = getQueryKey(model, url, args, false, optimisticUpdate); const queryFn = () => fetcher(reqUrl, undefined, fetch, false); let mergedOpt: any; @@ -103,7 +107,7 @@ export function useInfiniteModelQuery( options: StoreOrVal>, 'queryKey'>>, fetch?: FetchFn ) { - const queryKey = getQueryKey(model, url, args); + const queryKey = getQueryKey(model, url, args, true); const queryFn = ({ pageParam }: { pageParam: unknown }) => fetcher(makeUrl(url, pageParam ?? args), undefined, fetch, false); @@ -151,7 +155,8 @@ export function useModelMutation, 'mutationFn'>, fetch?: FetchFn, invalidateQueries = true, - checkReadBack?: C + checkReadBack?: C, + optimisticUpdate = false ) { const queryClient = useQueryClient(); const mutationFn = (data: any) => { @@ -169,10 +174,11 @@ export function useModelMutation(SvelteQueryContextKey); - const operation = url.split('/').pop(); - if (operation) { + if (invalidateQueries) { setupInvalidation( model, operation, @@ -182,6 +188,19 @@ export function useModelMutation queryClient.setQueryData(queryKey, data), + invalidateQueries ? (predicate) => queryClient.invalidateQueries({ predicate }) : undefined, + logging + ); + } } return createMutation(finalOptions); diff --git a/packages/plugins/tanstack-query/src/runtime/common.ts b/packages/plugins/tanstack-query/src/runtime/common.ts index 46e38eae0..7c1a4171c 100644 --- a/packages/plugins/tanstack-query/src/runtime/common.ts +++ b/packages/plugins/tanstack-query/src/runtime/common.ts @@ -1,7 +1,13 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { deserialize, serialize } from '@zenstackhq/runtime/browser'; -import { getMutatedModels, getReadModels, type ModelMeta, type PrismaWriteActionType } from '@zenstackhq/runtime/cross'; +import { + applyMutation, + getMutatedModels, + getReadModels, + type ModelMeta, + type PrismaWriteActionType, +} from '@zenstackhq/runtime/cross'; import * as crossFetch from 'cross-fetch'; /** @@ -75,22 +81,39 @@ export async function fetcher( } } -type QueryKey = [string /* prefix */, string /* model */, string /* operation */, unknown /* args */]; +type QueryKey = [ + string /* prefix */, + string /* model */, + string /* operation */, + unknown /* args */, + { + infinite: boolean; + optimisticUpdate: boolean; + } /* flags */ +]; /** * Computes query key for the given model, operation and query args. * @param model Model name. * @param urlOrOperation Prisma operation (e.g, `findMany`) or request URL. If it's a URL, the last path segment will be used as the operation name. * @param args Prisma query arguments. + * @param infinite Whether the query is infinite. + * @param optimisticUpdate Whether the query is optimistically updated. * @returns Query key */ -export function getQueryKey(model: string, urlOrOperation: string, args: unknown): QueryKey { +export function getQueryKey( + model: string, + urlOrOperation: string, + args: unknown, + infinite = false, + optimisticUpdate = false +): QueryKey { if (!urlOrOperation) { throw new Error('Invalid urlOrOperation'); } const operation = urlOrOperation.split('/').pop(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return [QUERY_KEY_PREFIX, model, operation!, args]; + return [QUERY_KEY_PREFIX, model, operation!, args, { infinite, optimisticUpdate }]; } export function marshal(value: unknown) { @@ -126,14 +149,20 @@ export function makeUrl(url: string, args: unknown) { } type InvalidationPredicate = ({ queryKey }: { queryKey: readonly unknown[] }) => boolean; +type InvalidateFunc = (predicate: InvalidationPredicate) => Promise; +type MutationOptions = { + onMutate?: (...args: any[]) => any; + onSuccess?: (...args: any[]) => any; + onSettled?: (...args: any[]) => any; +}; // sets up invalidation hook for a mutation export function setupInvalidation( model: string, operation: string, modelMeta: ModelMeta, - options: { onSuccess?: (...args: any[]) => any }, - invalidate: (predicate: InvalidationPredicate) => Promise, + options: MutationOptions, + invalidate: InvalidateFunc, logging = false ) { const origOnSuccess = options?.onSuccess; @@ -162,12 +191,12 @@ async function getInvalidationPredicate( const mutatedModels = await getMutatedModels(model, operation, mutationArgs, modelMeta); return ({ queryKey }: { queryKey: readonly unknown[] }) => { - const [_model, queryModel, queryOp, args] = queryKey as QueryKey; + const [_, queryModel, , args] = queryKey as QueryKey; if (mutatedModels.includes(queryModel)) { // direct match if (logging) { - console.log(`Invalidating query [${queryKey}] due to mutation "${model}.${operation}"`); + console.log(`Invalidating query ${JSON.stringify(queryKey)} due to mutation "${model}.${operation}"`); } return true; } @@ -176,7 +205,9 @@ async function getInvalidationPredicate( // traverse query args to find nested reads that match the model under mutation if (findNestedRead(queryModel, mutatedModels, modelMeta, args)) { if (logging) { - console.log(`Invalidating query [${queryKey}] due to mutation "${model}.${operation}"`); + console.log( + `Invalidating query ${JSON.stringify(queryKey)} due to mutation "${model}.${operation}"` + ); } return true; } @@ -191,3 +222,106 @@ function findNestedRead(visitingModel: string, targetModels: string[], modelMeta const modelsRead = getReadModels(visitingModel, modelMeta, args); return targetModels.some((m) => modelsRead.includes(m)); } + +type QueryCache = { + queryKey: readonly unknown[]; + state: { + data: unknown; + error: unknown; + }; +}[]; + +type SetCacheFunc = (queryKey: readonly unknown[], data: unknown) => void; + +export function setupOptimisticUpdate( + model: string, + operation: string, + modelMeta: ModelMeta, + options: MutationOptions, + queryCache: QueryCache, + setCache: SetCacheFunc, + invalidate?: InvalidateFunc, + logging = false +) { + const origOnMutate = options?.onMutate; + const origOnSettled = options?.onSettled; + + options.onMutate = async (...args: unknown[]) => { + const [variables] = args; + await optimisticUpdate( + model, + operation as PrismaWriteActionType, + variables, + modelMeta, + queryCache, + setCache, + logging + ); + return origOnMutate?.(...args); + }; + + options.onSettled = async (...args: unknown[]) => { + if (invalidate) { + const [, , variables] = args; + const predicate = await getInvalidationPredicate( + model, + operation as PrismaWriteActionType, + variables, + modelMeta, + logging + ); + await invalidate(predicate); + } + return origOnSettled?.(...args); + }; +} + +// optimistically updates query cache +async function optimisticUpdate( + mutationModel: string, + mutationOp: string, + mutationArgs: any, + modelMeta: ModelMeta, + queryCache: QueryCache, + setCache: SetCacheFunc, + logging = false +) { + for (const cacheItem of queryCache) { + const { + queryKey, + state: { data, error }, + } = cacheItem; + + if (error) { + continue; + } + + const [_, queryModel, queryOp, _queryArgs, { optimisticUpdate }] = queryKey as QueryKey; + if (!optimisticUpdate) { + continue; + } + + const mutatedData = await applyMutation( + queryModel, + queryOp, + data, + mutationModel, + mutationOp as PrismaWriteActionType, + mutationArgs, + modelMeta, + logging + ); + + if (mutatedData !== undefined) { + // mutation applicable to this query, update cache + if (logging) { + console.log( + `Optimistically updating query ${JSON.stringify( + queryKey + )} due to mutation "${mutationModel}.${mutationOp}"` + ); + } + setCache(queryKey, mutatedData); + } + } +} diff --git a/packages/plugins/tanstack-query/src/runtime/react.ts b/packages/plugins/tanstack-query/src/runtime/react.ts index ce5a6bd07..617302a3c 100644 --- a/packages/plugins/tanstack-query/src/runtime/react.ts +++ b/packages/plugins/tanstack-query/src/runtime/react.ts @@ -18,6 +18,7 @@ import { makeUrl, marshal, setupInvalidation, + setupOptimisticUpdate, type APIContext, } from './common'; @@ -50,6 +51,8 @@ export function getHooksContext() { * @param url The request URL. * @param args The request args object, URL-encoded and appended as "?q=" parameter * @param options The react-query options object + * @param fetch The fetch function to use for sending the HTTP request + * @param optimisticUpdate Whether to enable automatic optimistic update * @returns useQuery hook */ export function useModelQuery( @@ -57,11 +60,12 @@ export function useModelQuery( url: string, args?: unknown, options?: Omit, 'queryKey'>, - fetch?: FetchFn + fetch?: FetchFn, + optimisticUpdate = false ) { const reqUrl = makeUrl(url, args); return useQuery({ - queryKey: getQueryKey(model, url, args), + queryKey: getQueryKey(model, url, args, false, optimisticUpdate), queryFn: () => fetcher(reqUrl, undefined, fetch, false), ...options, }); @@ -74,6 +78,7 @@ export function useModelQuery( * @param url The request URL. * @param args The initial request args object, URL-encoded and appended as "?q=" parameter * @param options The react-query infinite query options object + * @param fetch The fetch function to use for sending the HTTP request * @returns useInfiniteQuery hook */ export function useInfiniteModelQuery( @@ -84,7 +89,7 @@ export function useInfiniteModelQuery( fetch?: FetchFn ) { return useInfiniteQuery({ - queryKey: getQueryKey(model, url, args), + queryKey: getQueryKey(model, url, args, true), queryFn: ({ pageParam }) => { return fetcher(makeUrl(url, pageParam ?? args), undefined, fetch, false); }, @@ -101,6 +106,8 @@ export function useInfiniteModelQuery( * @param url The request URL. * @param options The react-query options. * @param invalidateQueries Whether to invalidate queries after mutation. + * @param checkReadBack Whether to check for read back errors and return undefined if found. + * @param optimisticUpdate Whether to enable automatic optimistic update * @returns useMutation hooks */ export function useModelMutation( @@ -111,7 +118,8 @@ export function useModelMutation, 'mutationFn'>, fetch?: FetchFn, invalidateQueries = true, - checkReadBack?: C + checkReadBack?: C, + optimisticUpdate = false ) { const queryClient = useQueryClient(); const mutationFn = (data: any) => { @@ -129,10 +137,10 @@ export function useModelMutation queryClient.setQueryData(queryKey, data), + invalidateQueries ? (predicate) => queryClient.invalidateQueries({ predicate }) : undefined, + logging + ); + } } return useMutation(finalOptions); diff --git a/packages/plugins/tanstack-query/src/runtime/svelte.ts b/packages/plugins/tanstack-query/src/runtime/svelte.ts index 8cf6880cc..d87ca304c 100644 --- a/packages/plugins/tanstack-query/src/runtime/svelte.ts +++ b/packages/plugins/tanstack-query/src/runtime/svelte.ts @@ -14,11 +14,12 @@ import { APIContext, DEFAULT_QUERY_ENDPOINT, FetchFn, - QUERY_KEY_PREFIX, fetcher, + getQueryKey, makeUrl, marshal, setupInvalidation, + setupOptimisticUpdate, } from './common'; export { APIContext as RequestHandlerContext } from './common'; @@ -50,6 +51,8 @@ export function getHooksContext() { * @param url The request URL. * @param args The request args object, URL-encoded and appended as "?q=" parameter * @param options The svelte-query options object + * @param fetch The fetch function to use for sending the HTTP request + * @param optimisticUpdate Whether to enable automatic optimistic update * @returns useQuery hook */ export function useModelQuery( @@ -57,11 +60,12 @@ export function useModelQuery( url: string, args?: unknown, options?: Omit, 'queryKey'>, - fetch?: FetchFn + fetch?: FetchFn, + optimisticUpdate = false ) { const reqUrl = makeUrl(url, args); return createQuery({ - queryKey: [QUERY_KEY_PREFIX + model, url, args], + queryKey: getQueryKey(model, url, args, false, optimisticUpdate), queryFn: () => fetcher(reqUrl, undefined, fetch, false), ...options, }); @@ -74,6 +78,7 @@ export function useModelQuery( * @param url The request URL. * @param args The initial request args object, URL-encoded and appended as "?q=" parameter * @param options The svelte-query infinite query options object + * @param fetch The fetch function to use for sending the HTTP request * @returns useQuery hook */ export function useInfiniteModelQuery( @@ -84,7 +89,7 @@ export function useInfiniteModelQuery( fetch?: FetchFn ) { return createInfiniteQuery({ - queryKey: [QUERY_KEY_PREFIX + model, url, args], + queryKey: getQueryKey(model, url, args, true), queryFn: ({ pageParam }) => fetcher(makeUrl(url, pageParam ?? args), undefined, fetch, false), ...options, }); @@ -99,6 +104,8 @@ export function useInfiniteModelQuery( * @param url The request URL. * @param options The svelte-query options. * @param invalidateQueries Whether to invalidate queries after mutation. + * @param checkReadBack Whether to check for read back errors and return undefined if found. + * @param optimisticUpdate Whether to enable automatic optimistic update. * @returns useMutation hooks */ export function useModelMutation( @@ -109,7 +116,8 @@ export function useModelMutation, 'mutationFn'>, fetch?: FetchFn, invalidateQueries = true, - checkReadBack?: C + checkReadBack?: C, + optimisticUpdate = false ) { const queryClient = useQueryClient(); const mutationFn = (data: any) => { @@ -127,10 +135,11 @@ export function useModelMutation(SvelteQueryContextKey); - const operation = url.split('/').pop(); - if (operation) { + + if (invalidateQueries) { setupInvalidation( model, operation, @@ -140,6 +149,19 @@ export function useModelMutation queryClient.setQueryData(queryKey, data), + invalidateQueries ? (predicate) => queryClient.invalidateQueries({ predicate }) : undefined, + logging + ); + } } return createMutation(finalOptions); diff --git a/packages/plugins/tanstack-query/src/runtime/vue.ts b/packages/plugins/tanstack-query/src/runtime/vue.ts index 9899e297c..fe6cfc6a2 100644 --- a/packages/plugins/tanstack-query/src/runtime/vue.ts +++ b/packages/plugins/tanstack-query/src/runtime/vue.ts @@ -20,6 +20,7 @@ import { makeUrl, marshal, setupInvalidation, + setupOptimisticUpdate, } from './common'; export { APIContext as RequestHandlerContext } from './common'; @@ -52,6 +53,8 @@ export function getHooksContext() { * @param url The request URL. * @param args The request args object, URL-encoded and appended as "?q=" parameter * @param options The vue-query options object + * @param fetch The fetch function to use for sending the HTTP request + * @param optimisticUpdate Whether to enable automatic optimistic update * @returns useQuery hook */ export function useModelQuery( @@ -59,11 +62,12 @@ export function useModelQuery( url: string, args?: unknown, options?: UseQueryOptions, - fetch?: FetchFn + fetch?: FetchFn, + optimisticUpdate = false ) { const reqUrl = makeUrl(url, args); return useQuery({ - queryKey: getQueryKey(model, url, args), + queryKey: getQueryKey(model, url, args, false, optimisticUpdate), queryFn: () => fetcher(reqUrl, undefined, fetch, false), ...options, }); @@ -76,6 +80,7 @@ export function useModelQuery( * @param url The request URL. * @param args The initial request args object, URL-encoded and appended as "?q=" parameter * @param options The vue-query infinite query options object + * @param fetch The fetch function to use for sending the HTTP request * @returns useInfiniteQuery hook */ export function useInfiniteModelQuery( @@ -86,7 +91,7 @@ export function useInfiniteModelQuery( fetch?: FetchFn ) { return useInfiniteQuery({ - queryKey: getQueryKey(model, url, args), + queryKey: getQueryKey(model, url, args, true), queryFn: ({ pageParam }) => { return fetcher(makeUrl(url, pageParam ?? args), undefined, fetch, false); }, @@ -102,7 +107,10 @@ export function useInfiniteModelQuery( * @param modelMeta The model metadata. * @param url The request URL. * @param options The vue-query options. + * @param fetch The fetch function to use for sending the HTTP request * @param invalidateQueries Whether to invalidate queries after mutation. + * @param checkReadBack Whether to check for read back errors and return undefined if found. + * @param optimisticUpdate Whether to enable automatic optimistic update * @returns useMutation hooks */ export function useModelMutation( @@ -113,7 +121,8 @@ export function useModelMutation, 'mutationFn'>, fetch?: FetchFn, invalidateQueries = true, - checkReadBack?: C + checkReadBack?: C, + optimisticUpdate = false ) { const queryClient = useQueryClient(); const mutationFn = (data: any) => { @@ -132,10 +141,10 @@ export function useModelMutation queryClient.setQueryData(queryKey, data), + invalidateQueries ? (predicate) => queryClient.invalidateQueries({ predicate }) : undefined, + logging + ); + } } return useMutation(finalOptions); } 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 eb54c824e..c285a01f8 100644 --- a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx +++ b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx @@ -7,11 +7,11 @@ /// import { QueryClient, QueryClientProvider } from '@tanstack/react-query-v5'; -import { renderHook, waitFor, act } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import nock from 'nock'; import React from 'react'; -import { QUERY_KEY_PREFIX } from '../src/runtime/common'; import { RequestHandlerContext, useModelMutation, useModelQuery } from '../src/runtime-v5/react'; +import { getQueryKey } from '../src/runtime/common'; import { modelMeta } from './test-model-meta'; describe('Tanstack Query React Hooks V5 Test', () => { @@ -55,7 +55,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { await waitFor(() => { expect(result.current.isSuccess).toBe(true); expect(result.current.data).toMatchObject(data); - const cacheData = queryClient.getQueryData([QUERY_KEY_PREFIX, 'User', 'findUnique', queryArgs]); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); expect(cacheData).toMatchObject(data); }); }); @@ -143,11 +143,126 @@ describe('Tanstack Query React Hooks V5 Test', () => { act(() => mutationResult.current.mutate({ data: { name: 'foo' } })); await waitFor(() => { - const cacheData = queryClient.getQueryData([QUERY_KEY_PREFIX, 'User', 'findMany', undefined]); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); expect(cacheData).toHaveLength(1); }); }); + it('optimistic create single', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + 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'), undefined, undefined, undefined, 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, + undefined, + undefined, + false, + undefined, + true + ), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ data: { name: 'foo' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined, false, true)); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].name).toBe('foo'); + }); + }); + + it('optimistic create many', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + 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'), undefined, undefined, undefined, true), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'createMany')) + .post(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data: null }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation( + 'User', + 'POST', + makeUrl('User', 'createMany'), + modelMeta, + undefined, + undefined, + false, + undefined, + true + ), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ data: [{ name: 'foo' }, { name: 'bar' }] })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined, false, true)); + expect(cacheData).toHaveLength(2); + }); + }); + it('update and invalidation', async () => { const { queryClient, wrapper } = createWrapper(); @@ -187,11 +302,167 @@ describe('Tanstack Query React Hooks V5 Test', () => { act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); await waitFor(() => { - const cacheData = queryClient.getQueryData([QUERY_KEY_PREFIX, 'User', 'findUnique', queryArgs]); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); expect(cacheData).toMatchObject({ name: 'bar' }); }); }); + it('optimistic update', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + 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, undefined, undefined, true), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + 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, + undefined, + undefined, + false, + undefined, + true + ), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs, false, true)); + expect(cacheData).toMatchObject({ name: 'bar', $optimistic: true }); + }); + }); + + it('delete and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = [{ id: '1', name: 'foo' }]; + + 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')), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'delete')) + .delete(/.*/) + .reply(200, () => { + console.log('Mutating data'); + data.splice(0, 1); + return { data: [] }; + }); + + const { result: mutationResult } = renderHook( + () => useModelMutation('User', 'DELETE', makeUrl('User', 'delete'), modelMeta), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(0); + }); + }); + + it('optimistic delete', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = [{ id: '1', name: 'foo' }]; + + 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'), undefined, undefined, undefined, true), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'delete')) + .delete(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation( + 'User', + 'DELETE', + makeUrl('User', 'delete'), + modelMeta, + undefined, + undefined, + false, + undefined, + true + ), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined, false, true)); + expect(cacheData).toHaveLength(0); + }); + }); + it('top-level mutation and nested-read invalidation', async () => { const { queryClient, wrapper } = createWrapper(); @@ -231,7 +502,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { name: 'post2' } })); await waitFor(() => { - const cacheData: any = queryClient.getQueryData([QUERY_KEY_PREFIX, 'User', 'findUnique', queryArgs]); + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); expect(cacheData.posts[0].title).toBe('post2'); }); }); @@ -276,7 +547,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { ); await waitFor(() => { - const cacheData: any = queryClient.getQueryData([QUERY_KEY_PREFIX, 'Post', 'findMany', undefined]); + const cacheData: any = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); expect(cacheData).toHaveLength(2); }); }); diff --git a/packages/plugins/tanstack-query/tests/react-hooks.test.tsx b/packages/plugins/tanstack-query/tests/react-hooks.test.tsx index b06a5d825..a14f8bd06 100644 --- a/packages/plugins/tanstack-query/tests/react-hooks.test.tsx +++ b/packages/plugins/tanstack-query/tests/react-hooks.test.tsx @@ -7,10 +7,10 @@ /// import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor, act } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import nock from 'nock'; import React from 'react'; -import { QUERY_KEY_PREFIX } from '../src/runtime/common'; +import { getQueryKey } from '../src/runtime/common'; import { RequestHandlerContext, useModelMutation, useModelQuery } from '../src/runtime/react'; import { modelMeta } from './test-model-meta'; @@ -55,7 +55,7 @@ describe('Tanstack Query React Hooks Test', () => { await waitFor(() => { expect(result.current.isSuccess).toBe(true); expect(result.current.data).toMatchObject(data); - const cacheData = queryClient.getQueryData([QUERY_KEY_PREFIX, 'User', 'findUnique', queryArgs]); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); expect(cacheData).toMatchObject(data); }); }); @@ -143,11 +143,126 @@ describe('Tanstack Query React Hooks Test', () => { act(() => mutationResult.current.mutate({ data: { name: 'foo' } })); await waitFor(() => { - const cacheData = queryClient.getQueryData([QUERY_KEY_PREFIX, 'User', 'findMany', undefined]); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); expect(cacheData).toHaveLength(1); }); }); + it('optimistic create single', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + 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'), undefined, undefined, undefined, 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, + undefined, + undefined, + false, + undefined, + true + ), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ data: { name: 'foo' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined, false, true)); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].name).toBe('foo'); + }); + }); + + it('optimistic create many', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + 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'), undefined, undefined, undefined, true), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'createMany')) + .post(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data: null }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation( + 'User', + 'POST', + makeUrl('User', 'createMany'), + modelMeta, + undefined, + undefined, + false, + undefined, + true + ), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ data: [{ name: 'foo' }, { name: 'bar' }] })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined, false, true)); + expect(cacheData).toHaveLength(2); + }); + }); + it('update and invalidation', async () => { const { queryClient, wrapper } = createWrapper(); @@ -187,11 +302,167 @@ describe('Tanstack Query React Hooks Test', () => { act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); await waitFor(() => { - const cacheData = queryClient.getQueryData([QUERY_KEY_PREFIX, 'User', 'findUnique', queryArgs]); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); expect(cacheData).toMatchObject({ name: 'bar' }); }); }); + it('optimistic update', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + 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, undefined, undefined, true), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + 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, + undefined, + undefined, + false, + undefined, + true + ), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs, false, true)); + expect(cacheData).toMatchObject({ name: 'bar', $optimistic: true }); + }); + }); + + it('delete and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = [{ id: '1', name: 'foo' }]; + + 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')), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'delete')) + .delete(/.*/) + .reply(200, () => { + console.log('Mutating data'); + data.splice(0, 1); + return { data: [] }; + }); + + const { result: mutationResult } = renderHook( + () => useModelMutation('User', 'DELETE', makeUrl('User', 'delete'), modelMeta), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(0); + }); + }); + + it('optimistic delete', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = [{ id: '1', name: 'foo' }]; + + 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'), undefined, undefined, undefined, true), + { + wrapper, + } + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'delete')) + .delete(/.*/) + .reply(200, () => { + console.log('Not mutating data'); + return { data }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation( + 'User', + 'DELETE', + makeUrl('User', 'delete'), + modelMeta, + undefined, + undefined, + false, + undefined, + true + ), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined, false, true)); + expect(cacheData).toHaveLength(0); + }); + }); + it('top-level mutation and nested-read invalidation', async () => { const { queryClient, wrapper } = createWrapper(); @@ -231,7 +502,7 @@ describe('Tanstack Query React Hooks Test', () => { act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { name: 'post2' } })); await waitFor(() => { - const cacheData: any = queryClient.getQueryData([QUERY_KEY_PREFIX, 'User', 'findUnique', queryArgs]); + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); expect(cacheData.posts[0].title).toBe('post2'); }); }); @@ -275,7 +546,7 @@ describe('Tanstack Query React Hooks Test', () => { act(() => mutationResult.current.mutate({ data: { name: 'post2' } })); await waitFor(() => { - const cacheData: any = queryClient.getQueryData([QUERY_KEY_PREFIX, 'User', 'findUnique', queryArgs]); + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); expect(cacheData._count.posts).toBe(2); }); }); @@ -320,7 +591,7 @@ describe('Tanstack Query React Hooks Test', () => { ); await waitFor(() => { - const cacheData: any = queryClient.getQueryData([QUERY_KEY_PREFIX, 'Post', 'findMany', undefined]); + const cacheData: any = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); expect(cacheData).toHaveLength(2); }); }); @@ -363,7 +634,7 @@ describe('Tanstack Query React Hooks Test', () => { act(() => mutationResult.current.mutate({ where: { id: '1' } })); await waitFor(() => { - const cacheData = queryClient.getQueryData([QUERY_KEY_PREFIX, 'Post', 'findMany', undefined]); + const cacheData = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); expect(cacheData).toHaveLength(0); }); }); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 9fac34b2a..9e17b855a 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -66,6 +66,7 @@ "superjson": "^1.11.0", "tslib": "^2.4.1", "upper-case-first": "^2.0.2", + "uuid": "^9.0.0", "zod": "^3.22.4", "zod-validation-error": "^1.5.0" }, @@ -80,6 +81,7 @@ "@types/node": "^18.0.0", "@types/pluralize": "^0.0.29", "@types/semver": "^7.3.13", + "@types/uuid": "^8.3.4", "copyfiles": "^2.4.1", "rimraf": "^3.0.2", "typescript": "^4.9.3" diff --git a/packages/runtime/src/cross/index.ts b/packages/runtime/src/cross/index.ts index ea5f7456f..853f8bc7d 100644 --- a/packages/runtime/src/cross/index.ts +++ b/packages/runtime/src/cross/index.ts @@ -1,4 +1,6 @@ +export * from './model-data-visitor'; export * from './model-meta'; +export * from './mutator'; export * from './nested-read-visitor'; export * from './nested-write-visitor'; export * from './query-analyzer'; diff --git a/packages/runtime/src/enhancements/model-data-visitor.ts b/packages/runtime/src/cross/model-data-visitor.ts similarity index 95% rename from packages/runtime/src/enhancements/model-data-visitor.ts rename to packages/runtime/src/cross/model-data-visitor.ts index 78c40b75c..543932521 100644 --- a/packages/runtime/src/enhancements/model-data-visitor.ts +++ b/packages/runtime/src/cross/model-data-visitor.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { resolveField, type ModelMeta } from '../cross'; + +import { resolveField, type ModelMeta } from '.'; /** * Callback for @see ModelDataVisitor. diff --git a/packages/runtime/src/cross/model-meta.ts b/packages/runtime/src/cross/model-meta.ts index 9b4e706bc..37636de5a 100644 --- a/packages/runtime/src/cross/model-meta.ts +++ b/packages/runtime/src/cross/model-meta.ts @@ -25,27 +25,27 @@ export type FieldInfo = { /** * If the field is an ID field or part of a multi-field ID */ - isId: boolean; + isId?: boolean; /** * If the field type is a data model (or an optional/array of data model) */ - isDataModel: boolean; + isDataModel?: boolean; /** * If the field is an array */ - isArray: boolean; + isArray?: boolean; /** * If the field is optional */ - isOptional: boolean; + isOptional?: boolean; /** * Attributes on the field */ - attributes: RuntimeAttribute[]; + attributes?: RuntimeAttribute[]; /** * If the field is a relation field, the field name of the reverse side of the relation @@ -55,12 +55,12 @@ export type FieldInfo = { /** * If the field is the owner side of a relation */ - isRelationOwner: boolean; + isRelationOwner?: boolean; /** * If the field is a foreign key field */ - isForeignKey: boolean; + isForeignKey?: boolean; /** * Mapping from foreign key field names to relation field names diff --git a/packages/runtime/src/cross/mutator.ts b/packages/runtime/src/cross/mutator.ts new file mode 100644 index 000000000..53bd70f8c --- /dev/null +++ b/packages/runtime/src/cross/mutator.ts @@ -0,0 +1,253 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { v4 as uuid } from 'uuid'; +import deepcopy from 'deepcopy'; +import { + ModelDataVisitor, + NestedWriteVisitor, + enumerate, + getFields, + getIdFields, + type ModelMeta, + type PrismaWriteActionType, +} from '.'; + +/** + * Tries to apply a mutation to a query result. + * + * @param queryModel the model of the query + * @param queryOp the operation of the query + * @param queryData the result data of the query + * @param mutationModel the model of the mutation + * @param mutationOp the operation of the mutation + * @param mutationArgs the arguments of the mutation + * @param modelMeta the model metadata + * @param logging whether to log the mutation application + * @returns the updated query data if the mutation is applicable, otherwise undefined + */ +export async function applyMutation( + queryModel: string, + queryOp: string, + queryData: any, + mutationModel: string, + mutationOp: PrismaWriteActionType, + mutationArgs: any, + modelMeta: ModelMeta, + logging: boolean +) { + if (['count', 'aggregate', 'groupBy'].includes(queryOp)) { + // only findXXX results are applicable + return undefined; + } + + 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 (r) { + resultData = r; + updated = true; + } + } + }, + + createMany: (model, args) => { + if (model === queryModel && args?.data) { + for (const oneArg of enumerate(args.data)) { + const r = createMutate(queryModel, queryOp, resultData, oneArg, modelMeta, logging); + if (r) { + resultData = r; + updated = true; + } + } + } + }, + + update: (model, args) => { + const r = updateMutate(queryModel, resultData, model, args, modelMeta, logging); + if (r) { + resultData = r; + updated = true; + } + }, + + delete: (model, args) => { + const r = deleteMutate(queryModel, resultData, model, args, modelMeta, logging); + if (r) { + resultData = r; + updated = true; + } + }, + }); + + await visitor.visit(mutationModel, mutationOp, mutationArgs); + + return updated ? resultData : undefined; +} + +function createMutate( + queryModel: string, + queryOp: 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; + } + + const insert: any = {}; + const newDataFields = Object.keys(newData); + + Object.entries(modelFields).forEach(([name, field]) => { + if (field.isDataModel) { + // only include scalar fields + return; + } + if (newDataFields.includes(name)) { + insert[name] = newData[name]; + } else { + const defaultAttr = field.attributes?.find((attr) => attr.name === '@default'); + if (field.type === 'DateTime') { + // default value for DateTime field + if (defaultAttr || field.attributes?.some((attr) => attr.name === '@updatedAt')) { + insert[name] = new Date(); + } + } else if (defaultAttr?.args?.[0]?.value !== undefined) { + // other default value + insert[name] = defaultAttr.args[0].value; + } + } + }); + + // add temp id value + const idFields = getIdFields(modelMeta, queryModel, false); + idFields.forEach((f) => { + if (insert[f.name] === undefined) { + if (f.type === 'Int' || f.type === 'BigInt') { + const currMax = Math.max( + ...[...currentData].map((item) => { + const idv = parseInt(item[f.name]); + return isNaN(idv) ? 0 : idv; + }) + ); + insert[f.name] = currMax + 1; + } else { + insert[f.name] = uuid(); + } + } + }); + + insert.$optimistic = true; + + if (logging) { + console.log(`Optimistic create for ${queryModel}:`, insert); + } + return [insert, ...currentData]; +} + +function updateMutate( + queryModel: string, + currentData: any, + mutateModel: string, + mutateArgs: any, + modelMeta: ModelMeta, + logging: boolean +) { + if (!currentData) { + return undefined; + } + + if (!mutateArgs?.where || !mutateArgs?.data) { + return undefined; + } + + let updated = false; + + 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; + updated = true; + if (logging) { + console.log(`Optimistic update for ${queryModel}:`, item); + } + } + }); + } + + return updated ? deepcopy(currentData) /* ensures new object identity */ : undefined; +} + +function deleteMutate( + queryModel: string, + currentData: any, + mutateModel: string, + mutateArgs: any, + modelMeta: ModelMeta, + logging: boolean +) { + // TODO: handle mutation of nested reads? + + if (!currentData || !mutateArgs) { + return undefined; + } + + if (queryModel !== mutateModel) { + return undefined; + } + + let updated = false; + let result = currentData; + + if (Array.isArray(currentData)) { + for (const item of currentData) { + if (idFieldsMatch(mutateModel, item, mutateArgs, modelMeta)) { + result = (result as unknown[]).filter((x) => x !== item); + updated = true; + if (logging) { + console.log(`Optimistic delete for ${queryModel}:`, item); + } + } + } + } else { + if (idFieldsMatch(mutateModel, currentData, mutateArgs, modelMeta)) { + result = null; + updated = true; + if (logging) { + console.log(`Optimistic delete for ${queryModel}:`, currentData); + } + } + } + + return updated ? result : undefined; +} + +function idFieldsMatch(model: string, x: any, y: any, modelMeta: ModelMeta) { + if (!x || !y || typeof x !== 'object' || typeof y !== 'object') { + return false; + } + const idFields = getIdFields(modelMeta, model, false); + if (idFields.length === 0) { + return false; + } + return idFields.every((f) => x[f.name] === y[f.name]); +} diff --git a/packages/runtime/src/cross/utils.ts b/packages/runtime/src/cross/utils.ts index 6fc23812b..e4237dbc7 100644 --- a/packages/runtime/src/cross/utils.ts +++ b/packages/runtime/src/cross/utils.ts @@ -1,3 +1,6 @@ +import { lowerCaseFirst } from 'lower-case-first'; +import { ModelMeta } from '.'; + /** * Gets field names in a data model entity, filtering out internal fields. */ @@ -42,3 +45,19 @@ export function zip(x: Enumerable, y: Enumerable): Array<[T1, T2 return [[x, y]]; } } + +export function getIdFields(modelMeta: ModelMeta, model: string, throwIfNotFound = false) { + let fields = modelMeta.fields[lowerCaseFirst(model)]; + if (!fields) { + if (throwIfNotFound) { + throw new Error(`Unable to load fields for ${model}`); + } else { + fields = {}; + } + } + const result = Object.values(fields).filter((f) => f.isId); + if (result.length === 0 && throwIfNotFound) { + throw new Error(`model ${model} does not have an id field`); + } + return result; +} diff --git a/packages/runtime/src/enhancements/omit.ts b/packages/runtime/src/enhancements/omit.ts index 654be80fc..e8b3f8c98 100644 --- a/packages/runtime/src/enhancements/omit.ts +++ b/packages/runtime/src/enhancements/omit.ts @@ -52,7 +52,7 @@ class OmitHandler extends DefaultPrismaProxyHandler { continue; } - if (fieldInfo.attributes.find((attr) => attr.name === '@omit')) { + if (fieldInfo.attributes?.find((attr) => attr.name === '@omit')) { delete entityData[field]; } else if (fieldInfo.isDataModel) { // recurse diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index 5593c9c77..7a81d5286 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -5,18 +5,19 @@ import { upperCaseFirst } from 'upper-case-first'; import { fromZodError } from 'zod-validation-error'; import { CrudFailureReason, PRISMA_TX_FLAG } from '../../constants'; import { + ModelDataVisitor, NestedWriteVisitor, NestedWriteVisitorContext, enumerate, + getIdFields, resolveField, type FieldInfo, type ModelMeta, } from '../../cross'; import { AuthUser, DbClientContract, DbOperations, PolicyOperationKind } from '../../types'; -import { ModelDataVisitor } from '../model-data-visitor'; import { PrismaProxyHandler } from '../proxy'; import type { PolicyDef, ZodSchemas } from '../types'; -import { formatObject, getIdFields, prismaClientValidationError } from '../utils'; +import { formatObject, prismaClientValidationError } from '../utils'; import { Logger } from './logger'; import { PolicyUtil } from './policy-utils'; import { createDeferredPromise } from './promise'; diff --git a/packages/runtime/src/enhancements/policy/index.ts b/packages/runtime/src/enhancements/policy/index.ts index 8b05241dd..68d528644 100644 --- a/packages/runtime/src/enhancements/policy/index.ts +++ b/packages/runtime/src/enhancements/policy/index.ts @@ -3,13 +3,12 @@ import semver from 'semver'; import { PRISMA_MINIMUM_VERSION } from '../../constants'; -import { ModelMeta } from '../../cross'; +import { getIdFields, type ModelMeta } from '../../cross'; import { getDefaultModelMeta, getDefaultPolicy, getDefaultZodSchemas } from '../../loader'; import { AuthUser, DbClientContract } from '../../types'; import { hasAllFields } from '../../validation'; import { makeProxy } from '../proxy'; import type { CommonEnhancementOptions, PolicyDef, ZodSchemas } from '../types'; -import { getIdFields } from '../utils'; import { PolicyProxyHandler } from './handler'; /** diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index ccfb3af69..876845016 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -17,6 +17,7 @@ import { import { enumerate, getFields, + getIdFields, getModelFields, resolveField, zip, @@ -29,7 +30,6 @@ import { getVersion } from '../../version'; import type { InputCheckFunc, PolicyDef, ReadFieldCheckFunc, ZodSchemas } from '../types'; import { formatObject, - getIdFields, prismaClientKnownRequestError, prismaClientUnknownRequestError, prismaClientValidationError, diff --git a/packages/runtime/src/enhancements/utils.ts b/packages/runtime/src/enhancements/utils.ts index ea939f873..73b4d42a0 100644 --- a/packages/runtime/src/enhancements/utils.ts +++ b/packages/runtime/src/enhancements/utils.ts @@ -1,30 +1,9 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -import { lowerCaseFirst } from 'lower-case-first'; import path from 'path'; import * as util from 'util'; -import type { ModelMeta } from '../cross'; import type { DbClientContract } from '../types'; -/** - * Gets id fields for the given model. - */ -export function getIdFields(modelMeta: ModelMeta, model: string, throwIfNotFound = false) { - let fields = modelMeta.fields[lowerCaseFirst(model)]; - if (!fields) { - if (throwIfNotFound) { - throw new Error(`Unable to load fields for ${model}`); - } else { - fields = {}; - } - } - const result = Object.values(fields).filter((f) => f.isId); - if (result.length === 0 && throwIfNotFound) { - throw new Error(`model ${model} does not have an id field`); - } - return result; -} - /** * Formats an object for pretty printing. */ diff --git a/packages/sdk/src/model-meta-generator.ts b/packages/sdk/src/model-meta-generator.ts index a9e7e9780..41a0ea0c9 100644 --- a/packages/sdk/src/model-meta-generator.ts +++ b/packages/sdk/src/model-meta-generator.ts @@ -71,16 +71,66 @@ function generateModelMetadata(dataModels: DataModel[], writer: CodeBlockWriter, ? f.type.reference.$refText : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion f.type.type! - }", - isId: ${isIdField(f)}, - isDataModel: ${isDataModel(f.type.reference?.ref)}, - isArray: ${f.type.array}, - isOptional: ${f.type.optional}, - attributes: ${options.generateAttributes ? JSON.stringify(getFieldAttributes(f)) : '[]'}, - backLink: ${backlink ? "'" + backlink.name + "'" : 'undefined'}, - isRelationOwner: ${isRelationOwner(f, backlink)}, - isForeignKey: ${isForeignKeyField(f)}, - foreignKeyMapping: ${fkMapping ? JSON.stringify(fkMapping) : 'undefined'} + }",`); + + if (isIdField(f)) { + writer.write(` + isId: true,`); + } + + if (isDataModel(f.type.reference?.ref)) { + writer.write(` + isDataModel: true,`); + } + + if (f.type.array) { + writer.write(` + isArray: true,`); + } + + if (f.type.optional) { + writer.write(` + isOptional: true,`); + } + + if (options.generateAttributes) { + const attrs = getFieldAttributes(f); + if (attrs.length > 0) { + writer.write(` + attributes: ${JSON.stringify(attrs)},`); + } + } else { + // only include essential attributes + const attrs = getFieldAttributes(f).filter((attr) => + ['@default', '@updatedAt'].includes(attr.name) + ); + if (attrs.length > 0) { + writer.write(` + attributes: ${JSON.stringify(attrs)},`); + } + } + + if (backlink) { + writer.write(` + backLink: '${backlink.name}',`); + } + + if (isRelationOwner(f, backlink)) { + writer.write(` + isRelationOwner: true,`); + } + + if (isForeignKeyField(f)) { + writer.write(` + isForeignKey: true,`); + } + + if (fkMapping && Object.keys(fkMapping).length > 0) { + writer.write(` + foreignKeyMapping: ${JSON.stringify(fkMapping)},`); + } + + writer.write(` },`); } }); @@ -172,8 +222,7 @@ function getFieldAttributes(field: DataModelField): RuntimeAttribute[] { } else if (isStringLiteral(arg.value) || isBooleanLiteral(arg.value)) { args.push({ name: arg.name, value: arg.value.value }); } else { - // attributes with non-literal args are skipped - return undefined; + // non-literal args are ignored } } return { name: resolved(attr.decl).name, args }; diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index b85d86bcf..88b463c80 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -998,8 +998,8 @@ class RequestHandler extends APIHandlerBase { type: fieldInfo.type, idField: fieldTypeIdFields[0].name, idFieldType: fieldTypeIdFields[0].type, - isCollection: fieldInfo.isArray, - isOptional: fieldInfo.isOptional, + isCollection: !!fieldInfo.isArray, + isOptional: !!fieldInfo.isOptional, }; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff22a3292..9d0fddcc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,7 +124,7 @@ importers: version: 0.2.1 ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.23.2)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) + version: 29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) typescript: specifier: ^4.9.5 version: 4.9.5 @@ -444,6 +444,9 @@ importers: upper-case-first: specifier: ^2.0.2 version: 2.0.2 + uuid: + specifier: ^9.0.0 + version: 9.0.0 zod: specifier: ^3.22.4 version: 3.22.4 @@ -463,6 +466,9 @@ importers: '@types/semver': specifier: ^7.3.13 version: 7.5.0 + '@types/uuid': + specifier: ^8.3.4 + version: 8.3.4 copyfiles: specifier: ^2.4.1 version: 2.4.1 @@ -650,7 +656,7 @@ importers: version: 0.2.1 ts-jest: specifier: ^29.0.3 - version: 29.0.3(@babel/core@7.22.5)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4) + version: 29.0.3(@babel/core@7.23.2)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4) ts-node: specifier: ^10.9.1 version: 10.9.1(@types/node@18.0.0)(typescript@4.8.4) @@ -14816,7 +14822,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.3(@babel/core@7.22.5)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4): + /ts-jest@29.0.3(@babel/core@7.23.2)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4): resolution: {integrity: sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -14837,7 +14843,7 @@ packages: esbuild: optional: true dependencies: - '@babel/core': 7.22.5 + '@babel/core': 7.23.2 bs-logger: 0.2.6 esbuild: 0.15.12 fast-json-stable-stringify: 2.1.0 @@ -14851,7 +14857,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.23.2)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4): + /ts-jest@29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -14872,7 +14878,7 @@ packages: esbuild: optional: true dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.22.5 bs-logger: 0.2.6 esbuild: 0.18.13 fast-json-stable-stringify: 2.1.0 @@ -14882,11 +14888,11 @@ packages: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.3 - typescript: 4.9.4 + typescript: 4.9.5 yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.23.2)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): + /ts-jest@29.0.5(@babel/core@7.23.2)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4): resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -14917,7 +14923,7 @@ packages: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.3 - typescript: 4.9.5 + typescript: 4.9.4 yargs-parser: 21.1.1 dev: true