Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 20 additions & 16 deletions packages/core/postgrest-js/src/PostgrestFilterBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ export type IsStringOperator<Path extends string> = Path extends `${string}->>${

const PostgrestReservedCharsRegexp = new RegExp('[,()]')

/**
* Serialize a single value for an `in.(...)` / `not.in.(...)` filter list.
*
* PostgREST treats `,`, `(` and `)` as reserved characters inside the list, so
* any value containing one must be wrapped in double quotes. A quoted value may
* itself contain `"` or `\`, which PostgREST expects to be backslash-escaped
* (`"` -> `\"`, `\` -> `\\`). Without escaping, a value such as `a"b,c`
* serializes to `"a"b,c"`, whose stray quote corrupts the parsed list.
*/
function formatInFilterValue(value: unknown): string {
const str = `${value}`
if (typeof value === 'string' && (PostgrestReservedCharsRegexp.test(str) || /["\\]/.test(str))) {
// Escape backslashes first, then double quotes, then wrap in quotes.
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
return str
}

// Match relationship filters with `table.column` syntax and resolve underlying
// column value. If not matched, fallback to generic type.
// TODO: Validate the relationship itself ala select-query-parser. Currently we
Expand Down Expand Up @@ -835,14 +853,7 @@ export default class PostgrestFilterBuilder<
never
>
): this {
const cleanedValues = Array.from(new Set(values))
.map((s) => {
// handle postgrest reserved characters
// https://postgrest.org/en/v7.0.0/api.html#reserved-characters
if (typeof s === 'string' && PostgrestReservedCharsRegexp.test(s)) return `"${s}"`
else return `${s}`
})
.join(',')
const cleanedValues = Array.from(new Set(values)).map(formatInFilterValue).join(',')
this.url.searchParams.append(column, `in.(${cleanedValues})`)
return this
}
Expand All @@ -863,14 +874,7 @@ export default class PostgrestFilterBuilder<
: never
>
): this {
const cleanedValues = Array.from(new Set(values))
.map((s) => {
// handle postgrest reserved characters
// https://postgrest.org/en/v7.0.0/api.html#reserved-characters
if (typeof s === 'string' && PostgrestReservedCharsRegexp.test(s)) return `"${s}"`
else return `${s}`
})
.join(',')
const cleanedValues = Array.from(new Set(values)).map(formatInFilterValue).join(',')
this.url.searchParams.append(column, `not.in.(${cleanedValues})`)
return this
}
Expand Down
71 changes: 71 additions & 0 deletions packages/core/postgrest-js/test/in-filter-escaping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { PostgrestClient } from '../src/index'

const REST_URL = 'http://localhost:3000'

/**
* Build a client whose fetch records the request URL instead of hitting a
* server, so we can assert exactly how `.in()` / `.notIn()` serialize values.
*/
function clientCapturingUrl(): { postgrest: PostgrestClient; getUrl: () => string } {
let captured = ''
const fetchMock: typeof fetch = (input: RequestInfo | URL) => {
captured = typeof input === 'string' ? input : input.toString()
return Promise.resolve(new Response('[]', { status: 200 }))
}
const postgrest = new PostgrestClient(REST_URL, { fetch: fetchMock })
return { postgrest, getUrl: () => captured }
}

/** Decode the value of a query parameter from a raw (percent-encoded) URL. */
function paramValue(rawUrl: string, key: string): string {
const query = rawUrl.split('?')[1] ?? ''
for (const pair of query.split('&')) {
const eq = pair.indexOf('=')
// URLSearchParams encodes spaces as '+', which decodeURIComponent leaves as-is.
const decode = (s: string) => decodeURIComponent(s.replace(/\+/g, ' '))
const k = decode(pair.slice(0, eq))
if (k === key) return decode(pair.slice(eq + 1))
}
throw new Error(`param ${key} not found in ${rawUrl}`)
}

describe('in() / notIn() reserved-character escaping', () => {
test('quotes values containing reserved characters', async () => {
const { postgrest, getUrl } = clientCapturingUrl()
await postgrest.from('t').select().in('name', ['a,b', 'plain'])
expect(paramValue(getUrl(), 'name')).toBe('in.("a,b",plain)')
})

test('escapes an embedded double quote inside a quoted value', async () => {
const { postgrest, getUrl } = clientCapturingUrl()
// Value has both a reserved char (comma) and a double quote.
await postgrest.from('t').select().in('name', ['a"b,c'])
// The inner quote must be backslash-escaped so the quoted value stays intact.
expect(paramValue(getUrl(), 'name')).toBe('in.("a\\"b,c")')
})

test('escapes a value that contains only a double quote', async () => {
const { postgrest, getUrl } = clientCapturingUrl()
await postgrest.from('t').select().in('name', ['say "hi"'])
expect(paramValue(getUrl(), 'name')).toBe('in.("say \\"hi\\"")')
})

test('escapes backslashes inside a quoted value', async () => {
const { postgrest, getUrl } = clientCapturingUrl()
await postgrest.from('t').select().in('path', ['a\\b,c'])
// Backslash doubled, then wrapped in quotes.
expect(paramValue(getUrl(), 'path')).toBe('in.("a\\\\b,c")')
})

test('leaves simple values unquoted', async () => {
const { postgrest, getUrl } = clientCapturingUrl()
await postgrest.from('t').select().in('status', ['ONLINE', 'OFFLINE'])
expect(paramValue(getUrl(), 'status')).toBe('in.(ONLINE,OFFLINE)')
})

test('notIn() escapes the same way as in()', async () => {
const { postgrest, getUrl } = clientCapturingUrl()
await postgrest.from('t').select().notIn('name', ['a"b,c'])
expect(paramValue(getUrl(), 'name')).toBe('not.in.("a\\"b,c")')
})
})