diff --git a/.changeset/spotty-trains-attend.md b/.changeset/spotty-trains-attend.md new file mode 100644 index 00000000..71b63098 --- /dev/null +++ b/.changeset/spotty-trains-attend.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: support for aggregates diff --git a/packages/postgrest-core/src/lib/parse-select-param.ts b/packages/postgrest-core/src/lib/parse-select-param.ts index 606ca334..8aa94858 100644 --- a/packages/postgrest-core/src/lib/parse-select-param.ts +++ b/packages/postgrest-core/src/lib/parse-select-param.ts @@ -34,7 +34,7 @@ export const parseSelectParam = (s: string, currentPath?: Path): Path[] => { } const foreignTables = result.reduce((prev, curr, idx, matches) => { - if (curr.name === 'selectedColumns') { + if (curr.name === 'selectedColumns' && curr.value.length > 0) { const name = matches[idx - 1].value.slice(1, -1); prev = { ...prev, [name]: curr.value }; } @@ -62,6 +62,11 @@ export const parseSelectParam = (s: string, currentPath?: Path): Path[] => { .map((c) => { const split = c.split(':'); const hasAlias = split.length > 1; + + const aggregateSplit = split[hasAlias ? 1 : 0].split('.'); + const hasAggregate = + aggregateSplit.length > 1 && aggregateSplit[1].endsWith('()'); + return { declaration: [currentPath?.declaration, c].filter(Boolean).join('.'), alias: @@ -70,9 +75,13 @@ export const parseSelectParam = (s: string, currentPath?: Path): Path[] => { .filter(Boolean) .join('.') : undefined, - path: [currentPath?.path, split[hasAlias ? 1 : 0]] + path: [ + currentPath?.path, + hasAggregate ? aggregateSplit[0] : split[hasAlias ? 1 : 0], + ] .filter(Boolean) .join('.'), + ...(hasAggregate ? { aggregate: aggregateSplit[1].slice(0, -2) } : {}), }; }); diff --git a/packages/postgrest-core/src/lib/query-types.ts b/packages/postgrest-core/src/lib/query-types.ts index 4af59521..cf9cff1f 100644 --- a/packages/postgrest-core/src/lib/query-types.ts +++ b/packages/postgrest-core/src/lib/query-types.ts @@ -56,6 +56,10 @@ export type Path = { * The full declaration of a column that includes alias, hints and inner joins */ declaration: string; + /** + * The aggregate function applied to the path + */ + aggregate?: string; }; /** diff --git a/packages/postgrest-core/src/mutate-item.ts b/packages/postgrest-core/src/mutate-item.ts index 1436977e..b9025ce5 100644 --- a/packages/postgrest-core/src/mutate-item.ts +++ b/packages/postgrest-core/src/mutate-item.ts @@ -86,6 +86,7 @@ export type MutateItemCache> = { | 'applyFiltersOnPaths' | 'apply' | 'hasWildcardPath' + | 'hasAggregatePath' >; /** * Decode a key. Should return null if not a PostgREST key. @@ -141,7 +142,11 @@ export const mutateItem = async >( const orderBy = key.orderByKey ? parseOrderByKey(key.orderByKey) : undefined; - if (key.isHead === true || filter.hasWildcardPath) { + if ( + key.isHead === true || + filter.hasWildcardPath || + filter.hasAggregatePath + ) { // we cannot know whether the new item after mutating still has all paths required for a query if it contains a wildcard, // because we do not know what columns a table has. we must always revalidate then. mutations.push(revalidate(k)); diff --git a/packages/postgrest-core/src/postgrest-filter.ts b/packages/postgrest-core/src/postgrest-filter.ts index 8fec3e4c..e1dc328d 100644 --- a/packages/postgrest-core/src/postgrest-filter.ts +++ b/packages/postgrest-core/src/postgrest-filter.ts @@ -26,6 +26,7 @@ export class PostgrestFilter> { private _filtersFn: FilterFn | undefined; private _filterPaths: Path[]; public hasWildcardPath: boolean | undefined; + public hasAggregatePath: boolean | undefined; constructor( public readonly params: { filters: FilterDefinitions; paths: Path[] }, @@ -37,6 +38,7 @@ export class PostgrestFilter> { this.hasWildcardPath = this.params.paths.some((p) => p.declaration.endsWith('*'), ); + this.hasAggregatePath = this.params.paths.some((p) => Boolean(p.aggregate)); } public static fromQuery(query: string, opts?: PostgrestQueryParserOptions) { @@ -197,7 +199,9 @@ export class PostgrestFilter> { const filterFn = OPERATOR_MAP[operator]; if (!filterFn) throw new Error( - `Unable to build filter function for ${JSON.stringify(def)}. Operator ${operator} is not supported.`, + `Unable to build filter function for ${JSON.stringify( + def, + )}. Operator ${operator} is not supported.`, ); return (obj: object) => diff --git a/packages/postgrest-core/src/upsert-item.ts b/packages/postgrest-core/src/upsert-item.ts index 54f2a6e3..dcc57a20 100644 --- a/packages/postgrest-core/src/upsert-item.ts +++ b/packages/postgrest-core/src/upsert-item.ts @@ -93,6 +93,7 @@ export type UpsertItemCache> = { | 'applyFiltersOnPaths' | 'apply' | 'hasWildcardPath' + | 'hasAggregatePath' >; /** * Decode a key. Should return null if not a PostgREST key. @@ -143,7 +144,11 @@ export const upsertItem = async >( ? parseOrderByKey(key.orderByKey) : undefined; - if (key.isHead === true || filter.hasWildcardPath) { + if ( + key.isHead === true || + filter.hasWildcardPath || + filter.hasAggregatePath + ) { // we cannot know whether the new item after merging still has all paths required for a query if it contains a wildcard, // because we do not know what columns a table has. we must always revalidate then. mutations.push(revalidate(k)); diff --git a/packages/postgrest-core/tests/lib/parse-select-param.spec.ts b/packages/postgrest-core/tests/lib/parse-select-param.spec.ts index 49b11126..11c3c85d 100644 --- a/packages/postgrest-core/tests/lib/parse-select-param.spec.ts +++ b/packages/postgrest-core/tests/lib/parse-select-param.spec.ts @@ -2,6 +2,84 @@ import { describe, expect, it } from 'vitest'; import { parseSelectParam } from '../../src/lib/parse-select-param'; describe('parseSelectParam', () => { + it('should parse aggregates', () => { + expect(parseSelectParam('amount.sum()')).toEqual([ + { + alias: undefined, + declaration: 'amount.sum()', + path: 'amount', + aggregate: 'sum', + }, + ]); + }); + + it('should parse multiple aggregates with grouping', () => { + expect(parseSelectParam('amount.sum(),amount.avg(),order_date')).toEqual([ + { + alias: undefined, + declaration: 'amount.sum()', + path: 'amount', + aggregate: 'sum', + }, + { + alias: undefined, + declaration: 'amount.avg()', + path: 'amount', + aggregate: 'avg', + }, + { + alias: undefined, + declaration: 'order_date', + path: 'order_date', + }, + ]); + }); + + it('should parse aggregates with alias', () => { + expect( + parseSelectParam('amount.sum(),alias:amount.avg(),order_date'), + ).toEqual([ + { + alias: undefined, + declaration: 'amount.sum()', + path: 'amount', + aggregate: 'sum', + }, + { + alias: 'alias', + declaration: 'alias:amount.avg()', + path: 'amount', + aggregate: 'avg', + }, + { + alias: undefined, + declaration: 'order_date', + path: 'order_date', + }, + ]); + }); + + it('should parse aggregates within embedded resources', () => { + expect(parseSelectParam('state,orders(amount.sum(),order_date)')).toEqual([ + { + alias: undefined, + declaration: 'state', + path: 'state', + }, + { + alias: undefined, + declaration: 'orders.amount.sum()', + path: 'orders.amount', + aggregate: 'sum', + }, + { + alias: undefined, + declaration: 'orders.order_date', + path: 'orders.order_date', + }, + ]); + }); + it('should return input if falsy', () => { expect( parseSelectParam( diff --git a/packages/postgrest-core/tests/mutate-item.spec.ts b/packages/postgrest-core/tests/mutate-item.spec.ts index 8aa068a1..e4349c15 100644 --- a/packages/postgrest-core/tests/mutate-item.spec.ts +++ b/packages/postgrest-core/tests/mutate-item.spec.ts @@ -52,6 +52,11 @@ const mutateFnMock = async ( }, getPostgrestFilter() { return { + get hasAggregatePath(): boolean { + return typeof postgrestFilter.hasAggregatePath === 'boolean' + ? postgrestFilter.hasAggregatePath + : false; + }, get hasWildcardPath(): boolean { return typeof postgrestFilter.hasWildcardPath === 'boolean' ? postgrestFilter.hasWildcardPath @@ -137,6 +142,9 @@ const mutateRelationMock = async ( }, getPostgrestFilter() { return { + get hasAggregatePath(): boolean { + return false; + }, get hasWildcardPath(): boolean { return false; }, @@ -205,6 +213,11 @@ const mutateFnResult = async ( }, getPostgrestFilter() { return { + get hasAggregatePath(): boolean { + return typeof postgrestFilter.hasAggregatePath === 'boolean' + ? postgrestFilter.hasAggregatePath + : false; + }, get hasWildcardPath(): boolean { return typeof postgrestFilter.hasWildcardPath === 'boolean' ? postgrestFilter.hasWildcardPath @@ -347,6 +360,25 @@ describe('mutateItem', () => { expect(mutate).toHaveBeenCalledTimes(0); }); + it('should revalidate aggregate query', async () => { + const { mutate, revalidate } = await mutateFnMock( + { id_1: '0', id_2: '0' }, + (c) => c, + {}, + { + apply: false, + applyFilters: false, + hasPaths: false, + hasWildcardPath: false, + hasAggregatePath: true, + hasFiltersOnPaths: true, + applyFiltersOnPaths: true, + }, + ); + expect(revalidate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledTimes(0); + }); + it('should revalidate isHead query', async () => { const { mutate, revalidate } = await mutateFnMock( { id_1: '0', id_2: '0' }, diff --git a/packages/postgrest-core/tests/postgrest-filter.spec.ts b/packages/postgrest-core/tests/postgrest-filter.spec.ts index 52b549ad..8aa170c6 100644 --- a/packages/postgrest-core/tests/postgrest-filter.spec.ts +++ b/packages/postgrest-core/tests/postgrest-filter.spec.ts @@ -55,6 +55,19 @@ describe('PostgrestFilter', () => { ).toEqual(true); }); + it('should set has aggregate paths', () => { + expect( + PostgrestFilter.fromQuery( + new PostgrestParser( + createClient('https://localhost', 'test') + .from('contact') + .select('name,city,state,orders(amount.sum(),order_date)') + .eq('username', 'test'), + ).queryKey, + ).hasAggregatePath, + ).toEqual(true); + }); + describe('.transform', () => { it('should transform nested one-to-many relations', () => { expect( diff --git a/packages/postgrest-core/tests/upsert-item.spec.ts b/packages/postgrest-core/tests/upsert-item.spec.ts index 1f1f93bf..70313918 100644 --- a/packages/postgrest-core/tests/upsert-item.spec.ts +++ b/packages/postgrest-core/tests/upsert-item.spec.ts @@ -55,6 +55,11 @@ const mutateFnMock = async ( ? postgrestFilter.hasWildcardPath : false; }, + get hasAggregatePath(): boolean { + return typeof postgrestFilter.hasAggregatePath === 'boolean' + ? postgrestFilter.hasAggregatePath + : false; + }, denormalize(obj: ItemType): ItemType { return obj; }, @@ -137,6 +142,9 @@ const mutateRelationMock = async ( get hasWildcardPath(): boolean { return false; }, + get hasAggregatePath(): boolean { + return false; + }, denormalize(obj: RelationType): RelationType { return obj; }, @@ -202,6 +210,11 @@ const mutateFnResult = async ( }, getPostgrestFilter() { return { + get hasAggregatePath(): boolean { + return typeof postgrestFilter.hasAggregatePath === 'boolean' + ? postgrestFilter.hasAggregatePath + : false; + }, get hasWildcardPath(): boolean { return typeof postgrestFilter.hasWildcardPath === 'boolean' ? postgrestFilter.hasWildcardPath @@ -341,6 +354,24 @@ describe('upsertItem', () => { expect(revalidate).toHaveBeenCalledTimes(1); }); + it('should revalidate aggregate query', async () => { + const { mutate, revalidate } = await mutateFnMock( + { id_1: '0', id_2: '0', value: 'test' }, + {}, + { + hasWildcardPath: false, + hasAggregatePath: true, + apply: false, + applyFilters: false, + hasPaths: false, + hasFiltersOnPaths: true, + applyFiltersOnPaths: true, + }, + ); + expect(mutate).toHaveBeenCalledTimes(0); + expect(revalidate).toHaveBeenCalledTimes(1); + }); + it('should revalidate isHead query', async () => { const { mutate, revalidate } = await mutateFnMock( { id_1: '0', id_2: '0', value: 'test' }, diff --git a/packages/postgrest-react-query/tests/mutate/use-update-mutation.integration.spec.tsx b/packages/postgrest-react-query/tests/mutate/use-update-mutation.integration.spec.tsx index 9f3220fc..f7542260 100644 --- a/packages/postgrest-react-query/tests/mutate/use-update-mutation.integration.spec.tsx +++ b/packages/postgrest-react-query/tests/mutate/use-update-mutation.integration.spec.tsx @@ -151,6 +151,66 @@ describe('useUpdateMutation', () => { await screen.findByText('success: true'); }); + it('should revalidate existing cache item with aggregate', async () => { + const queryClient = new QueryClient(); + const USERNAME_1 = `${testRunPrefix}-aggregate-1`; + const USERNAME_2 = `${testRunPrefix}-aggregate-2`; + function Page() { + const [success, setSuccess] = useState(false); + const { data } = useQuery( + client + .from('contact') + .select('id.count()') + .in('username', [USERNAME_1, USERNAME_2]), + ); + const { mutateAsync: insert } = useInsertMutation( + client.from('contact'), + ['id'], + ); + const { mutateAsync: update } = useUpdateMutation( + client.from('contact'), + ['id'], + null, + { + onSuccess: () => setSuccess(true), + }, + ); + const res = data ? data[0] : null; + return ( +
+
await insert([{ username: USERNAME_1 }])} + /> +
{ + const { data } = await client + .from('contact') + .select('id') + .eq('username', USERNAME_1) + .single(); + await update({ + id: data!.id, + username: USERNAME_2, + }); + }} + /> + {`count: ${res?.count}`} + {`success: ${success}`} +
+ ); + } + + renderWithConfig(, queryClient); + await screen.findByText('count: 0'); + fireEvent.click(screen.getByTestId('insert')); + await screen.findByText('count: 1'); + fireEvent.click(screen.getByTestId('update')); + await screen.findByText('count: 1'); + await screen.findByText('success: true'); + }); + it('should revalidate existing cache item with count and head', async () => { const queryClient = new QueryClient(); const USERNAME_1 = `${testRunPrefix}-count-1`; diff --git a/packages/postgrest-swr/tests/mutate/use-update-mutation.integration.spec.tsx b/packages/postgrest-swr/tests/mutate/use-update-mutation.integration.spec.tsx index 525cb1b3..8dccf3ea 100644 --- a/packages/postgrest-swr/tests/mutate/use-update-mutation.integration.spec.tsx +++ b/packages/postgrest-swr/tests/mutate/use-update-mutation.integration.spec.tsx @@ -1,4 +1,4 @@ -import { type SupabaseClient, createClient } from '@supabase/supabase-js'; +import { SupabaseClient, createClient } from '@supabase/supabase-js'; import { cleanup, fireEvent, screen } from '@testing-library/react'; import { useEffect, useRef, useState } from 'react'; import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -229,6 +229,69 @@ describe('useUpdateMutation', () => { await screen.findByText('success: true'); }); + it('should revalidate existing cache item with aggregate', async () => { + const USERNAME_1 = `${testRunPrefix}-aggregate-1`; + const USERNAME_2 = `${testRunPrefix}-aggregate-2`; + function Page() { + const [success, setSuccess] = useState(false); + const { data } = useQuery( + client + .from('contact') + .select('id.count()') + .in('username', [USERNAME_1, USERNAME_2]), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); + const { trigger: insert } = useInsertMutation(client.from('contact'), [ + 'id', + ]); + const { trigger: update } = useUpdateMutation( + client.from('contact'), + ['id'], + null, + { + onSuccess: () => setSuccess(true), + }, + ); + const res = (data ? data[0] : null) as any; + return ( +
+
await insert([{ username: USERNAME_1 }])} + /> +
{ + const { data } = await client + .from('contact') + .select('id') + .eq('username', USERNAME_1) + .single(); + + await update({ + id: data!.id, + username: USERNAME_2, + }); + }} + /> + {`count: ${res?.count}`} + {`success: ${success}`} +
+ ); + } + + renderWithConfig(, { provider: () => provider }); + await screen.findByText('count: 0'); + fireEvent.click(screen.getByTestId('insert')); + await screen.findByText('count: 1', {}, { timeout: 2000 }); + fireEvent.click(screen.getByTestId('update')); + await screen.findByText('count: 1', {}, { timeout: 2000 }); + await screen.findByText('success: true'); + }); + it('should revalidate existing cache item with count and head', async () => { const USERNAME_1 = `${testRunPrefix}-count-1`; const USERNAME_2 = `${testRunPrefix}-count-2`; diff --git a/supabase/migrations/20240311151147_allow_aggregates.sql b/supabase/migrations/20240311151147_allow_aggregates.sql new file mode 100644 index 00000000..b19c71e2 --- /dev/null +++ b/supabase/migrations/20240311151147_allow_aggregates.sql @@ -0,0 +1,3 @@ +alter role authenticator set pgrst.db_aggregates_enabled to 'true'; + +