diff --git a/.changeset/large-rice-give.md b/.changeset/large-rice-give.md new file mode 100644 index 00000000..a8253fd3 --- /dev/null +++ b/.changeset/large-rice-give.md @@ -0,0 +1,7 @@ +--- +"@supabase-cache-helpers/postgrest-core": patch +"@supabase-cache-helpers/postgrest-react-query": patch +"@supabase-cache-helpers/postgrest-swr": patch +--- + +fix: use flattened object for normalized data to fix bugs with nested joins overlapping with the id diff --git a/packages/postgrest-core/__tests__/delete-item.spec.ts b/packages/postgrest-core/__tests__/delete-item.spec.ts index 08c9e127..ac243ff1 100644 --- a/packages/postgrest-core/__tests__/delete-item.spec.ts +++ b/packages/postgrest-core/__tests__/delete-item.spec.ts @@ -1,7 +1,7 @@ import { deleteItem } from '../src/delete-item'; -import { mutate } from '../src/lib/mutate'; +import { mutate } from '../src/mutate/mutate'; -jest.mock('../src/lib/mutate', () => ({ +jest.mock('../src/mutate/mutate', () => ({ mutate: jest.fn().mockImplementation(() => jest.fn()), })); @@ -31,7 +31,7 @@ describe('deleteItem', () => { hasFiltersOnPaths() { return true; }, - transform: (obj) => obj, + denormalize: (obj) => obj, apply(obj): obj is ItemType { return true; }, diff --git a/packages/postgrest-core/__tests__/lib/build-mutation-fetcher-response.spec.ts b/packages/postgrest-core/__tests__/fetch/build-mutation-fetcher-response.spec.ts similarity index 77% rename from packages/postgrest-core/__tests__/lib/build-mutation-fetcher-response.spec.ts rename to packages/postgrest-core/__tests__/fetch/build-mutation-fetcher-response.spec.ts index 36f60512..98b045f8 100644 --- a/packages/postgrest-core/__tests__/lib/build-mutation-fetcher-response.spec.ts +++ b/packages/postgrest-core/__tests__/fetch/build-mutation-fetcher-response.spec.ts @@ -1,6 +1,59 @@ -import { buildMutationFetcherResponse } from '../../src/lib/build-mutation-fetcher-response'; +import { createClient } from '@supabase/supabase-js'; + +import { buildMutationFetcherResponse } from '../../src/fetch/build-mutation-fetcher-response'; +import { buildNormalizedQuery } from '../../src/fetch/build-normalized-query'; +import { PostgrestParser } from '../../src/postgrest-parser'; + +const c = createClient('https://localhost', 'any'); describe('buildMutationFetcherResponse', () => { + it('should work with dedupe alias and user-defined alias', () => { + const q = c.from('contact').select('some,value').eq('test', 'value'); + + const query = buildNormalizedQuery({ + query: 'note_id(test,relation_id,rel:relation_id(test))', + queriesForTable: () => [new PostgrestParser(q)], + }); + + expect(query).toBeTruthy(); + + expect( + buildMutationFetcherResponse( + { + test: '123', + some: '456', + value: '789', + note_id: { + test: '123', + d_0_relation_id: 'id', + relation_id: { + test: '345', + }, + }, + }, + { userQueryPaths: query!.userQueryPaths, paths: query!.paths } + ) + ).toEqual({ + normalizedData: { + test: '123', + some: '456', + value: '789', + 'note_id.test': '123', + 'note_id.relation_id': 'id', + 'note_id.relation_id.test': '345', + }, + userQueryData: { + note_id: { + test: '123', + relation_id: 'id', + rel: { + test: '345', + }, + }, + }, + }); + }); + it('should build nested paths correctly', () => { expect( buildMutationFetcherResponse( @@ -171,8 +224,8 @@ describe('buildMutationFetcherResponse', () => { path: 'inbox_id.name', }, { - alias: undefined, - declaration: 'recipient_id', + alias: 'd_0_recipient_id', + declaration: 'd_0_recipient_id:recipient_id', path: 'recipient_id', }, { @@ -181,8 +234,8 @@ describe('buildMutationFetcherResponse', () => { path: 'organisation_id', }, { - alias: undefined, - declaration: 'inbox_id', + alias: 'd_1_inbox_id', + declaration: 'd_1_inbox_id:inbox_id', path: 'inbox_id', }, { @@ -244,45 +297,33 @@ describe('buildMutationFetcherResponse', () => { normalizedData: { assignee_id: null, blurb: 'Second Content', - channel_id: { - active: true, - id: '554870cc-b918-44b5-ad64-9574fda8fe1d', - name: 'Email Channel', - provider_id: null, - }, + 'channel_id.active': true, + 'channel_id.id': '554870cc-b918-44b5-ad64-9574fda8fe1d', + 'channel_id.name': 'Email Channel', + 'channel_id.provider_id': null, channel_type: 'email', created_at: '2023-04-14T07:19:54.763336+00:00', display_date: '09:19', id: 'e9394bba-6657-44a7-bc8c-9dbcc4851176', - inbox_id: { - id: '4b8221b0-f594-4924-ad94-ef5eee76aed4', - name: 'Default Inbox', - }, + 'inbox_id.id': '4b8221b0-f594-4924-ad94-ef5eee76aed4', + 'inbox_id.name': 'Default Inbox', is_spam: false, latest_message_attachment_count: 0, organisation_id: 'b18efb43-feef-4171-b7b9-26ee48a795e3', - recipient_id: { - contact_id: '7a53de3e-73c9-4cc9-b8f1-5b9927e531ad', - full_name: 'Recipient Two', - handle: 'two@recipient.com', - id: 'cfae4bd9-acd7-48bc-84f1-f857c91b0294', - }, + 'recipient_id.contact_id': '7a53de3e-73c9-4cc9-b8f1-5b9927e531ad', + 'recipient_id.full_name': 'Recipient Two', + 'recipient_id.handle': 'two@recipient.com', + 'recipient_id.id': 'cfae4bd9-acd7-48bc-84f1-f857c91b0294', recipient_list: 'Recipient One, Recipient Two', session_time: null, status: 'closed', subject: 'Email Conversation Subject', - tag: [ - { - color: 'blue', - id: '0fae4bd9-acd7-48bc-84f1-f857c91b0294', - name: 'Test', - }, - { - color: 'red', - id: '1fae4bd9-acd7-48bc-84f1-f857c91b0294', - name: 'Test 2', - }, - ], + 'tag.0.color': 'blue', + 'tag.0.id': '0fae4bd9-acd7-48bc-84f1-f857c91b0294', + 'tag.0.name': 'Test', + 'tag.1.color': 'red', + 'tag.1.id': '1fae4bd9-acd7-48bc-84f1-f857c91b0294', + 'tag.1.name': 'Test 2', unread: false, }, userQueryData: { diff --git a/packages/postgrest-core/__tests__/lib/build-query.spec.ts b/packages/postgrest-core/__tests__/fetch/build-normalized-query.spec.ts similarity index 87% rename from packages/postgrest-core/__tests__/lib/build-query.spec.ts rename to packages/postgrest-core/__tests__/fetch/build-normalized-query.spec.ts index 68ecd40f..7333ac34 100644 --- a/packages/postgrest-core/__tests__/lib/build-query.spec.ts +++ b/packages/postgrest-core/__tests__/fetch/build-normalized-query.spec.ts @@ -1,11 +1,11 @@ import { createClient } from '@supabase/supabase-js'; -import { buildQuery } from '../../src/lib/build-query'; +import { buildNormalizedQuery } from '../../src/fetch/build-normalized-query'; import { PostgrestParser } from '../../src/postgrest-parser'; const c = createClient('https://localhost', 'any'); -describe('buildQuery', () => { +describe('buildNormalizedQuery', () => { it('should work without user query', () => { const q1 = c.from('contact').select('some,value').eq('test', 'value'); const q2 = c @@ -14,7 +14,7 @@ describe('buildQuery', () => { .eq('another_test', 'value'); expect( - buildQuery({ + buildNormalizedQuery({ queriesForTable: () => [ new PostgrestParser(q1), new PostgrestParser(q2), @@ -31,7 +31,7 @@ describe('buildQuery', () => { .eq('another_test', 'value'); expect( - buildQuery({ + buildNormalizedQuery({ query: 'something,the,user,queries', disabled: true, queriesForTable: () => [ @@ -64,7 +64,7 @@ describe('buildQuery', () => { .eq('another_test', 'value'); expect( - buildQuery({ + buildNormalizedQuery({ query: 'something,the,user,queries', queriesForTable: () => [ new PostgrestParser(q1), @@ -85,7 +85,7 @@ describe('buildQuery', () => { .eq('another_test', 'value'); expect( - buildQuery({ + buildNormalizedQuery({ query: 'something,the,user,queries', queriesForTable: () => [ new PostgrestParser(q1), @@ -103,7 +103,7 @@ describe('buildQuery', () => { .eq('another_test', 'value'); expect( - buildQuery({ + buildNormalizedQuery({ query: 'something,the,user,queries,alias:some_relation!hint2(test)', queriesForTable: () => [ new PostgrestParser(q1), @@ -124,7 +124,7 @@ describe('buildQuery', () => { .or('some.eq.123,and(value.eq.342,other.gt.4)'); expect( - buildQuery({ + buildNormalizedQuery({ query: 'something,the,user,queries', queriesForTable: () => [ new PostgrestParser(q1), @@ -134,6 +134,33 @@ describe('buildQuery', () => { ).toEqual('something,the,user,queries,test,some,value,another_test,other'); }); + it('should add deduplication alias', () => { + const q = c.from('contact').select('some,value').eq('test', 'value'); + + expect( + buildNormalizedQuery({ + query: 'something,the,user,queries,note_id,note:note_id(test)', + queriesForTable: () => [new PostgrestParser(q)], + })?.selectQuery + ).toEqual( + 'something,the,user,queries,d_0_note_id:note_id,note_id(test),test,some,value' + ); + }); + + it('should add deduplication alias to nested alias', () => { + const q = c.from('contact').select('some,value').eq('test', 'value'); + + expect( + buildNormalizedQuery({ + query: + 'something,the,user,queries,note_id(test,relation_id,rel:relation_id(test))', + queriesForTable: () => [new PostgrestParser(q)], + })?.selectQuery + ).toEqual( + 'something,the,user,queries,note_id(test,d_0_relation_id:relation_id,relation_id(test)),test,some,value' + ); + }); + it('should work with complex master detail example', () => { const q1 = c .from('conversation') @@ -152,7 +179,7 @@ describe('buildQuery', () => { .neq('status', 'archived'); expect( - buildQuery({ + buildNormalizedQuery({ query: 'id,assignee:assignee_id(id,test_name:display_name),tags:tag(id,tag_name:name)', queriesForTable: () => [ @@ -162,7 +189,7 @@ describe('buildQuery', () => { }) ).toMatchObject({ selectQuery: - 'id,assignee_id(id,display_name),tag(id,name,color),status,session_time,is_spam,subject,channel_type,created_at,recipient_list,unread,recipient_id(id,contact_id,full_name,handle),channel_id(id,active,name,provider_id),inbox_id(id,name),organisation_id,recipient_id,inbox_id,display_date,latest_message_attachment_count,blurb', + 'id,assignee_id(id,display_name),tag(id,name,color),status,session_time,is_spam,subject,channel_type,created_at,recipient_list,unread,recipient_id(id,contact_id,full_name,handle),channel_id(id,active,name,provider_id),inbox_id(id,name),organisation_id,d_0_recipient_id:recipient_id,d_1_inbox_id:inbox_id,display_date,latest_message_attachment_count,blurb', paths: expect.arrayContaining([ { alias: undefined, @@ -285,8 +312,8 @@ describe('buildQuery', () => { path: 'inbox_id.name', }, { - alias: undefined, - declaration: 'recipient_id', + alias: 'd_0_recipient_id', + declaration: 'd_0_recipient_id:recipient_id', path: 'recipient_id', }, { @@ -295,8 +322,8 @@ describe('buildQuery', () => { path: 'organisation_id', }, { - alias: undefined, - declaration: 'inbox_id', + alias: 'd_1_inbox_id', + declaration: 'd_1_inbox_id:inbox_id', path: 'inbox_id', }, { @@ -354,7 +381,7 @@ describe('buildQuery', () => { .eq('recipients.contact_id', 'some-contact-id'); expect( - buildQuery({ + buildNormalizedQuery({ queriesForTable: () => [new PostgrestParser(q1)], })?.selectQuery ).toEqual('recipient!recipient_conversation_id_fkey!inner(contact_id)'); @@ -370,7 +397,7 @@ describe('buildQuery', () => { .eq('another_test', 'value'); expect( - buildQuery({ + buildNormalizedQuery({ query: 'something,the,user,queries', queriesForTable: () => [ new PostgrestParser(q1), diff --git a/packages/postgrest-core/__tests__/lib/build-select-statement.spec.ts b/packages/postgrest-core/__tests__/fetch/build-select-statement.spec.ts similarity index 96% rename from packages/postgrest-core/__tests__/lib/build-select-statement.spec.ts rename to packages/postgrest-core/__tests__/fetch/build-select-statement.spec.ts index 4004cfc0..f3f9a407 100644 --- a/packages/postgrest-core/__tests__/lib/build-select-statement.spec.ts +++ b/packages/postgrest-core/__tests__/fetch/build-select-statement.spec.ts @@ -1,4 +1,4 @@ -import { buildSelectStatement } from '../../src/lib/build-select-statement'; +import { buildSelectStatement } from '../../src/fetch/build-select-statement'; describe('buildSelectStatement', () => { it('should build nested paths correctly', () => { diff --git a/packages/postgrest-core/__tests__/fetch/dedupe.spec.ts b/packages/postgrest-core/__tests__/fetch/dedupe.spec.ts new file mode 100644 index 00000000..ba687c65 --- /dev/null +++ b/packages/postgrest-core/__tests__/fetch/dedupe.spec.ts @@ -0,0 +1,16 @@ +import { buildDedupePath } from '../../src/fetch/dedupe'; + +describe('buildDedupePath', () => { + it('should apply alias to nested path correctly', () => { + expect( + buildDedupePath(0, { + path: 'note_id.relation_id', + declaration: 'note_id.relation_id', + }) + ).toMatchObject({ + path: 'note_id.relation_id', + declaration: 'note_id.d_0_relation_id:relation_id', + alias: 'note_id.d_0_relation_id', + }); + }); +}); diff --git a/packages/postgrest-core/__tests__/filter/denormalize.spec.ts b/packages/postgrest-core/__tests__/filter/denormalize.spec.ts new file mode 100644 index 00000000..f54cb8b2 --- /dev/null +++ b/packages/postgrest-core/__tests__/filter/denormalize.spec.ts @@ -0,0 +1,29 @@ +import { denormalize } from '../../src/filter/denormalize'; +import { parseSelectParam } from '../../src/lib/parse-select-param'; + +describe('denormalize', () => { + it('should work with nested alias', () => { + const paths = parseSelectParam( + 'note_id(test,relation_id,rel:relation_id(test))' + ); + + expect( + denormalize(paths, { + test: '123', + some: '456', + value: '789', + 'note_id.test': '123', + 'note_id.relation_id': 'id', + 'note_id.relation_id.test': '345', + }) + ).toEqual({ + note_id: { + test: '123', + relation_id: 'id', + rel: { + test: '345', + }, + }, + }); + }); +}); diff --git a/packages/postgrest-core/__tests__/index.spec.ts b/packages/postgrest-core/__tests__/index.spec.ts new file mode 100644 index 00000000..a52d7ef1 --- /dev/null +++ b/packages/postgrest-core/__tests__/index.spec.ts @@ -0,0 +1,7 @@ +import * as Import from '../src'; + +describe('index exports', () => { + it('should export', () => { + expect(Object.keys(Import)).toHaveLength(30); + }); +}); diff --git a/packages/postgrest-core/__tests__/lib/get-table.spec.ts b/packages/postgrest-core/__tests__/lib/get-table.spec.ts new file mode 100644 index 00000000..a48c203e --- /dev/null +++ b/packages/postgrest-core/__tests__/lib/get-table.spec.ts @@ -0,0 +1,11 @@ +import { createClient } from '@supabase/supabase-js'; + +import { getTable } from '../../src/lib/get-table'; + +const c = createClient('http://localhost:3000', 'test'); + +describe('getTable', () => { + it('should return table name', () => { + expect(getTable(c.from('test').select('id').eq('id', 1))).toEqual('test'); + }); +}); diff --git a/packages/postgrest-core/__tests__/lib/transform-recursive.spec.ts b/packages/postgrest-core/__tests__/lib/transform-recursive.spec.ts deleted file mode 100644 index 90b7cabe..00000000 --- a/packages/postgrest-core/__tests__/lib/transform-recursive.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { transformRecursive } from '../../src/lib/transform-recursive'; - -describe('transformRecursive', () => { - it('should transform nulls to null', () => { - expect( - transformRecursive( - [ - { - alias: 'id', - declaration: 'id', - path: 'id', - }, - { - alias: 'full_name', - declaration: 'full_name:display_name', - path: 'display_name', - }, - ], - { - id: null, - display_name: null, - }, - 'path' - ) - ).toEqual({ id: null, display_name: null }); - }); - it('should transform nulls of relation to null', () => { - expect( - transformRecursive( - [ - { - alias: 'assignee.id', - declaration: 'assignee:assignee_id.id', - path: 'assignee_id.id', - }, - { - alias: 'assignee.display_name', - declaration: 'assignee:assignee_id.display_name', - path: 'assignee_id.display_name', - }, - ], - { - assignee_id: null, - }, - 'path' - ) - ).toEqual({ assignee_id: null }); - }); -}); diff --git a/packages/postgrest-core/__tests__/lib/build-delete-mutator-fn.spec.ts b/packages/postgrest-core/__tests__/mutate/build-delete-mutator-fn.spec.ts similarity index 97% rename from packages/postgrest-core/__tests__/lib/build-delete-mutator-fn.spec.ts rename to packages/postgrest-core/__tests__/mutate/build-delete-mutator-fn.spec.ts index 62d0dd99..7b2cd9b9 100644 --- a/packages/postgrest-core/__tests__/lib/build-delete-mutator-fn.spec.ts +++ b/packages/postgrest-core/__tests__/mutate/build-delete-mutator-fn.spec.ts @@ -1,4 +1,4 @@ -import { buildDeleteMutatorFn } from '../../src/lib/build-delete-mutator-fn'; +import { buildDeleteMutatorFn } from '../../src/mutate/build-delete-mutator-fn'; type ItemType = { id_1: string; diff --git a/packages/postgrest-core/__tests__/lib/build-upsert-mutator-fn.spec.ts b/packages/postgrest-core/__tests__/mutate/build-upsert-mutator-fn.spec.ts similarity index 99% rename from packages/postgrest-core/__tests__/lib/build-upsert-mutator-fn.spec.ts rename to packages/postgrest-core/__tests__/mutate/build-upsert-mutator-fn.spec.ts index 8661a5d4..7229cb77 100644 --- a/packages/postgrest-core/__tests__/lib/build-upsert-mutator-fn.spec.ts +++ b/packages/postgrest-core/__tests__/mutate/build-upsert-mutator-fn.spec.ts @@ -1,4 +1,4 @@ -import { buildUpsertMutatorFn } from '../../src/lib/build-upsert-mutator-fn'; +import { buildUpsertMutatorFn } from '../../src/mutate/build-upsert-mutator-fn'; type ItemType = { [idx: string]: string | null; diff --git a/packages/postgrest-core/__tests__/lib/mutate.spec.ts b/packages/postgrest-core/__tests__/mutate/mutate.spec.ts similarity index 96% rename from packages/postgrest-core/__tests__/lib/mutate.spec.ts rename to packages/postgrest-core/__tests__/mutate/mutate.spec.ts index 375d5027..e2946201 100644 --- a/packages/postgrest-core/__tests__/lib/mutate.spec.ts +++ b/packages/postgrest-core/__tests__/mutate/mutate.spec.ts @@ -1,12 +1,12 @@ -import { buildDeleteMutatorFn } from '../../src/lib/build-delete-mutator-fn'; -import { buildUpsertMutatorFn } from '../../src/lib/build-upsert-mutator-fn'; -import { OperationType, mutate } from '../../src/lib/mutate'; import { DecodedKey, PostgrestMutatorOpts } from '../../src/lib/mutator-types'; +import { buildDeleteMutatorFn } from '../../src/mutate/build-delete-mutator-fn'; +import { buildUpsertMutatorFn } from '../../src/mutate/build-upsert-mutator-fn'; +import { OperationType, mutate } from '../../src/mutate/mutate'; -jest.mock('../../src/lib/build-delete-mutator-fn', () => ({ +jest.mock('../../src/mutate/build-delete-mutator-fn', () => ({ buildDeleteMutatorFn: jest.fn().mockImplementation(() => jest.fn()), })); -jest.mock('../../src/lib/build-upsert-mutator-fn', () => ({ +jest.mock('../../src/mutate/build-upsert-mutator-fn', () => ({ buildUpsertMutatorFn: jest.fn().mockImplementation(() => jest.fn()), })); @@ -61,7 +61,7 @@ const mockMutate = async ({ hasFiltersOnPaths() { return postgrestFilter.hasFiltersOnPaths; }, - transform: (obj) => obj, + denormalize: (obj) => obj, apply(obj): obj is ItemType { return postgrestFilter.apply; }, diff --git a/packages/postgrest-core/__tests__/lib/upsert.spec.ts b/packages/postgrest-core/__tests__/mutate/upsert.spec.ts similarity index 98% rename from packages/postgrest-core/__tests__/lib/upsert.spec.ts rename to packages/postgrest-core/__tests__/mutate/upsert.spec.ts index 51bab7e0..f13b7df0 100644 --- a/packages/postgrest-core/__tests__/lib/upsert.spec.ts +++ b/packages/postgrest-core/__tests__/mutate/upsert.spec.ts @@ -1,4 +1,4 @@ -import { upsert } from '../../src/lib/build-upsert-mutator-fn'; +import { upsert } from '../../src/mutate/build-upsert-mutator-fn'; type ItemType = { [idx: string]: string | number | null; diff --git a/packages/postgrest-core/__tests__/postgrest-filter.spec.ts b/packages/postgrest-core/__tests__/postgrest-filter.spec.ts index a1e5930b..be862049 100644 --- a/packages/postgrest-core/__tests__/postgrest-filter.spec.ts +++ b/packages/postgrest-core/__tests__/postgrest-filter.spec.ts @@ -1,4 +1,5 @@ import { createClient } from '@supabase/supabase-js'; +import { flatten } from 'flat'; import { PostgrestFilter } from '../src/postgrest-filter'; import { PostgrestParser } from '../src/postgrest-parser'; @@ -42,6 +43,66 @@ describe('PostgrestFilter', () => { }); describe('.transform', () => { + it('should transform within arrays', () => { + expect( + new PostgrestFilter({ + filters: [ + { + or: [ + { + alias: undefined, + negate: false, + operator: 'eq', + path: 'id', + value: '846beb37-f4ca-4995-951e-067412e09095', + }, + ], + }, + ], + paths: [ + { declaration: 'id', alias: undefined, path: 'id' }, + { declaration: 'status', alias: undefined, path: 'status' }, + { declaration: 'unread', alias: undefined, path: 'unread' }, + { declaration: 'tag.id', alias: undefined, path: 'tag.id' }, + { + declaration: 'tag.name', + alias: undefined, + path: 'tag.name', + }, + { + declaration: 'tag.color', + alias: undefined, + path: 'tag.color', + }, + ], + }).denormalize({ + id: '846beb37-f4ca-4995-951e-067412e09095', + unread: false, + 'tag.0.id': '046beb37-f4ca-4995-951e-067412e09095', + 'tag.0.name': 'one', + 'tag.0.color': 'red', + 'tag.1.id': '146beb37-f4ca-4995-951e-067412e09095', + 'tag.1.name': 'two', + 'tag.1.color': 'blue', + }) + ).toEqual({ + id: '846beb37-f4ca-4995-951e-067412e09095', + unread: false, + tag: [ + { + id: '046beb37-f4ca-4995-951e-067412e09095', + name: 'one', + color: 'red', + }, + { + id: '146beb37-f4ca-4995-951e-067412e09095', + name: 'two', + color: 'blue', + }, + ], + }); + }); + it('should transform nested aliases within arrays', () => { expect( new PostgrestFilter({ @@ -74,21 +135,15 @@ describe('PostgrestFilter', () => { path: 'tag.color', }, ], - }).transform({ + }).denormalize({ id: '846beb37-f4ca-4995-951e-067412e09095', unread: false, - tag: [ - { - id: '046beb37-f4ca-4995-951e-067412e09095', - name: 'one', - color: 'red', - }, - { - id: '146beb37-f4ca-4995-951e-067412e09095', - name: 'two', - color: 'blue', - }, - ], + 'tag.0.id': '046beb37-f4ca-4995-951e-067412e09095', + 'tag.0.name': 'one', + 'tag.0.color': 'red', + 'tag.1.id': '146beb37-f4ca-4995-951e-067412e09095', + 'tag.1.name': 'two', + 'tag.1.color': 'blue', }) ).toEqual({ id: '846beb37-f4ca-4995-951e-067412e09095', @@ -138,13 +193,11 @@ describe('PostgrestFilter', () => { path: 'recipient_id.full_name', }, ], - }).transform({ + }).denormalize({ id: '846beb37-f4ca-4995-951e-067412e09095', unread: false, - recipient_id: { - id: '046beb37-f4ca-4995-951e-067412e09095', - full_name: 'test', - }, + 'recipient_id.id': '046beb37-f4ca-4995-951e-067412e09095', + 'recipient_id.full_name': 'test', }) ).toEqual({ id: '846beb37-f4ca-4995-951e-067412e09095', @@ -247,7 +300,7 @@ describe('PostgrestFilter', () => { path: 'assignee_id.display_name', }, ], - }).transform({ + }).denormalize({ id: '846beb37-f4ca-4995-951e-067412e09095', unread: false, }) @@ -277,7 +330,23 @@ describe('PostgrestFilter', () => { { path: 'array', declaration: 'array' }, { path: 'some.nested.value', declaration: 'some.nested.value' }, ], - }).transform(MOCK) + }).denormalize({ + id: 1, + text: 'some-text', + 'array.0': 'element-1', + 'array.1': 'element-2', + empty_array: [], + null_value: null, + 'array_of_objects.0.some.value': 'value', + 'array_of_objects.1.some.value': 'value', + 'invalid_array_of_objects.0.some.value': 'value', + 'invalid_array_of_objects.1.some.other': 'value', + date: '2023-09-11T17:17:51.457Z', + boolean: false, + 'some.nested.value': 'test', + 'some.nested.array.0.type': 'a', + 'some.nested.array.1.type': 'b', + }) ).toEqual({ array: ['element-1', 'element-2'], some: { diff --git a/packages/postgrest-core/__tests__/upsert-item.spec.ts b/packages/postgrest-core/__tests__/upsert-item.spec.ts index ef8fb4ae..964c1930 100644 --- a/packages/postgrest-core/__tests__/upsert-item.spec.ts +++ b/packages/postgrest-core/__tests__/upsert-item.spec.ts @@ -1,7 +1,7 @@ -import { mutate } from '../src/lib/mutate'; +import { mutate } from '../src/mutate/mutate'; import { upsertItem } from '../src/upsert-item'; -jest.mock('../src/lib/mutate', () => ({ +jest.mock('../src/mutate/mutate', () => ({ mutate: jest.fn().mockImplementation(() => jest.fn()), })); @@ -31,7 +31,7 @@ describe('upsertItem', () => { hasFiltersOnPaths() { return true; }, - transform: (obj) => obj, + denormalize: (obj) => obj, apply(obj: unknown): obj is ItemType { return true; }, diff --git a/packages/postgrest-core/package.json b/packages/postgrest-core/package.json index 5c5bbc95..2f7a6089 100644 --- a/packages/postgrest-core/package.json +++ b/packages/postgrest-core/package.json @@ -48,9 +48,10 @@ "@supabase/supabase-js": "^2.0.0" }, "dependencies": { - "fast-equals": "^5.0.1", + "fast-equals": "5.0.1", "merge-anything": "5.1.7", - "xregexp": "^5.1.1" + "flat": "5.0.2", + "xregexp": "5.1.1" }, "devDependencies": { "@supabase-cache-helpers/eslint-config-custom": "workspace:*", @@ -61,6 +62,7 @@ "@supabase/supabase-js": "2.26.0", "@types/jest": "29.5.0", "@types/lodash": "4.14.184", + "@types/flat": "5.0.2", "dotenv": "16.3.1", "eslint": "8.40.0", "jest": "29.6.1", diff --git a/packages/postgrest-core/src/delete-fetcher.ts b/packages/postgrest-core/src/delete-fetcher.ts index a737b426..a0c4ed6e 100644 --- a/packages/postgrest-core/src/delete-fetcher.ts +++ b/packages/postgrest-core/src/delete-fetcher.ts @@ -5,8 +5,8 @@ import { GenericTable, } from '@supabase/postgrest-js/dist/module/types'; -import { MutationFetcherResponse } from './lib/build-mutation-fetcher-response'; -import { BuildQueryOps } from './lib/build-query'; +import { MutationFetcherResponse } from './fetch/build-mutation-fetcher-response'; +import { BuildNormalizedQueryOps } from './fetch/build-normalized-query'; export type DeleteFetcher = ( input: Partial @@ -28,7 +28,7 @@ export const buildDeleteFetcher = >( qb: PostgrestQueryBuilder, primaryKeys: (keyof T['Row'])[], - opts: BuildQueryOps & DeleteFetcherOptions + opts: BuildNormalizedQueryOps & DeleteFetcherOptions ): DeleteFetcher => async ( input: Partial diff --git a/packages/postgrest-core/src/delete-item.ts b/packages/postgrest-core/src/delete-item.ts index 10cb8a77..d778804b 100644 --- a/packages/postgrest-core/src/delete-item.ts +++ b/packages/postgrest-core/src/delete-item.ts @@ -1,4 +1,4 @@ -import { Operation, mutate, Cache } from './lib/mutate'; +import { Operation, mutate, Cache } from './mutate/mutate'; export type DeleteItemProps> = Omit< Operation, diff --git a/packages/postgrest-core/src/fetch/build-mutation-fetcher-response.ts b/packages/postgrest-core/src/fetch/build-mutation-fetcher-response.ts new file mode 100644 index 00000000..43013a02 --- /dev/null +++ b/packages/postgrest-core/src/fetch/build-mutation-fetcher-response.ts @@ -0,0 +1,123 @@ +import flatten from 'flat'; + +import { get } from '../lib/get'; +import { + groupPathsRecursive, + isNestedPath, +} from '../lib/group-paths-recursive'; +import { Path } from '../lib/query-types'; +import { BuildNormalizedQueryReturn } from './build-normalized-query'; + +/** + * The parsed response of the mutation fetcher + **/ +export type MutationFetcherResponse = { + /** + * Normalized response. A flat json object with a depth of 1, where the keys are the full json paths. + **/ + normalizedData: R; + /** + * Result of the query passed by the user + **/ + userQueryData?: R; +}; + +export const buildMutationFetcherResponse = ( + /** + * response of the select query built by `buildNormalizedQuery`. contains dedupe aliases. + **/ + input: R, + { + paths, + userQueryPaths, + }: Pick +): MutationFetcherResponse => { + return { + normalizedData: normalizeResponse(paths, input), + userQueryData: userQueryPaths + ? buildUserQueryData(userQueryPaths, paths, input) + : undefined, + }; +}; + +/** + * Normalize the response by removing the dedupe alias and flattening it + **/ +const normalizeResponse = (paths: Path[], obj: R): R => { + const groups = groupPathsRecursive(paths); + + return groups.reduce((prev, curr) => { + // prefer alias over path because of dedupe alias + const value = get(obj, curr.alias || curr.path); + + if (typeof value === 'undefined') return prev; + if (value === null || !isNestedPath(curr)) { + return { + ...prev, + [curr.path]: value, + }; + } + if (Array.isArray(value)) { + return { + ...prev, + ...(flatten({ + [curr.path]: value.map((v) => normalizeResponse(curr.paths, v)), + }) as R), + }; + } + return { + ...prev, + ...flatten({ + [curr.path]: normalizeResponse( + curr.paths, + value as Record + ), + }), + }; + }, {} as R); +}; + +/** + * Build userQueryData from response + * + * note that `paths` is reflecting `obj`, not `userQueryPaths`. + * iterate over `userQueryPaths` and find the corresponding path in `paths`. + * Then, get value using the found alias and path from `obj`. + **/ +const buildUserQueryData = ( + userQueryPaths: Path[], + paths: Path[], + obj: R +): R => { + const userQueryGroups = groupPathsRecursive(userQueryPaths); + const pathGroups = groupPathsRecursive(paths); + + return userQueryGroups.reduce((prev, curr) => { + // paths is reflecting the obj + const inputPath = pathGroups.find( + (p) => p.path === curr.path && isNestedPath(p) === isNestedPath(curr) + ); + if (!inputPath) { + // should never happen though since userQueryPaths is a subset of paths + throw new Error(`Path ${curr.path} not found in response paths`); + } + const value = get(obj, inputPath.alias || inputPath.path); + + if (typeof value === 'undefined') return prev; + if (value === null || !isNestedPath(curr) || !isNestedPath(inputPath)) { + (prev as Record)[curr.alias ? curr.alias : curr.path] = + value; + } else if (Array.isArray(value)) { + (prev as Record)[curr.alias ? curr.alias : curr.path] = + value.map((v) => buildUserQueryData(curr.paths, inputPath.paths, v)); + } else { + (prev as Record)[curr.alias ? curr.alias : curr.path] = + buildUserQueryData( + curr.paths, + inputPath.paths, + value as Record + ); + } + return prev; + }, {} as R); +}; diff --git a/packages/postgrest-core/src/fetch/build-normalized-query.ts b/packages/postgrest-core/src/fetch/build-normalized-query.ts new file mode 100644 index 00000000..3465b94d --- /dev/null +++ b/packages/postgrest-core/src/fetch/build-normalized-query.ts @@ -0,0 +1,120 @@ +import { extractPathsFromFilters } from '../lib/extract-paths-from-filter'; +import { parseSelectParam } from '../lib/parse-select-param'; +import { + FilterDefinitions, + Path, + QueryWithoutWildcard, +} from '../lib/query-types'; +import { removeAliasFromDeclaration } from '../lib/remove-alias-from-declaration'; +import { buildSelectStatement } from './build-select-statement'; +import { buildDedupePath } from './dedupe'; + +export type BuildNormalizedQueryOps = { + query?: QueryWithoutWildcard | null; + // if true, will not add any paths from the cache to the query + disabled?: boolean; + queriesForTable: () => { paths: Path[]; filters: FilterDefinitions }[]; +}; + +export type BuildNormalizedQueryReturn = { + // The joint select query + selectQuery: string; + // All paths the user is querying for + userQueryPaths: Path[] | null; + // All paths the user is querying for + all paths that are currently loaded into the cache + paths: Path[]; +}; + +/** + * returns select statement that includes the users query + all paths currently loaded into cache to later perform a "smart update" + * + * the select statement does not contain any user-defined aliases. only custom ones to dedupe. + * without deduping, we would not be able to query inbox_id,inbox:inbox_id(name), + * because it will result in a select of inbox_id,inbox_id(name), which does not work. + * to dedupe, we add a custom alias to the query, e.g. dedupe_0:inbox_id,inbox_id(name) + * we then later remove them when normalizing the data + **/ +export const buildNormalizedQuery = ({ + query, + disabled, + queriesForTable, +}: BuildNormalizedQueryOps): BuildNormalizedQueryReturn | null => { + // parse user query + const userQueryPaths = query ? parseSelectParam(query) : null; + + // unique set of declaration without paths. + // alias not needed for paths + // declaration without alias! + let paths: Path[] = userQueryPaths + ? userQueryPaths.map((q) => ({ + declaration: removeAliasFromDeclaration(q.declaration), + path: q.path, + })) + : []; + + if (!disabled) { + for (const tableQuery of queriesForTable()) { + for (const filterPath of extractPathsFromFilters(tableQuery.filters)) { + // add paths used in filter + const path = tableQuery.paths.find( + (p) => p.path === filterPath.path && p.alias === filterPath.alias + ) ?? { + path: filterPath.path, + declaration: filterPath.path, + }; + // add unique + if ( + paths.every( + (p) => + removeAliasFromDeclaration(p.declaration) !== + removeAliasFromDeclaration(path.declaration) + ) + ) { + // do not use alias + paths.push({ + path: path.path, + declaration: removeAliasFromDeclaration(path.declaration), + }); + } + } + // add paths used in query + for (const path of tableQuery.paths) { + if ( + paths.every( + (p) => + removeAliasFromDeclaration(p.declaration) !== + removeAliasFromDeclaration(path.declaration) + ) && + // do not add agg functions + !path.declaration.endsWith('.count') + ) { + paths.push({ + path: path.path, + declaration: removeAliasFromDeclaration(path.declaration), + }); + } + } + } + } + + // dedupe paths by adding an alias to the shortest path, + // e.g. inbox_id,inbox_id(name) -> d_0:inbox_id,inbox_id(name), + let dedupeCounter = 0; + paths = paths.map((p, _, a) => { + // check if there is path that starts with the same declaration but is longer + // e.g. path is "inbox_id", and there is an "inbox_id(name)" in the cache + if (a.some((i) => i.path.startsWith(`${p.path}.`))) { + // if that is the case, add our dedupe alias to the query + // the alias has to be added to the last path element only, + // e.g. relation_id.some_id -> relation_id.d_0_some_id:some_id + return buildDedupePath(dedupeCounter++, p); + } else { + // otherwise, leave the path as is + return p; + } + }); + + const selectQuery = buildSelectStatement(paths); + if (selectQuery.length === 0) return null; + return { selectQuery, userQueryPaths, paths }; +}; diff --git a/packages/postgrest-core/src/lib/build-select-statement.ts b/packages/postgrest-core/src/fetch/build-select-statement.ts similarity index 73% rename from packages/postgrest-core/src/lib/build-select-statement.ts rename to packages/postgrest-core/src/fetch/build-select-statement.ts index 79e2227c..ef07146c 100644 --- a/packages/postgrest-core/src/lib/build-select-statement.ts +++ b/packages/postgrest-core/src/fetch/build-select-statement.ts @@ -1,5 +1,8 @@ -import { groupPathsRecursive, isNestedPath } from './group-paths-recursive'; -import { Path } from './query-types'; +import { + groupPathsRecursive, + isNestedPath, +} from '../lib/group-paths-recursive'; +import { Path } from '../lib/query-types'; // Transforms a list of Path[] into a select statement export const buildSelectStatement = (paths: Path[]): string => { diff --git a/packages/postgrest-core/src/fetch/dedupe.ts b/packages/postgrest-core/src/fetch/dedupe.ts new file mode 100644 index 00000000..9a438191 --- /dev/null +++ b/packages/postgrest-core/src/fetch/dedupe.ts @@ -0,0 +1,30 @@ +import { Path } from '../lib/query-types'; + +export const DEDUPE_ALIAS_PREFIX = 'd'; + +/** + * add dedupe alias to path + **/ +export const buildDedupePath = (idx: number, p: Path) => { + return { + path: p.path, + declaration: p.declaration + .split('.') + .map((el, i, a) => { + const withoutAlias = el.split(':').pop() as string; + if (i === a.length - 1) { + return `${[DEDUPE_ALIAS_PREFIX, idx, withoutAlias].join( + '_' + )}:${withoutAlias}`; + } + return withoutAlias; + }) + .join('.'), + alias: p.path + .split('.') + .map((el, i, a) => + i === a.length - 1 ? [DEDUPE_ALIAS_PREFIX, idx, el].join('_') : el + ) + .join('.'), + }; +}; diff --git a/packages/postgrest-core/src/filter/denormalize.ts b/packages/postgrest-core/src/filter/denormalize.ts new file mode 100644 index 00000000..1fa06b95 --- /dev/null +++ b/packages/postgrest-core/src/filter/denormalize.ts @@ -0,0 +1,91 @@ +import { + groupPathsRecursive, + isNestedPath, +} from '../lib/group-paths-recursive'; +import { Path } from '../lib/query-types'; + +/** + * Denormalize a normalized response object using the paths of the target query + * **/ +export const denormalize = >( + // the paths into which we need to transform + paths: Path[], + // the normalized response data + obj: R +): R => { + const groups = groupPathsRecursive(paths); + + return groups.reduce((prev, curr) => { + let value = obj[curr.path]; + + if (!isNestedPath(curr)) { + if (typeof value === 'undefined') { + // if simple array, e.g. ['a', 'b', 'c'], unflatten + const array = Object.entries(obj).reduce((prev, [k, v]) => { + // test if key is curr_path.0, curr_path.1 etc. + if (new RegExp(`^${curr.path}.\\d+$`).test(k)) { + prev.push(v); + } + return prev; + }, []); + if (array.length > 0) { + value = array; + } + } + if (typeof value === 'undefined') { + return prev; + } + return { + ...prev, + [curr.alias || curr.path]: value, + }; + } + + let isArray = false; + const flatNestedObjectOrArray = Object.entries(obj).reduce< + Record> | Record + >((prev, [k, v]) => { + const isNested = k.startsWith(`${curr.path}.`); + if (!isNested) return prev; + // either set to key, or to idx.key + const flatKey = k.slice(curr.path.length + 1); + const maybeIdx = flatKey.match(/^\b\d+\b/); + if (maybeIdx && isFlatNestedArray(prev)) { + isArray = true; + const key = flatKey.slice(maybeIdx[0].length + 1); + return { + ...prev, + [maybeIdx[0]]: { + ...(prev[maybeIdx[0]] ? prev[maybeIdx[0]] : {}), + [key]: v, + }, + }; + } + return { + ...prev, + [flatKey]: v, + }; + }, {}); + if (Object.keys(flatNestedObjectOrArray).length === 0) return prev; + if (isArray && isFlatNestedArray(flatNestedObjectOrArray)) { + return { + ...prev, + [curr.alias || curr.path]: Object.values(flatNestedObjectOrArray).map( + (v) => denormalize(curr.paths, v) + ), + }; + } + return { + ...prev, + [curr.alias || curr.path]: denormalize( + curr.paths, + flatNestedObjectOrArray + ), + }; + }, {} as R); +}; + +// just to make ts happy +const isFlatNestedArray = ( + obj: Record> | Record +): obj is Record> => true; diff --git a/packages/postgrest-core/src/index.ts b/packages/postgrest-core/src/index.ts index d028ab2f..2b4633fa 100644 --- a/packages/postgrest-core/src/index.ts +++ b/packages/postgrest-core/src/index.ts @@ -1,5 +1,6 @@ // cherry pick exports that are used by the adapter packages -export * from './lib/build-query'; +export * from './fetch/build-normalized-query'; +export * from './fetch/build-mutation-fetcher-response'; export * from './lib/query-types'; export * from './lib/mutator-types'; export * from './lib/get-table'; @@ -10,7 +11,6 @@ export * from './lib/is-postgrest-builder'; export * from './lib/get'; export * from './lib/set-filter-value'; export * from './lib/parse-value'; -export * from './lib/build-mutation-fetcher-response'; export * from './cursor-pagination-fetcher'; export * from './delete-fetcher'; diff --git a/packages/postgrest-core/src/insert-fetcher.ts b/packages/postgrest-core/src/insert-fetcher.ts index cbf2e8ce..3fde27f2 100644 --- a/packages/postgrest-core/src/insert-fetcher.ts +++ b/packages/postgrest-core/src/insert-fetcher.ts @@ -6,8 +6,11 @@ import { GenericSchema } from '@supabase/supabase-js/dist/module/lib/types'; import { buildMutationFetcherResponse, MutationFetcherResponse, -} from './lib/build-mutation-fetcher-response'; -import { BuildQueryOps, buildQuery } from './lib/build-query'; +} from './fetch/build-mutation-fetcher-response'; +import { + BuildNormalizedQueryOps, + buildNormalizedQuery, +} from './fetch/build-normalized-query'; export type InsertFetcher = ( input: T['Insert'][] @@ -27,12 +30,12 @@ function buildInsertFetcher< R = GetResult >( qb: PostgrestQueryBuilder, - opts: BuildQueryOps & InsertFetcherOptions + opts: BuildNormalizedQueryOps & InsertFetcherOptions ): InsertFetcher { return async ( input: T['Insert'][] ): Promise[] | null> => { - const query = buildQuery(opts); + const query = buildNormalizedQuery(opts); if (query) { const { selectQuery, userQueryPaths, paths } = query; const { data } = await qb diff --git a/packages/postgrest-core/src/lib/build-mutation-fetcher-response.ts b/packages/postgrest-core/src/lib/build-mutation-fetcher-response.ts deleted file mode 100644 index 09c3b893..00000000 --- a/packages/postgrest-core/src/lib/build-mutation-fetcher-response.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { BuildQueryReturn } from './build-query'; -import { transformRecursive } from './transform-recursive'; - -export type MutationFetcherResponse = { - // Normalized response - normalizedData: R; - // Result of query passed by user - userQueryData?: R; -}; - -export const buildMutationFetcherResponse = ( - input: R, - { paths, userQueryPaths }: Pick -): MutationFetcherResponse => ({ - normalizedData: transformRecursive(paths, input, 'path'), - userQueryData: userQueryPaths - ? transformRecursive(userQueryPaths, input, 'alias') - : undefined, -}); diff --git a/packages/postgrest-core/src/lib/build-query.ts b/packages/postgrest-core/src/lib/build-query.ts deleted file mode 100644 index a9e81e38..00000000 --- a/packages/postgrest-core/src/lib/build-query.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { buildSelectStatement } from './build-select-statement'; -import { extractPathsFromFilters } from './extract-paths-from-filter'; -import { parseSelectParam } from './parse-select-param'; -import { FilterDefinitions, Path, QueryWithoutWildcard } from './query-types'; -import { removeAliasFromDeclaration } from './remove-alias-from-declaration'; - -export type BuildQueryOps = { - query?: QueryWithoutWildcard | null; - disabled?: boolean; - queriesForTable: () => { paths: Path[]; filters: FilterDefinitions }[]; -}; - -export type BuildQueryReturn = { - selectQuery: string; - userQueryPaths: Path[] | null; - paths: Path[]; -}; - -// returns select statement that includes all paths currently loaded into cache to later perform a "smart update" -export const buildQuery = ({ - query, - disabled, - queriesForTable, -}: BuildQueryOps): BuildQueryReturn | null => { - // parse user query - const userQueryPaths = query ? parseSelectParam(query) : null; - - // cache data paths - // unique set of declaration without paths. - // alias not needed for paths - // declaration without alias! - const paths: Path[] = userQueryPaths - ? userQueryPaths.map((q) => ({ - declaration: removeAliasFromDeclaration(q.declaration), - path: q.path, - })) - : []; - if (!disabled) { - for (const tableQuery of queriesForTable()) { - for (const filterPath of extractPathsFromFilters(tableQuery.filters)) { - // add paths used in filter - const path = tableQuery.paths.find( - (p) => p.path === filterPath.path && p.alias === filterPath.alias - ) ?? { - ...filterPath, - declaration: filterPath.path, - }; - // add unique - if ( - paths.every( - (p) => - removeAliasFromDeclaration(p.declaration) !== - removeAliasFromDeclaration(path.declaration) - ) - ) { - // do not use alias - paths.push({ - path: path.path, - declaration: removeAliasFromDeclaration(path.declaration), - }); - } - } - // add paths used in query - for (const path of tableQuery.paths) { - if ( - paths.every( - (p) => - removeAliasFromDeclaration(p.declaration) !== - removeAliasFromDeclaration(path.declaration) - ) && - // do not add agg functions - !path.declaration.endsWith('.count') - ) { - // do not use alias - paths.push({ - path: path.path, - declaration: removeAliasFromDeclaration(path.declaration), - }); - } - } - } - } - const selectQuery = buildSelectStatement(paths); - if (selectQuery.length === 0) return null; - return { selectQuery, userQueryPaths, paths }; -}; diff --git a/packages/postgrest-core/src/lib/encode-object.ts b/packages/postgrest-core/src/lib/encode-object.ts index c7cdedfc..dcb47654 100644 --- a/packages/postgrest-core/src/lib/encode-object.ts +++ b/packages/postgrest-core/src/lib/encode-object.ts @@ -1,17 +1,16 @@ -import { get } from './get'; -import { getAllPaths } from './get-all-paths'; +import flatten from 'flat'; + import { sortSearchParams } from './sort-search-param'; /** * Encodes an object by url-encoding an ordered lists of all paths and their values. - * @param obj The object to encode - * @returns The encoded object */ export const encodeObject = (obj: Record): string => { - const paths = getAllPaths(obj).sort(); + const sortedEntries = Object.entries( + flatten(obj) as Record + ).sort(([a], [b]) => a.length - b.length); const bodyParams = new URLSearchParams(); - paths.forEach((key) => { - const value = get(obj, key); + sortedEntries.forEach(([key, value]) => { bodyParams.append(key, String(value)); }); return sortSearchParams(bodyParams).toString(); diff --git a/packages/postgrest-core/src/lib/get-all-paths.ts b/packages/postgrest-core/src/lib/get-all-paths.ts deleted file mode 100644 index d013a509..00000000 --- a/packages/postgrest-core/src/lib/get-all-paths.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Returns all paths of an object in dot notation - * @param obj - * @param prev - * @returns - */ -export const getAllPaths = ( - obj: Record, - prev = '' -): string[] => { - const result = []; - - for (const k in obj) { - const path = prev + (prev ? '.' : '') + k; - - if (typeof obj[k] == 'object') { - result.push(...getAllPaths(obj[k] as Record, path)); - } else result.push(path); - } - - return result; -}; diff --git a/packages/postgrest-core/src/lib/transform-recursive.ts b/packages/postgrest-core/src/lib/transform-recursive.ts deleted file mode 100644 index 8e1d5f67..00000000 --- a/packages/postgrest-core/src/lib/transform-recursive.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { get } from './get'; -import { groupPathsRecursive, isNestedPath } from './group-paths-recursive'; -import { Path } from './query-types'; - -export const transformRecursive = ( - paths: Path[], - obj: R, - // whether to return the value on the aliased path or the actual path - mode: 'alias' | 'path' -): R => { - const groups = groupPathsRecursive(paths); - - return groups.reduce((prev, curr) => { - const value = get(obj, curr.path); - if (typeof value === 'undefined') return prev; - if (value === null) { - (prev as Record)[ - mode === 'alias' && curr.alias ? curr.alias : curr.path - ] = value; - } else if (!isNestedPath(curr)) { - (prev as Record)[ - mode === 'alias' && curr.alias ? curr.alias : curr.path - ] = value; - } else if (Array.isArray(value)) { - (prev as Record)[ - mode === 'alias' && curr.alias ? curr.alias : curr.path - ] = value.map((v) => transformRecursive(curr.paths, v, mode)); - } else { - (prev as Record)[ - mode === 'alias' && curr.alias ? curr.alias : curr.path - ] = transformRecursive( - curr.paths, - value as Record, - mode - ); - } - return prev; - }, {} as R); -}; diff --git a/packages/postgrest-core/src/lib/build-delete-mutator-fn.ts b/packages/postgrest-core/src/mutate/build-delete-mutator-fn.ts similarity index 88% rename from packages/postgrest-core/src/lib/build-delete-mutator-fn.ts rename to packages/postgrest-core/src/mutate/build-delete-mutator-fn.ts index e1592323..c8ea1df9 100644 --- a/packages/postgrest-core/src/lib/build-delete-mutator-fn.ts +++ b/packages/postgrest-core/src/mutate/build-delete-mutator-fn.ts @@ -1,10 +1,10 @@ import { isPostgrestHasMorePaginationCacheData, isPostgrestPaginationCacheData, -} from './cache-data-types'; -import { MutatorFn } from './mutator-types'; -import { OrderDefinition } from './query-types'; -import { isAnyPostgrestResponse } from './response-types'; +} from '../lib/cache-data-types'; +import { MutatorFn } from '../lib/mutator-types'; +import { OrderDefinition } from '../lib/query-types'; +import { isAnyPostgrestResponse } from '../lib/response-types'; import { toHasMorePaginationCacheData, toPaginationCacheData, diff --git a/packages/postgrest-core/src/lib/build-upsert-mutator-fn.ts b/packages/postgrest-core/src/mutate/build-upsert-mutator-fn.ts similarity index 92% rename from packages/postgrest-core/src/lib/build-upsert-mutator-fn.ts rename to packages/postgrest-core/src/mutate/build-upsert-mutator-fn.ts index ec07632f..1fecd372 100644 --- a/packages/postgrest-core/src/lib/build-upsert-mutator-fn.ts +++ b/packages/postgrest-core/src/mutate/build-upsert-mutator-fn.ts @@ -1,14 +1,14 @@ import { merge as mergeAnything } from 'merge-anything'; -import { PostgrestFilter } from '../postgrest-filter'; import { isPostgrestHasMorePaginationCacheData, isPostgrestPaginationCacheData, -} from './cache-data-types'; -import { findIndexOrdered } from './find-index-ordered'; -import { MutatorFn, UpsertMutatorConfig } from './mutator-types'; -import { OrderDefinition } from './query-types'; -import { isAnyPostgrestResponse } from './response-types'; +} from '../lib/cache-data-types'; +import { findIndexOrdered } from '../lib/find-index-ordered'; +import { MutatorFn, UpsertMutatorConfig } from '../lib/mutator-types'; +import { OrderDefinition } from '../lib/query-types'; +import { isAnyPostgrestResponse } from '../lib/response-types'; +import { PostgrestFilter } from '../postgrest-filter'; import { toHasMorePaginationCacheData, toPaginationCacheData, diff --git a/packages/postgrest-core/src/lib/mutate.ts b/packages/postgrest-core/src/mutate/mutate.ts similarity index 95% rename from packages/postgrest-core/src/lib/mutate.ts rename to packages/postgrest-core/src/mutate/mutate.ts index c5270332..fe107757 100644 --- a/packages/postgrest-core/src/lib/mutate.ts +++ b/packages/postgrest-core/src/mutate/mutate.ts @@ -1,14 +1,14 @@ -import { PostgrestFilter } from '../postgrest-filter'; -import { PostgrestQueryParserOptions } from '../postgrest-query-parser'; -import { buildDeleteMutatorFn } from './build-delete-mutator-fn'; -import { buildUpsertMutatorFn } from './build-upsert-mutator-fn'; import { DecodedKey, MutatorFn, PostgrestMutatorOpts, UpsertMutatorConfig, -} from './mutator-types'; -import { parseOrderByKey } from './parse-order-by-key'; +} from '../lib/mutator-types'; +import { parseOrderByKey } from '../lib/parse-order-by-key'; +import { PostgrestFilter } from '../postgrest-filter'; +import { PostgrestQueryParserOptions } from '../postgrest-query-parser'; +import { buildDeleteMutatorFn } from './build-delete-mutator-fn'; +import { buildUpsertMutatorFn } from './build-upsert-mutator-fn'; export type OperationType = 'UPSERT' | 'DELETE'; @@ -41,7 +41,7 @@ export type Cache> = { | 'apply' | 'hasPaths' | 'applyFilters' - | 'transform' + | 'denormalize' | 'hasFiltersOnPaths' | 'applyFiltersOnPaths' >; @@ -74,7 +74,7 @@ export const mutate = async >( if (type === 'UPSERT') { const filter = getPostgrestFilter(key.queryKey); // parse input into expected target format - const transformedInput = filter.transform(input); + const transformedInput = filter.denormalize(input); if ( filter.applyFilters(transformedInput) || // also allow upsert if either the filter does not apply eq filters on any pk diff --git a/packages/postgrest-core/src/lib/transformers.ts b/packages/postgrest-core/src/mutate/transformers.ts similarity index 98% rename from packages/postgrest-core/src/lib/transformers.ts rename to packages/postgrest-core/src/mutate/transformers.ts index 7c1bf125..d7093603 100644 --- a/packages/postgrest-core/src/lib/transformers.ts +++ b/packages/postgrest-core/src/mutate/transformers.ts @@ -1,7 +1,7 @@ import { PostgrestHasMorePaginationCacheData, PostgrestPaginationCacheData, -} from './cache-data-types'; +} from '../lib/cache-data-types'; export const toHasMorePaginationCacheData = < Type extends Record diff --git a/packages/postgrest-core/src/postgrest-filter.ts b/packages/postgrest-core/src/postgrest-filter.ts index 87b92bbc..583ac63a 100644 --- a/packages/postgrest-core/src/postgrest-filter.ts +++ b/packages/postgrest-core/src/postgrest-filter.ts @@ -1,5 +1,6 @@ import { PostgrestBuilder } from '@supabase/postgrest-js'; +import { denormalize } from './filter/denormalize'; import { extractPathsFromFilters } from './lib/extract-paths-from-filter'; import { filterFilterDefinitionsByPaths } from './lib/filter-filter-definitions-by-paths'; import { get } from './lib/get'; @@ -14,7 +15,6 @@ import { Path, ValueType, } from './lib/query-types'; -import { transformRecursive } from './lib/transform-recursive'; import { PostgrestQueryParser, PostgrestQueryParserOptions, @@ -56,12 +56,8 @@ export class PostgrestFilter> { }); } - transform(obj: Record): Record { - return transformRecursive( - [...this.params.paths, ...this._filterPaths], - obj, - 'alias' - ); + denormalize(obj: Record): Record { + return denormalize([...this.params.paths, ...this._filterPaths], obj); } apply(obj: unknown): obj is Result { diff --git a/packages/postgrest-core/src/update-fetcher.ts b/packages/postgrest-core/src/update-fetcher.ts index e570b180..09015175 100644 --- a/packages/postgrest-core/src/update-fetcher.ts +++ b/packages/postgrest-core/src/update-fetcher.ts @@ -8,8 +8,11 @@ import { import { buildMutationFetcherResponse, MutationFetcherResponse, -} from './lib/build-mutation-fetcher-response'; -import { buildQuery, BuildQueryOps } from './lib/build-query'; +} from './fetch/build-mutation-fetcher-response'; +import { + buildNormalizedQuery, + BuildNormalizedQueryOps, +} from './fetch/build-normalized-query'; export type UpdateFetcher = ( input: Partial @@ -31,7 +34,7 @@ export const buildUpdateFetcher = >( qb: PostgrestQueryBuilder, primaryKeys: (keyof T['Row'])[], - opts: BuildQueryOps & UpdateFetcherOptions + opts: BuildNormalizedQueryOps & UpdateFetcherOptions ): UpdateFetcher => async ( input: Partial @@ -43,7 +46,7 @@ export const buildUpdateFetcher = throw new Error(`Missing value for primary key ${String(key)}`); filterBuilder = filterBuilder.eq(key as string, value); } - const query = buildQuery(opts); + const query = buildNormalizedQuery(opts); if (query) { const { selectQuery, userQueryPaths, paths } = query; const { data } = await filterBuilder diff --git a/packages/postgrest-core/src/upsert-fetcher.ts b/packages/postgrest-core/src/upsert-fetcher.ts index 4593d3f3..cabfca0a 100644 --- a/packages/postgrest-core/src/upsert-fetcher.ts +++ b/packages/postgrest-core/src/upsert-fetcher.ts @@ -6,8 +6,11 @@ import { GenericSchema } from '@supabase/supabase-js/dist/module/lib/types'; import { buildMutationFetcherResponse, MutationFetcherResponse, -} from './lib/build-mutation-fetcher-response'; -import { buildQuery, BuildQueryOps } from './lib/build-query'; +} from './fetch/build-mutation-fetcher-response'; +import { + buildNormalizedQuery, + BuildNormalizedQueryOps, +} from './fetch/build-normalized-query'; export type UpsertFetcher = ( input: T['Insert'][] @@ -28,12 +31,12 @@ export const buildUpsertFetcher = R = GetResult >( qb: PostgrestQueryBuilder, - opts: BuildQueryOps & UpsertFetcherOptions + opts: BuildNormalizedQueryOps & UpsertFetcherOptions ): UpsertFetcher => async ( input: T['Insert'][] ): Promise[] | null> => { - const query = buildQuery(opts); + const query = buildNormalizedQuery(opts); if (query) { const { selectQuery, userQueryPaths, paths } = query; const { data } = await qb diff --git a/packages/postgrest-core/src/upsert-item.ts b/packages/postgrest-core/src/upsert-item.ts index 2d299c3d..32aa5b07 100644 --- a/packages/postgrest-core/src/upsert-item.ts +++ b/packages/postgrest-core/src/upsert-item.ts @@ -1,5 +1,5 @@ -import { mutate, Operation, Cache } from './lib/mutate'; import { UpsertMutatorConfig } from './lib/mutator-types'; +import { mutate, Operation, Cache } from './mutate/mutate'; export type UpsertItemProps> = Omit< Operation, diff --git a/packages/postgrest-react-query/src/lib/use-queries-for-table-loader.ts b/packages/postgrest-react-query/src/lib/use-queries-for-table-loader.ts index 3bd73014..4f4f221f 100644 --- a/packages/postgrest-react-query/src/lib/use-queries-for-table-loader.ts +++ b/packages/postgrest-react-query/src/lib/use-queries-for-table-loader.ts @@ -1,4 +1,4 @@ -import { BuildQueryOps } from '@supabase-cache-helpers/postgrest-core'; +import { BuildNormalizedQueryOps } from '@supabase-cache-helpers/postgrest-core'; import { useQueryClient } from '@tanstack/react-query'; import { decode } from './key'; @@ -13,11 +13,14 @@ export const useQueriesForTableLoader = (table: string) => { .getQueryCache() .getAll() .map((c) => c.queryKey) - .reduce>((prev, curr) => { - const decodedKey = decode(curr); - if (decodedKey?.table === table) { - prev.push(getPostgrestFilter(decodedKey.queryKey).params); - } - return prev; - }, []); + .reduce>( + (prev, curr) => { + const decodedKey = decode(curr); + if (decodedKey?.table === table) { + prev.push(getPostgrestFilter(decodedKey.queryKey).params); + } + return prev; + }, + [] + ); }; diff --git a/packages/postgrest-react-query/src/subscribe/use-subscription-query.ts b/packages/postgrest-react-query/src/subscribe/use-subscription-query.ts index 73e60226..aa8640c8 100644 --- a/packages/postgrest-react-query/src/subscribe/use-subscription-query.ts +++ b/packages/postgrest-react-query/src/subscribe/use-subscription-query.ts @@ -1,5 +1,5 @@ import { - buildQuery, + buildNormalizedQuery, PostgrestMutatorOpts, } from '@supabase-cache-helpers/postgrest-core'; import { GetResult } from '@supabase/postgrest-js/dist/module/select-query-parser'; @@ -105,7 +105,7 @@ function useSubscriptionQuery< filter, async (payload) => { let data: T['Row'] | R = payload.new ?? payload.old; - const selectQuery = buildQuery({ queriesForTable, query }); + const selectQuery = buildNormalizedQuery({ queriesForTable, query }); if ( payload.eventType !== REALTIME_POSTGRES_CHANGES_LISTEN_EVENT.DELETE && diff --git a/packages/postgrest-swr/src/lib/use-queries-for-table-loader.ts b/packages/postgrest-swr/src/lib/use-queries-for-table-loader.ts index ed5b3c0a..7aea5acb 100644 --- a/packages/postgrest-swr/src/lib/use-queries-for-table-loader.ts +++ b/packages/postgrest-swr/src/lib/use-queries-for-table-loader.ts @@ -1,4 +1,4 @@ -import { BuildQueryOps } from '@supabase-cache-helpers/postgrest-core'; +import { BuildNormalizedQueryOps } from '@supabase-cache-helpers/postgrest-core'; import { useSWRConfig } from 'swr'; import { decode } from './decode'; @@ -10,7 +10,7 @@ export const useQueriesForTableLoader = (table: string) => { return () => Array.from(cache.keys()).reduce< - ReturnType + ReturnType >((prev, curr) => { const decodedKey = decode(curr); if (decodedKey?.table === table) { diff --git a/packages/postgrest-swr/src/subscribe/use-subscription-query.ts b/packages/postgrest-swr/src/subscribe/use-subscription-query.ts index 860f51e6..314d6f73 100644 --- a/packages/postgrest-swr/src/subscribe/use-subscription-query.ts +++ b/packages/postgrest-swr/src/subscribe/use-subscription-query.ts @@ -1,5 +1,5 @@ import { - buildQuery, + buildNormalizedQuery, PostgrestMutatorOpts, QueryWithoutWildcard, } from '@supabase-cache-helpers/postgrest-core'; @@ -108,7 +108,7 @@ function useSubscriptionQuery< filter, async (payload) => { let data: T['Row'] | R = payload.new ?? payload.old; - const selectQuery = buildQuery({ queriesForTable, query }); + const selectQuery = buildNormalizedQuery({ queriesForTable, query }); if ( payload.eventType !== REALTIME_POSTGRES_CHANGES_LISTEN_EVENT.DELETE && diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4c5d9b1..4d9cab84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -352,19 +352,22 @@ importers: '@supabase-cache-helpers/tsconfig': workspace:* '@supabase/postgrest-js': 1.7.2 '@supabase/supabase-js': 2.26.0 + '@types/flat': 5.0.2 '@types/jest': 29.5.0 '@types/lodash': 4.14.184 dotenv: 16.3.1 eslint: 8.40.0 - fast-equals: ^5.0.1 + fast-equals: 5.0.1 + flat: 5.0.2 jest: 29.6.1 merge-anything: 5.1.7 ts-jest: 29.1.0 tsup: 7.2.0 typescript: 5.0.4 - xregexp: ^5.1.1 + xregexp: 5.1.1 dependencies: fast-equals: 5.0.1 + flat: 5.0.2 merge-anything: 5.1.7 xregexp: 5.1.1 devDependencies: @@ -374,6 +377,7 @@ importers: '@supabase-cache-helpers/tsconfig': link:../tsconfig '@supabase/postgrest-js': 1.7.2 '@supabase/supabase-js': 2.26.0 + '@types/flat': 5.0.2 '@types/jest': 29.5.0 '@types/lodash': 4.14.184 dotenv: 16.3.1 @@ -2522,7 +2526,7 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 dependencies: - '@babel/runtime': 7.18.9 + '@babel/runtime': 7.21.0 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-compose-refs': 1.0.0_react@18.2.0 '@radix-ui/react-context': 1.0.0_react@18.2.0 @@ -3589,6 +3593,10 @@ packages: resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} dev: false + /@types/flat/5.0.2: + resolution: {integrity: sha512-3zsplnP2djeps5P9OyarTxwRpMLoe5Ash8aL9iprw0JxB+FAHjY+ifn4yZUuW4/9hqtnmor6uvjSRzJhiVbrEQ==} + dev: true + /@types/graceful-fs/4.1.5: resolution: {integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==} dependencies: @@ -6262,6 +6270,11 @@ packages: flatted: 3.2.6 rimraf: 3.0.2 + /flat/5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + dev: false + /flatted/3.2.6: resolution: {integrity: sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ==}