From 23f25cefbfcefbdb0cf74af384f9cda20ced518f Mon Sep 17 00:00:00 2001 From: Ria Scholz <123465523+riascho@users.noreply.github.com> Date: Mon, 14 Apr 2025 17:56:07 +0200 Subject: [PATCH] feat(Supabase Node): Add support for database schema (#13339) Co-authored-by: Dana <152518854+dana-gill@users.noreply.github.com> Co-authored-by: Dana Lee --- .../nodes/Supabase/GenericFunctions.ts | 19 ++- .../nodes/Supabase/RowDescription.ts | 1 + .../nodes/Supabase/Supabase.node.ts | 18 +++ .../Supabase/tests/Supabase.node.test.ts | 111 +++++++++++++++++- 4 files changed, 141 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Supabase/GenericFunctions.ts b/packages/nodes-base/nodes/Supabase/GenericFunctions.ts index 50df54863e..6f92f35c44 100644 --- a/packages/nodes-base/nodes/Supabase/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Supabase/GenericFunctions.ts @@ -28,6 +28,15 @@ export async function supabaseApiRequest( serviceRole: string; }>('supabaseApi'); + if (this.getNodeParameter('useCustomSchema', false)) { + const schema = this.getNodeParameter('schema', 'public'); + if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) { + headers['Content-Profile'] = schema; + } else if (['GET', 'HEAD'].includes(method)) { + headers['Accept-Profile'] = schema; + } + } + const options: IRequestOptions = { headers: { Prefer: 'return=representation', @@ -35,18 +44,20 @@ export async function supabaseApiRequest( method, qs, body, - uri: uri || `${credentials.host}/rest/v1${resource}`, + uri: uri ?? `${credentials.host}/rest/v1${resource}`, json: true, }; + try { - if (Object.keys(headers).length !== 0) { - options.headers = Object.assign({}, options.headers, headers); - } + options.headers = Object.assign({}, options.headers, headers); if (Object.keys(body).length === 0) { delete options.body; } return await this.helpers.requestWithAuthentication.call(this, 'supabaseApi', options); } catch (error) { + if (error.description) { + error.message = `${error.message}: ${error.description}`; + } throw new NodeApiError(this.getNode(), error as JsonObject); } } diff --git a/packages/nodes-base/nodes/Supabase/RowDescription.ts b/packages/nodes-base/nodes/Supabase/RowDescription.ts index eafe601752..3d894bcef1 100644 --- a/packages/nodes-base/nodes/Supabase/RowDescription.ts +++ b/packages/nodes-base/nodes/Supabase/RowDescription.ts @@ -60,6 +60,7 @@ export const rowFields: INodeProperties[] = [ description: 'Choose from the list, or specify an ID using an expression', typeOptions: { + loadOptionsDependsOn: ['useCustomSchema', 'schema'], loadOptionsMethod: 'getTables', }, required: true, diff --git a/packages/nodes-base/nodes/Supabase/Supabase.node.ts b/packages/nodes-base/nodes/Supabase/Supabase.node.ts index 4853a6ad3f..ea42dfca4b 100644 --- a/packages/nodes-base/nodes/Supabase/Supabase.node.ts +++ b/packages/nodes-base/nodes/Supabase/Supabase.node.ts @@ -51,6 +51,24 @@ export class Supabase implements INodeType { }, ], properties: [ + { + displayName: 'Use Custom Schema', + name: 'useCustomSchema', + type: 'boolean', + default: false, + noDataExpression: true, + description: + 'Whether to use a database schema different from the default "public" schema (requires schema exposure in the Supabase API)', + }, + { + displayName: 'Schema', + name: 'schema', + type: 'string', + default: 'public', + description: 'Name of database schema to use for table', + noDataExpression: false, + displayOptions: { show: { useCustomSchema: [true] } }, + }, { displayName: 'Resource', name: 'resource', diff --git a/packages/nodes-base/nodes/Supabase/tests/Supabase.node.test.ts b/packages/nodes-base/nodes/Supabase/tests/Supabase.node.test.ts index f786426b3d..cba5ff173f 100644 --- a/packages/nodes-base/nodes/Supabase/tests/Supabase.node.test.ts +++ b/packages/nodes-base/nodes/Supabase/tests/Supabase.node.test.ts @@ -14,14 +14,22 @@ import { Supabase } from '../Supabase.node'; describe('Test Supabase Node', () => { const node = new Supabase(); - const input = [{ json: {} }]; + const mockRequestWithAuthentication = jest.fn().mockResolvedValue([]); + + beforeEach(() => { + jest.clearAllMocks(); + }); const createMockExecuteFunction = ( nodeParameters: IDataObject, continueOnFail: boolean = false, ) => { const fakeExecuteFunction = { + getCredentials: jest.fn().mockResolvedValue({ + host: 'https://api.supabase.io', + serviceRole: 'service_role', + }), getNodeParameter( parameterName: string, itemIndex: number, @@ -29,13 +37,10 @@ describe('Test Supabase Node', () => { options?: IGetNodeParameterOptions | undefined, ) { const parameter = options?.extractValue ? `${parameterName}.value` : parameterName; - const parameterValue = get(nodeParameters, parameter, fallbackValue); - if ((parameterValue as IDataObject)?.nodeOperationError) { throw new NodeOperationError(mock(), 'Get Options Error', { itemIndex }); } - return parameterValue; }, getNode() { @@ -44,6 +49,7 @@ describe('Test Supabase Node', () => { continueOnFail: () => continueOnFail, getInputData: () => input, helpers: { + requestWithAuthentication: mockRequestWithAuthentication, constructExecutionMetaData: ( _inputData: INodeExecutionData[], _options: { itemData: IPairedItemData | IPairedItemData[] }, @@ -95,5 +101,102 @@ describe('Test Supabase Node', () => { offset: 0, }, ); + + supabaseApiRequest.mockRestore(); + }); + + it('should not set schema headers if no custom schema is used', async () => { + const fakeExecuteFunction = createMockExecuteFunction({ + resource: 'row', + operation: 'getAll', + returnAll: true, + useCustomSchema: false, + schema: 'public', + tableId: 'my_table', + }); + + await node.execute.call(fakeExecuteFunction); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'supabaseApi', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Prefer: 'return=representation', + }), + uri: 'https://api.supabase.io/rest/v1/my_table', + }), + ); + }); + + it('should set the schema headers for GET calls if custom schema is used', async () => { + const fakeExecuteFunction = createMockExecuteFunction({ + resource: 'row', + operation: 'getAll', + returnAll: true, + useCustomSchema: true, + schema: 'custom_schema', + tableId: 'my_table', + }); + + await node.execute.call(fakeExecuteFunction); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'supabaseApi', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Accept-Profile': 'custom_schema', + Prefer: 'return=representation', + }), + uri: 'https://api.supabase.io/rest/v1/my_table', + }), + ); + }); + + it('should set the schema headers for POST calls if custom schema is used', async () => { + const fakeExecuteFunction = createMockExecuteFunction({ + resource: 'row', + operation: 'create', + returnAll: true, + useCustomSchema: true, + schema: 'custom_schema', + tableId: 'my_table', + }); + + await node.execute.call(fakeExecuteFunction); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'supabaseApi', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Profile': 'custom_schema', + Prefer: 'return=representation', + }), + uri: 'https://api.supabase.io/rest/v1/my_table', + }), + ); + }); + + it('should show descriptive message when error is caught', async () => { + const fakeExecuteFunction = createMockExecuteFunction({ + resource: 'row', + operation: 'create', + returnAll: true, + useCustomSchema: true, + schema: '', + tableId: 'my_table', + }); + + fakeExecuteFunction.helpers.requestWithAuthentication = jest.fn().mockRejectedValue({ + description: 'Something when wrong', + message: 'error', + }); + + await expect(node.execute.call(fakeExecuteFunction)).rejects.toHaveProperty( + 'message', + 'error: Something when wrong', + ); }); });