Skip to content

Commit eed189f

Browse files
7vigneshstenlan
andcommitted
feat(postgrest): expose query builder options to .from method
Reimplement the feature from scratch on latest master, addressing all review feedback from mandarini: 1. PostgrestQueryBuilderOptions type added to postgrest-js with headers, fetch, urlLengthLimit, and retry fields. 2. PostgrestClient.from() now accepts an optional options parameter. Per-request headers are merged (request takes precedence), and per-request fetch/retry/urlLengthLimit override client defaults. 3. SupabaseClient.from() forwards options to rest.from(), wrapping any custom fetch with fetchWithAuth so auth headers are always injected. A new options object is created to avoid mutating the caller's object. 4. mergeHeaders utility added for non-destructive header merging. 5. Tests cover: custom fetch usage, header precedence, auth injection, retry override, and non-mutation of caller options. Co-authored-by: stenlan <stenlan@users.noreply.github.com> Closes #438
1 parent 21e410f commit eed189f

6 files changed

Lines changed: 193 additions & 12 deletions

File tree

packages/core/postgrest-js/src/PostgrestClient.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import PostgrestQueryBuilder from './PostgrestQueryBuilder'
22
import PostgrestFilterBuilder from './PostgrestFilterBuilder'
33
import { Fetch, GenericSchema, ClientServerOptions } from './types/common/common'
44
import { GetRpcFunctionFilterBuilderByArgs } from './types/common/rpc'
5+
import { PostgrestQueryBuilderOptions } from './types/types'
6+
import { mergeHeaders } from './utils'
57

68
/**
79
* PostgREST client.
@@ -156,24 +158,29 @@ export default class PostgrestClient<
156158
from<
157159
TableName extends string & keyof Schema['Tables'],
158160
Table extends Schema['Tables'][TableName],
159-
>(relation: TableName): PostgrestQueryBuilder<ClientOptions, Schema, Table, TableName>
161+
>(
162+
relation: TableName,
163+
options?: PostgrestQueryBuilderOptions
164+
): PostgrestQueryBuilder<ClientOptions, Schema, Table, TableName>
160165
from<ViewName extends string & keyof Schema['Views'], View extends Schema['Views'][ViewName]>(
161-
relation: ViewName
166+
relation: ViewName,
167+
options?: PostgrestQueryBuilderOptions
162168
): PostgrestQueryBuilder<ClientOptions, Schema, View, ViewName>
163169
from(
164-
relation: (string & keyof Schema['Tables']) | (string & keyof Schema['Views'])
170+
relation: (string & keyof Schema['Tables']) | (string & keyof Schema['Views']),
171+
options?: PostgrestQueryBuilderOptions
165172
): PostgrestQueryBuilder<ClientOptions, Schema, any, any> {
166173
if (!relation || typeof relation !== 'string' || relation.trim() === '') {
167174
throw new Error('Invalid relation name: relation must be a non-empty string.')
168175
}
169176

170177
const url = new URL(`${this.url}/${relation}`)
171178
return new PostgrestQueryBuilder(url, {
172-
headers: new Headers(this.headers),
179+
headers: mergeHeaders(this.headers, options?.headers),
173180
schema: this.schemaName,
174-
fetch: this.fetch,
175-
urlLengthLimit: this.urlLengthLimit,
176-
retry: this.retry,
181+
fetch: options?.fetch ?? this.fetch,
182+
urlLengthLimit: options?.urlLengthLimit ?? this.urlLengthLimit,
183+
retry: options?.retry ?? this.retry,
177184
})
178185
}
179186

packages/core/postgrest-js/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type {
2727
PostgrestResponseSuccess,
2828
PostgrestSingleResponse,
2929
PostgrestMaybeSingleResponse,
30+
PostgrestQueryBuilderOptions,
3031
} from './types/types'
3132
export type { ClientServerOptions as PostgrestClientOptions } from './types/common/common'
3233
// https://github.com/supabase/postgrest-js/issues/551

packages/core/postgrest-js/src/types/types.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import PostgrestError from '../PostgrestError'
22
import { ContainsNull } from '../select-query-parser/types'
33
import { SelectQueryError } from '../select-query-parser/utils'
4-
import { ClientServerOptions } from './common/common'
4+
import { ClientServerOptions, Fetch } from './common/common'
55

66
/**
77
* Response format
@@ -40,6 +40,27 @@ export type PostgrestSingleResponse<T> = PostgrestResponseSuccess<T> | Postgrest
4040
export type PostgrestMaybeSingleResponse<T> = PostgrestSingleResponse<T | null>
4141
export type PostgrestResponse<T> = PostgrestSingleResponse<T[]>
4242

43+
/**
44+
* Per-request options for `.from()` queries. These override the
45+
* corresponding client-level defaults for a single query.
46+
*/
47+
export type PostgrestQueryBuilderOptions = {
48+
/** Additional headers to merge with the client-level headers. Per-request headers take precedence. */
49+
headers?: HeadersInit
50+
/** A custom fetch implementation for this request only. Auth headers are injected automatically. */
51+
fetch?: Fetch
52+
/** Override the client-level URL length limit for this request. */
53+
urlLengthLimit?: number
54+
/** Override the client-level retry setting for this request. */
55+
retry?: boolean
56+
}
57+
58+
/** @internal */
59+
export type PostgrestQueryBuilderOptionsWithSchema<TSchema extends string> =
60+
PostgrestQueryBuilderOptions & {
61+
schema?: TSchema
62+
}
63+
4364
export type DatabaseWithOptions<Database, Options extends ClientServerOptions> = {
4465
db: Database
4566
options: Options
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Non-destructively merges an optional {@link HeadersInit} into a base
3+
* {@link Headers} object. Right-side entries take precedence over left.
4+
*/
5+
export function mergeHeaders(left: Headers, right?: HeadersInit): Headers {
6+
const merged = new Headers(left)
7+
8+
if (!right) return merged
9+
10+
const entries =
11+
right instanceof Headers
12+
? right.entries()
13+
: Array.isArray(right)
14+
? right
15+
: Object.entries(right)
16+
17+
for (const [key, value] of entries) {
18+
merged.set(key, value)
19+
}
20+
21+
return merged
22+
}

packages/core/supabase-js/src/SupabaseClient.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
PostgrestClient,
55
type PostgrestFilterBuilder,
66
type PostgrestQueryBuilder,
7+
type PostgrestQueryBuilderOptions,
78
} from '@supabase/postgrest-js'
89
import {
910
type RealtimeChannel,
@@ -429,17 +430,38 @@ export default class SupabaseClient<
429430
from<
430431
TableName extends string & keyof Schema['Tables'],
431432
Table extends Schema['Tables'][TableName],
432-
>(relation: TableName): PostgrestQueryBuilder<ClientOptions, Schema, Table, TableName>
433+
>(
434+
relation: TableName,
435+
options?: PostgrestQueryBuilderOptions
436+
): PostgrestQueryBuilder<ClientOptions, Schema, Table, TableName>
433437
from<ViewName extends string & keyof Schema['Views'], View extends Schema['Views'][ViewName]>(
434-
relation: ViewName
438+
relation: ViewName,
439+
options?: PostgrestQueryBuilderOptions
435440
): PostgrestQueryBuilder<ClientOptions, Schema, View, ViewName>
436441
/**
437442
* Perform a query on a table or a view.
438443
*
439444
* @param relation - The table or view name to query
445+
* @param options - Per-request options that override client-level defaults
440446
*/
441-
from(relation: string): PostgrestQueryBuilder<ClientOptions, Schema, any> {
442-
return this.rest.from(relation)
447+
from(
448+
relation: string,
449+
options?: PostgrestQueryBuilderOptions
450+
): PostgrestQueryBuilder<ClientOptions, Schema, any> {
451+
const resolvedOptions: PostgrestQueryBuilderOptions | undefined = options?.fetch
452+
? {
453+
...options,
454+
fetch: fetchWithAuth(
455+
this.supabaseKey,
456+
this.supabaseUrl,
457+
this._getSessionToken.bind(this),
458+
options.fetch,
459+
this.settings?.tracePropagation
460+
),
461+
}
462+
: options
463+
464+
return this.rest.from(relation, resolvedOptions)
443465
}
444466

445467
// NOTE: signatures must be kept in sync with PostgrestClient.schema

packages/core/supabase-js/test/unit/SupabaseClient.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,114 @@ describe('SupabaseClient', () => {
123123
})
124124
})
125125

126+
describe('PostgREST per-request options', () => {
127+
test('should use custom fetch passed via options parameter', async () => {
128+
const clientFetch = jest
129+
.fn()
130+
.mockResolvedValue(
131+
new Response(JSON.stringify([]), {
132+
status: 200,
133+
headers: { 'content-type': 'application/json' },
134+
})
135+
)
136+
const requestFetch = jest
137+
.fn()
138+
.mockResolvedValue(
139+
new Response(JSON.stringify([]), {
140+
status: 200,
141+
headers: { 'content-type': 'application/json' },
142+
})
143+
)
144+
145+
const client = createClient(URL, KEY, {
146+
global: { fetch: clientFetch },
147+
})
148+
149+
await client.from('users', { fetch: requestFetch }).select()
150+
151+
expect(requestFetch).toHaveBeenCalled()
152+
expect(clientFetch).not.toHaveBeenCalled()
153+
})
154+
155+
test('should prefer per-request header over client-level header with same name', async () => {
156+
const mockFetch = jest
157+
.fn()
158+
.mockResolvedValue(
159+
new Response(JSON.stringify([]), {
160+
status: 200,
161+
headers: { 'content-type': 'application/json' },
162+
})
163+
)
164+
165+
const client = createClient(URL, KEY, {
166+
global: {
167+
headers: { 'X-Custom': 'client-value' },
168+
fetch: mockFetch,
169+
},
170+
})
171+
172+
await client.from('users', { headers: { 'X-Custom': 'request-value' } }).select()
173+
174+
const calledHeaders = mockFetch.mock.calls[0][1]?.headers
175+
const headers = calledHeaders instanceof Headers ? calledHeaders : new Headers(calledHeaders)
176+
expect(headers.get('X-Custom')).toBe('request-value')
177+
})
178+
179+
test('should inject auth headers when a custom per-request fetch is provided', async () => {
180+
const requestFetch = jest
181+
.fn()
182+
.mockResolvedValue(
183+
new Response(JSON.stringify([]), {
184+
status: 200,
185+
headers: { 'content-type': 'application/json' },
186+
})
187+
)
188+
189+
const client = createClient(URL, KEY)
190+
191+
await client.from('users', { fetch: requestFetch }).select()
192+
193+
expect(requestFetch).toHaveBeenCalled()
194+
const calledHeaders = requestFetch.mock.calls[0][1]?.headers
195+
const headers = calledHeaders instanceof Headers ? calledHeaders : new Headers(calledHeaders)
196+
expect(headers.get('apikey')).toBe(KEY)
197+
})
198+
199+
test('should forward per-request retry option', () => {
200+
const client = createClient(URL, KEY)
201+
202+
// Default retry is true
203+
// @ts-expect-error retryEnabled is protected
204+
const defaultRetry = client.from('users').select().retryEnabled
205+
expect(defaultRetry).toBe(true)
206+
207+
// Per-request override to false
208+
// @ts-expect-error retryEnabled is protected
209+
const overriddenRetry = client.from('users', { retry: false }).select().retryEnabled
210+
expect(overriddenRetry).toBe(false)
211+
})
212+
213+
test('should not mutate the caller options object', async () => {
214+
const requestFetch = jest
215+
.fn()
216+
.mockResolvedValue(
217+
new Response(JSON.stringify([]), {
218+
status: 200,
219+
headers: { 'content-type': 'application/json' },
220+
})
221+
)
222+
223+
const client = createClient(URL, KEY)
224+
const options = { fetch: requestFetch }
225+
const originalFetch = options.fetch
226+
227+
await client.from('users', options).select()
228+
229+
// The options object should not have been mutated
230+
expect(options.fetch).toBe(originalFetch)
231+
})
232+
})
233+
126234
describe('Custom Headers', () => {
127235
test('should have custom header set', () => {
128236
const customHeader = { 'X-Test-Header': 'value' }

0 commit comments

Comments
 (0)