diff --git a/.changeset/rotten-pillows-eat.md b/.changeset/rotten-pillows-eat.md new file mode 100644 index 00000000..2e766d43 --- /dev/null +++ b/.changeset/rotten-pillows-eat.md @@ -0,0 +1,7 @@ +--- +"@supabase-cache-helpers/postgrest-react-query": minor +"@supabase-cache-helpers/postgrest-core": minor +"@supabase-cache-helpers/postgrest-swr": minor +--- + +feat: add delete many mutation diff --git a/packages/postgrest-core/__tests__/delete-fetcher.spec.ts b/packages/postgrest-core/__tests__/delete-fetcher.spec.ts index ce2b9c27..84be5bb3 100644 --- a/packages/postgrest-core/__tests__/delete-fetcher.spec.ts +++ b/packages/postgrest-core/__tests__/delete-fetcher.spec.ts @@ -22,7 +22,7 @@ describe('delete', () => { await expect( buildDeleteFetcher(client.from('contact'), ['id'], { queriesForTable: () => [], - })({}), + })([{ username: 'test' }]), ).rejects.toThrowError('Missing value for primary key id'); }); @@ -40,9 +40,11 @@ describe('delete', () => { { queriesForTable: () => [], }, - )({ - id: contact?.id, - }); + )([ + { + id: contact?.id, + }, + ]); expect(deletedContact).toEqual(null); const { data } = await client .from('contact') @@ -67,12 +69,16 @@ describe('delete', () => { { queriesForTable: () => [{ paths: [], filters: [] }], }, - )({ - id: contact?.id, - }); - expect(deletedContact).toEqual({ - normalizedData: { id: contact?.id }, - }); + )([ + { + id: contact?.id, + }, + ]); + expect(deletedContact).toEqual([ + { + normalizedData: { id: contact?.id }, + }, + ]); }); it('should apply query if provided', async () => { @@ -86,12 +92,51 @@ describe('delete', () => { const result = await buildDeleteFetcher(client.from('contact'), ['id'], { query: 'ticket_number', queriesForTable: () => [], - })({ - id: contact?.id, - }); - expect(result).toEqual({ - normalizedData: { id: contact?.id }, - userQueryData: { ticket_number: 1234 }, - }); + })([ + { + id: contact?.id, + }, + ]); + expect(result).toEqual([ + { + normalizedData: { id: contact?.id }, + userQueryData: { id: contact?.id, ticket_number: 1234 }, + }, + ]); + }); + + it('should delete multiple entities by primary keys', async () => { + const { data: contacts } = await client + .from('contact') + .insert([ + { username: `${testRunPrefix}-test-1` }, + { username: `${testRunPrefix}-test-2` }, + ]) + .select('id') + .throwOnError(); + + expect(contacts).toBeDefined(); + expect(contacts!.length).toEqual(2); + + const deletedContact = await buildDeleteFetcher( + client.from('contact'), + ['id'], + { + queriesForTable: () => [], + }, + )((contacts ?? []).map((c) => ({ id: c.id }))); + + expect(deletedContact).toEqual(null); + + const { data } = await client + .from('contact') + .select('*') + .in( + 'id', + (contacts ?? []).map((c) => c.id), + ) + .throwOnError(); + + expect(data).toEqual([]); }); }); diff --git a/packages/postgrest-core/src/delete-fetcher.ts b/packages/postgrest-core/src/delete-fetcher.ts index 7ecf2c82..09bf2a45 100644 --- a/packages/postgrest-core/src/delete-fetcher.ts +++ b/packages/postgrest-core/src/delete-fetcher.ts @@ -7,10 +7,11 @@ import { import { MutationFetcherResponse } from './fetch/build-mutation-fetcher-response'; import { BuildNormalizedQueryOps } from './fetch/build-normalized-query'; +import { parseSelectParam } from './lib/parse-select-param'; export type DeleteFetcher = ( - input: Partial, -) => Promise | null>; + input: Partial[], +) => Promise[] | null>; export type DeleteFetcherOptions< S extends GenericSchema, @@ -32,36 +33,76 @@ export const buildDeleteFetcher = opts: BuildNormalizedQueryOps & DeleteFetcherOptions, ): DeleteFetcher => async ( - input: Partial, - ): Promise | null> => { + input: Partial[], + ): Promise[] | null> => { let filterBuilder = qb.delete(opts); - for (const key of primaryKeys) { - const value = input[key]; - if (!value) - throw new Error(`Missing value for primary key ${String(key)}`); - filterBuilder = filterBuilder.eq(key as string, value); + + if (primaryKeys.length === 1) { + const primaryKey = primaryKeys[0]; + filterBuilder.in( + primaryKey as string, + input.map((i) => { + const v = i[primaryKey]; + if (!v) { + throw new Error( + `Missing value for primary key ${primaryKey as string}`, + ); + } + return v; + }), + ); + } else { + filterBuilder = filterBuilder.or( + input + .map( + (i) => + `and(${primaryKeys.map((c) => { + const v = i[c]; + if (!v) { + throw new Error( + `Missing value for primary key ${c as string}`, + ); + } + return `${c as string}.eq.${v}`; + })})`, + ) + .join(','), + ); } - const primaryKeysData = primaryKeys.reduce((prev, key) => { - return { - ...prev, - [key]: input[key], - }; - }, {} as R); + + const primaryKeysData = input.map((i) => + primaryKeys.reduce((prev, key) => { + return { + ...prev, + [key]: i[key], + }; + }, {} as R), + ); + if (!opts.disabled && opts.query) { + // make sure query returns the primary keys + const paths = parseSelectParam(opts.query); + const addKeys = primaryKeys.filter( + (key) => !paths.find((p) => p.path === key), + ); const { data } = await filterBuilder - .select(opts.query) - .throwOnError() - .single(); - return { + .select([opts.query, ...addKeys].join(',')) + .throwOnError(); + return primaryKeysData.map>((pk) => ({ // since we are deleting, only the primary keys are required - normalizedData: primaryKeysData, - userQueryData: data as R, - }; + normalizedData: pk, + userQueryData: ((data as R[]) ?? []).find((d) => + primaryKeys.every((k) => d[k as keyof R] === pk[k as keyof R]), + ), + })); } - await filterBuilder.throwOnError().single(); + + await filterBuilder.throwOnError(); + if (opts.queriesForTable().length > 0) { // if there is at least one query on the table we are deleting from, return primary keys - return { normalizedData: primaryKeysData }; + return primaryKeysData.map((pk) => ({ normalizedData: pk })); } + return null; }; diff --git a/packages/postgrest-react-query/__tests__/mutate/use-delete-many-mutation.integration.spec.tsx b/packages/postgrest-react-query/__tests__/mutate/use-delete-many-mutation.integration.spec.tsx new file mode 100644 index 00000000..e6f5d851 --- /dev/null +++ b/packages/postgrest-react-query/__tests__/mutate/use-delete-many-mutation.integration.spec.tsx @@ -0,0 +1,133 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, screen } from '@testing-library/react'; +import React, { useState } from 'react'; + +import { useDeleteManyMutation, useQuery } from '../../src'; +import type { Database } from '../database.types'; +import { renderWithConfig } from '../utils'; + +const TEST_PREFIX = 'postgrest-react-query-delmany'; + +describe('useDeleteManyMutation', () => { + let client: SupabaseClient; + let testRunPrefix: string; + + let contacts: Database['public']['Tables']['contact']['Row'][]; + + beforeAll(async () => { + testRunPrefix = `${TEST_PREFIX}-${Math.floor(Math.random() * 100)}`; + client = createClient( + process.env.SUPABASE_URL as string, + process.env.SUPABASE_ANON_KEY as string, + ); + }); + + beforeEach(async () => { + await client.from('contact').delete().ilike('username', `${TEST_PREFIX}%`); + + const { data } = await client + .from('contact') + .insert( + new Array(3) + .fill(0) + .map((idx) => ({ username: `${testRunPrefix}-${idx}` })), + ) + .select('*'); + contacts = data as Database['public']['Tables']['contact']['Row'][]; + }); + + it('should delete existing cache item and reduce count', async () => { + const queryClient = new QueryClient(); + function Page() { + const [success, setSuccess] = useState(false); + const { data, count } = useQuery( + client + .from('contact') + .select('id,username', { count: 'exact' }) + .eq('username', contacts[0].username ?? ''), + ); + const { mutateAsync: deleteContact } = useDeleteManyMutation( + client.from('contact'), + ['id'], + null, + { onSuccess: () => setSuccess(true) }, + ); + const { mutateAsync: deleteWithEmptyOptions } = useDeleteManyMutation( + client.from('contact'), + ['id'], + null, + {}, + ); + const { mutateAsync: deleteWithoutOptions } = useDeleteManyMutation( + client.from('contact'), + ['id'], + ); + return ( +
+
+ await deleteContact([ + { + id: (data ?? []).find((c) => c)?.id, + }, + ]) + } + /> +
+ await deleteWithEmptyOptions([ + { + id: (data ?? []).find((c) => c)?.id, + }, + ]) + } + /> +
+ await deleteWithoutOptions([ + { + id: (data ?? []).find((c) => c)?.id, + }, + ]) + } + /> + {(data ?? []).map((d) => ( + {d.username} + ))} + {`count: ${count}`} + {`success: ${success}`} +
+ ); + } + + renderWithConfig(, queryClient); + await screen.findByText( + `count: ${contacts.length}`, + {}, + { timeout: 10000 }, + ); + fireEvent.click(screen.getByTestId('deleteWithEmptyOptions')); + await screen.findByText( + `count: ${contacts.length - 1}`, + {}, + { timeout: 10000 }, + ); + fireEvent.click(screen.getByTestId('deleteWithoutOptions')); + await screen.findByText( + `count: ${contacts.length - 2}`, + {}, + { timeout: 10000 }, + ); + fireEvent.click(screen.getByTestId('delete')); + await screen.findByText('success: true', {}, { timeout: 10000 }); + await screen.findByText( + `count: ${contacts.length - 3}`, + {}, + { timeout: 10000 }, + ); + }); +}); diff --git a/packages/postgrest-react-query/package.json b/packages/postgrest-react-query/package.json index 6242e70d..55aec1e2 100644 --- a/packages/postgrest-react-query/package.json +++ b/packages/postgrest-react-query/package.json @@ -26,7 +26,7 @@ "license": "MIT", "scripts": { "build": "tsup", - "test": "jest --coverage", + "test": "jest --coverage --runInBand", "clean": "rm -rf .turbo && rm -rf lint-results && rm -rf .nyc_output && rm -rf node_modules && rm -rf dist", "lint": "eslint src/**", "lint:report": "eslint {src/**,__tests__/**} --format json --output-file ./lint-results/postgrest-react-query.json", diff --git a/packages/postgrest-react-query/src/mutate/index.ts b/packages/postgrest-react-query/src/mutate/index.ts index b6bf397d..9f7c605c 100644 --- a/packages/postgrest-react-query/src/mutate/index.ts +++ b/packages/postgrest-react-query/src/mutate/index.ts @@ -1,3 +1,4 @@ +export * from './use-delete-many-mutation'; export * from './use-delete-mutation'; export * from './use-insert-mutation'; export * from './use-update-mutation'; diff --git a/packages/postgrest-react-query/src/mutate/types.ts b/packages/postgrest-react-query/src/mutate/types.ts index f9334356..9c647190 100644 --- a/packages/postgrest-react-query/src/mutate/types.ts +++ b/packages/postgrest-react-query/src/mutate/types.ts @@ -13,7 +13,12 @@ import { } from '@supabase-cache-helpers/postgrest-core'; import { UseMutationOptions } from '@tanstack/react-query'; -export type Operation = 'Insert' | 'UpdateOne' | 'Upsert' | 'DeleteOne'; +export type Operation = + | 'Insert' + | 'UpdateOne' + | 'Upsert' + | 'DeleteOne' + | 'DeleteMany'; export type GetFetcherOptions< S extends GenericSchema, @@ -25,7 +30,7 @@ export type GetFetcherOptions< ? UpdateFetcherOptions : O extends 'Upsert' ? UpsertFetcherOptions - : O extends 'DeleteOne' + : O extends 'DeleteOne' | 'DeleteMany' ? DeleteFetcherOptions : never; @@ -34,11 +39,13 @@ export type GetInputType< O extends Operation, > = O extends 'DeleteOne' ? Partial // TODO: Can we pick the primary keys somehow? - : O extends 'Insert' | 'Upsert' - ? T['Insert'][] - : O extends 'UpdateOne' - ? T['Update'] - : never; + : O extends 'DeleteMany' + ? Partial[] + : O extends 'Insert' | 'Upsert' + ? T['Insert'][] + : O extends 'UpdateOne' + ? T['Update'] + : never; export type GetReturnType< S extends GenericSchema, @@ -58,7 +65,7 @@ export type GetReturnType< ? R | null : O extends 'DeleteOne' ? R | null - : O extends 'Insert' | 'Upsert' + : O extends 'Insert' | 'Upsert' | 'DeleteMany' ? R[] | null : never; diff --git a/packages/postgrest-react-query/src/mutate/use-delete-many-mutation.ts b/packages/postgrest-react-query/src/mutate/use-delete-many-mutation.ts new file mode 100644 index 00000000..97de4afb --- /dev/null +++ b/packages/postgrest-react-query/src/mutate/use-delete-many-mutation.ts @@ -0,0 +1,76 @@ +import { PostgrestQueryBuilder } from '@supabase/postgrest-js'; +import { GetResult } from '@supabase/postgrest-js/dist/module/select-query-parser'; +import { + GenericSchema, + GenericTable, +} from '@supabase/postgrest-js/dist/module/types'; +import { + buildDeleteFetcher, + getTable, +} from '@supabase-cache-helpers/postgrest-core'; +import { useMutation } from '@tanstack/react-query'; + +import { UsePostgrestMutationOpts } from './types'; +import { useDeleteItem } from '../cache'; +import { useQueriesForTableLoader } from '../lib'; + +/** + * Hook to execute a DELETE mutation + * + * @param {PostgrestQueryBuilder} qb PostgrestQueryBuilder instance for the table + * @param {Array} primaryKeys Array of primary keys of the table + * @param {string | null} query Optional PostgREST query string for the DELETE mutation + * @param {Omit, 'mutationFn'>} [opts] Options to configure the hook + */ +function useDeleteManyMutation< + S extends GenericSchema, + T extends GenericTable, + RelationName, + Re = T extends { Relationships: infer R } ? R : unknown, + Q extends string = '*', + R = GetResult, +>( + qb: PostgrestQueryBuilder, + primaryKeys: (keyof T['Row'])[], + query?: Q | null, + opts?: Omit< + UsePostgrestMutationOpts, + 'mutationFn' + >, +) { + const queriesForTable = useQueriesForTableLoader(getTable(qb)); + const deleteItem = useDeleteItem({ + ...opts, + primaryKeys, + table: getTable(qb), + schema: qb.schema as string, + }); + + return useMutation({ + mutationFn: async (input) => { + const result = await buildDeleteFetcher( + qb, + primaryKeys, + { + query: query ?? undefined, + queriesForTable, + disabled: opts?.disableAutoQuery, + ...opts, + }, + )(input); + + if (result) { + for (const r of result) { + deleteItem(r.normalizedData as T['Row']); + } + } + + if (!result || result.every((r) => !r.userQueryData)) return null; + + return result.map((r) => r.userQueryData as R); + }, + ...opts, + }); +} + +export { useDeleteManyMutation }; diff --git a/packages/postgrest-react-query/src/mutate/use-delete-mutation.ts b/packages/postgrest-react-query/src/mutate/use-delete-mutation.ts index ae174def..7fb23262 100644 --- a/packages/postgrest-react-query/src/mutate/use-delete-mutation.ts +++ b/packages/postgrest-react-query/src/mutate/use-delete-mutation.ts @@ -48,7 +48,7 @@ function useDeleteMutation< return useMutation({ mutationFn: async (input) => { - const result = await buildDeleteFetcher( + const r = await buildDeleteFetcher( qb, primaryKeys, { @@ -57,7 +57,11 @@ function useDeleteMutation< disabled: opts?.disableAutoQuery, ...opts, }, - )(input); + )([input]); + + if (!r) return null; + + const result = r[0]; if (result) { await deleteItem(result.normalizedData as T['Row']); diff --git a/packages/postgrest-swr/__tests__/mutate/use-delete-many-mutation.spec.tsx b/packages/postgrest-swr/__tests__/mutate/use-delete-many-mutation.spec.tsx new file mode 100644 index 00000000..a8c2eca9 --- /dev/null +++ b/packages/postgrest-swr/__tests__/mutate/use-delete-many-mutation.spec.tsx @@ -0,0 +1,290 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { fireEvent, screen } from '@testing-library/react'; +import React, { useState } from 'react'; + +import { useDeleteManyMutation, useQuery } from '../../src'; +import type { Database } from '../database.types'; +import { renderWithConfig } from '../utils'; + +const TEST_PREFIX = 'postgrest-swr-delmany'; + +describe('useDeleteManyMutation', () => { + let client: SupabaseClient; + let provider: Map; + let testRunPrefix: string; + let testRunNumber: number; + + let contacts: Database['public']['Tables']['contact']['Row'][]; + let multiPks: Database['public']['Tables']['multi_pk']['Row'][]; + + beforeAll(async () => { + testRunNumber = Math.floor(Math.random() * 100); + testRunPrefix = `${TEST_PREFIX}-${testRunNumber}`; + client = createClient( + process.env.SUPABASE_URL as string, + process.env.SUPABASE_ANON_KEY as string, + ); + }); + + beforeEach(async () => { + provider = new Map(); + + await client.from('contact').delete().ilike('username', `${TEST_PREFIX}%`); + + const { data } = await client + .from('contact') + .insert( + new Array(3) + .fill(0) + .map((_, idx) => ({ username: `${testRunPrefix}-${idx}` })), + ) + .select('*'); + contacts = data as Database['public']['Tables']['contact']['Row'][]; + + await client.from('multi_pk').delete().ilike('name', `${TEST_PREFIX}%`); + + const input = new Array(3).fill(0).map((_, idx) => ({ + id_1: testRunNumber + idx, + id_2: testRunNumber + idx, + name: `${testRunPrefix}-${idx}`, + })); + + const { data: multiPksResult } = await client + .from('multi_pk') + .insert(input) + .select('*') + .throwOnError(); + + multiPks = + multiPksResult as Database['public']['Tables']['multi_pk']['Row'][]; + }); + + it('should delete existing cache item and reduce count', async () => { + function Page() { + const [success, setSuccess] = useState(false); + const { data, count } = useQuery( + client + .from('contact') + .select('id,username', { count: 'exact' }) + .ilike('username', `${testRunPrefix}%`), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); + const { trigger: deleteContact } = useDeleteManyMutation( + client.from('contact'), + ['id'], + 'id', + { onSuccess: () => setSuccess(true) }, + ); + const { trigger: deleteWithEmptyOptions } = useDeleteManyMutation( + client.from('contact'), + ['id'], + null, + {}, + ); + const { trigger: deleteWithoutOptions } = useDeleteManyMutation( + client.from('contact'), + ['id'], + ); + return ( +
+
+ await deleteContact([ + { + id: (data ?? []).find((c) => c)?.id, + }, + ]) + } + /> +
+ await deleteWithEmptyOptions([ + { + id: (data ?? []).find((c) => c)?.id, + }, + ]) + } + /> +
+ await deleteWithoutOptions([ + { + id: (data ?? []).find((c) => c)?.id, + }, + ]) + } + /> + {(data ?? []).map((d) => ( + {d.username} + ))} + {`count: ${count}`} + {`success: ${success}`} +
+ ); + } + + renderWithConfig(, { provider: () => provider }); + await screen.findByText( + `count: ${contacts.length}`, + {}, + { timeout: 10000 }, + ); + fireEvent.click(screen.getByTestId('deleteWithEmptyOptions')); + await screen.findByText( + `count: ${contacts.length - 1}`, + {}, + { timeout: 10000 }, + ); + fireEvent.click(screen.getByTestId('deleteWithoutOptions')); + await screen.findByText( + `count: ${contacts.length - 2}`, + {}, + { timeout: 10000 }, + ); + fireEvent.click(screen.getByTestId('delete')); + await screen.findByText('success: true', {}, { timeout: 10000 }); + await screen.findByText( + `count: ${contacts.length - 3}`, + {}, + { timeout: 10000 }, + ); + }); + + it('should batch delete', async () => { + function Page() { + const [success, setSuccess] = useState(false); + const [error, setError] = useState(false); + + const { data, count } = useQuery( + client + .from('contact') + .select('id,username', { count: 'exact' }) + .ilike('username', `${testRunPrefix}%`), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); + + const { trigger: deleteContact } = useDeleteManyMutation( + client.from('contact'), + ['id'], + null, + { + onSuccess: () => setSuccess(true), + onError: (e) => { + setError(true); + }, + }, + ); + + return ( +
+
{ + await deleteContact( + contacts.map((c) => ({ + id: c.id, + })), + ); + }} + /> + {(data ?? []).map((d) => ( + {d.username} + ))} + {`count: ${count}`} + {`success: ${success}`} + {`error: ${error}`} +
+ ); + } + + renderWithConfig(, { provider: () => provider }); + + await screen.findByText( + `count: ${contacts.length}`, + {}, + { timeout: 10000 }, + ); + + fireEvent.click(screen.getByTestId('batchDelete')); + + await screen.findByText(`count: 0`, {}, { timeout: 10000 }); + await screen.findByText('success: true', {}, { timeout: 10000 }); + await screen.findByText('error: false', {}, { timeout: 10000 }); + }); + + it('should batch delete with multi pks', async () => { + function Page() { + const [success, setSuccess] = useState(false); + const [error, setError] = useState(false); + + const { data, count } = useQuery( + client + .from('multi_pk') + .select('id_1,id_2,name', { count: 'exact' }) + .ilike('name', `${testRunPrefix}%`), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); + + const { trigger: deleteMultiPk } = useDeleteManyMutation( + client.from('multi_pk'), + ['id_1', 'id_2'], + null, + { + onSuccess: () => setSuccess(true), + onError: (e) => { + console.error(e); + setError(true); + }, + }, + ); + + return ( +
+
{ + await deleteMultiPk( + multiPks.map((i) => ({ + id_1: i.id_1, + id_2: i.id_2, + })), + ); + }} + /> + {(data ?? []).map((d) => ( + {d.name} + ))} + {`count: ${count}`} + {`success: ${success}`} + {`error: ${error}`} +
+ ); + } + + renderWithConfig(, { provider: () => provider }); + + await screen.findByText( + `count: ${contacts.length}`, + {}, + { timeout: 10000 }, + ); + + fireEvent.click(screen.getByTestId('batchDelete')); + + await screen.findByText(`count: 0`, {}, { timeout: 10000 }); + await screen.findByText('success: true', {}, { timeout: 10000 }); + await screen.findByText('error: false', {}, { timeout: 10000 }); + }); +}); diff --git a/packages/postgrest-swr/__tests__/mutate/use-delete-mutation.integration.spec.tsx b/packages/postgrest-swr/__tests__/mutate/use-delete-mutation.integration.spec.tsx index 597a7096..4fc1bd06 100644 --- a/packages/postgrest-swr/__tests__/mutate/use-delete-mutation.integration.spec.tsx +++ b/packages/postgrest-swr/__tests__/mutate/use-delete-mutation.integration.spec.tsx @@ -75,7 +75,7 @@ describe('useDeleteMutation', () => { const { trigger: deleteContact } = useDeleteMutation( client.from('contact'), ['id'], - null, + 'id', { onSuccess: () => setSuccess(true) }, ); const { trigger: deleteWithEmptyOptions } = useDeleteMutation( diff --git a/packages/postgrest-swr/package.json b/packages/postgrest-swr/package.json index fe5157dc..09d167c8 100644 --- a/packages/postgrest-swr/package.json +++ b/packages/postgrest-swr/package.json @@ -31,7 +31,7 @@ "license": "MIT", "scripts": { "build": "tsup", - "test": "jest --coverage", + "test": "jest --coverage --runInBand", "clean": "rm -rf .turbo && rm -rf lint-results && rm -rf .nyc_output && rm -rf node_modules && rm -rf dist", "lint": "eslint src/**", "lint:report": "eslint {src/**,__tests__/**} --format json --output-file ./lint-results/postgrest-swr.json", diff --git a/packages/postgrest-swr/src/mutate/index.ts b/packages/postgrest-swr/src/mutate/index.ts index cb80d8f5..beefd449 100644 --- a/packages/postgrest-swr/src/mutate/index.ts +++ b/packages/postgrest-swr/src/mutate/index.ts @@ -1,4 +1,5 @@ export * from './types'; +export * from './use-delete-many-mutation'; export * from './use-delete-mutation'; export * from './use-insert-mutation'; export * from './use-update-mutation'; diff --git a/packages/postgrest-swr/src/mutate/types.ts b/packages/postgrest-swr/src/mutate/types.ts index a236cc4f..75fa0998 100644 --- a/packages/postgrest-swr/src/mutate/types.ts +++ b/packages/postgrest-swr/src/mutate/types.ts @@ -16,7 +16,12 @@ import { SWRMutationConfiguration } from 'swr/mutation'; export type { SWRMutationConfiguration, PostgrestError }; -export type Operation = 'Insert' | 'UpdateOne' | 'Upsert' | 'DeleteOne'; +export type Operation = + | 'Insert' + | 'UpdateOne' + | 'Upsert' + | 'DeleteOne' + | 'DeleteMany'; export type GetFetcherOptions< S extends GenericSchema, @@ -28,7 +33,7 @@ export type GetFetcherOptions< ? UpdateFetcherOptions : O extends 'Upsert' ? UpsertFetcherOptions - : O extends 'DeleteOne' + : O extends 'DeleteOne' | 'DeleteMany' ? DeleteFetcherOptions : never; @@ -37,11 +42,13 @@ export type GetInputType< O extends Operation, > = O extends 'DeleteOne' ? Partial // TODO: Can we pick the primary keys somehow? - : O extends 'Insert' | 'Upsert' - ? T['Insert'][] - : O extends 'UpdateOne' - ? T['Update'] - : never; + : O extends 'DeleteMany' + ? Partial[] + : O extends 'Insert' | 'Upsert' + ? T['Insert'][] + : O extends 'UpdateOne' + ? T['Update'] + : never; export type GetReturnType< S extends GenericSchema, @@ -61,7 +68,7 @@ export type GetReturnType< ? R | null : O extends 'DeleteOne' ? R | null - : O extends 'Insert' | 'Upsert' + : O extends 'Insert' | 'Upsert' | 'DeleteMany' ? R[] | null : never; diff --git a/packages/postgrest-swr/src/mutate/use-delete-many-mutation.ts b/packages/postgrest-swr/src/mutate/use-delete-many-mutation.ts new file mode 100644 index 00000000..413d9791 --- /dev/null +++ b/packages/postgrest-swr/src/mutate/use-delete-many-mutation.ts @@ -0,0 +1,90 @@ +import { PostgrestError, PostgrestQueryBuilder } from '@supabase/postgrest-js'; +import { GetResult } from '@supabase/postgrest-js/dist/module/select-query-parser'; +import { + GenericSchema, + GenericTable, +} from '@supabase/postgrest-js/dist/module/types'; +import { + buildDeleteFetcher, + getTable, +} from '@supabase-cache-helpers/postgrest-core'; +import useMutation, { SWRMutationResponse } from 'swr/mutation'; + +import { UsePostgrestSWRMutationOpts } from './types'; +import { useRandomKey } from './use-random-key'; +import { useDeleteItem } from '../cache'; +import { useQueriesForTableLoader } from '../lib'; + +/** + * Hook for performing a DELETE mutation on a PostgREST resource. + * + * @param qb - The PostgrestQueryBuilder instance for the resource. + * @param primaryKeys - An array of primary key column names for the table. + * @param query - An optional query string. + * @param opts - An optional object of options to configure the mutation. + * @returns A SWRMutationResponse object containing the mutation response data, error, and mutation function. + */ +function useDeleteManyMutation< + S extends GenericSchema, + T extends GenericTable, + RelationName, + Re = T extends { Relationships: infer R } ? R : unknown, + Q extends string = '*', + R = GetResult, +>( + qb: PostgrestQueryBuilder, + primaryKeys: (keyof T['Row'])[], + query?: Q | null, + opts?: UsePostgrestSWRMutationOpts< + S, + T, + RelationName, + Re, + 'DeleteMany', + Q, + R + >, +): SWRMutationResponse< + R[] | null, + PostgrestError, + string, + Partial[] +> { + const key = useRandomKey(); + const queriesForTable = useQueriesForTableLoader(getTable(qb)); + const deleteItem = useDeleteItem({ + ...opts, + primaryKeys, + table: getTable(qb), + schema: qb.schema as string, + }); + + return useMutation[]>( + key, + async (_, { arg }) => { + const result = await buildDeleteFetcher( + qb, + primaryKeys, + { + query: query ?? undefined, + queriesForTable, + disabled: opts?.disableAutoQuery, + ...opts, + }, + )(arg); + + if (result) { + for (const r of result) { + deleteItem(r.normalizedData); + } + } + + if (!result || result.every((r) => !r.userQueryData)) return null; + + return result.map((r) => r.userQueryData as R); + }, + opts, + ); +} + +export { useDeleteManyMutation }; diff --git a/packages/postgrest-swr/src/mutate/use-delete-mutation.ts b/packages/postgrest-swr/src/mutate/use-delete-mutation.ts index b397b25f..cb0ffea4 100644 --- a/packages/postgrest-swr/src/mutate/use-delete-mutation.ts +++ b/packages/postgrest-swr/src/mutate/use-delete-mutation.ts @@ -49,7 +49,7 @@ function useDeleteMutation< return useMutation>( key, async (_, { arg }) => { - const result = await buildDeleteFetcher( + const r = await buildDeleteFetcher( qb, primaryKeys, { @@ -58,13 +58,17 @@ function useDeleteMutation< disabled: opts?.disableAutoQuery, ...opts, }, - )(arg); + )([arg]); + + if (!r || r.length === 0) return null; + + const result = r[0]; if (result) { - deleteItem(result?.normalizedData as T['Row']); + deleteItem(result.normalizedData); } - return result?.userQueryData ?? null; + return result.userQueryData as R; }, opts, );