Skip to content

feat: postgrest 13 add maxaffected in client libraries #619

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 11 commits into
base: avallete/psql-436-feedback-request-postgrest-js-and-postgrest-v13-integration
Choose a base branch
from
Draft
31 changes: 29 additions & 2 deletions src/PostgrestFilterBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import PostgrestTransformBuilder from './PostgrestTransformBuilder'
import { JsonPathToAccessor, JsonPathToType } from './select-query-parser/utils'
import { ClientServerOptions, GenericSchema } from './types'
import { HeaderManager } from './utils'

type FilterOperator =
| 'eq'
Expand Down Expand Up @@ -77,14 +78,16 @@ export default class PostgrestFilterBuilder<
Row extends Record<string, unknown>,
Result,
RelationName = unknown,
Relationships = unknown
Relationships = unknown,
Method = unknown
> extends PostgrestTransformBuilder<
ClientOptions,
Schema,
Row,
Result,
RelationName,
Relationships
Relationships,
Method
> {
/**
* Match only rows where `column` is equal to `value`.
Expand Down Expand Up @@ -599,4 +602,28 @@ export default class PostgrestFilterBuilder<
this.url.searchParams.append(column, `${operator}.${value}`)
return this
}

/**
* Set the maximum number of rows that can be affected by the query.
* Only available in PostgREST v13+ and only works with PATCH and DELETE methods.
*
* @param value - The maximum number of rows that can be affected
*/
maxAffected(
value: number
): ClientOptions['postgrestVersion'] extends 13
? Method extends 'PATCH' | 'DELETE'
? this
: InvalidMethodError<'maxAffected method only available on update or delete'>
: InvalidMethodError<'maxAffected method only available on postgrest 13+'> {
const preferHeaderManager = new HeaderManager('Prefer', this.headers['Prefer'])
preferHeaderManager.add('handling=strict')
preferHeaderManager.add(`max-affected=${value}`)
this.headers['Prefer'] = preferHeaderManager.get()
return this as unknown as ClientOptions['postgrestVersion'] extends 13
? Method extends 'PATCH' | 'DELETE'
? this
: InvalidMethodError<'maxAffected method only available on update or delete'>
: InvalidMethodError<'maxAffected method only available on postgrest 13+'>
}
}
27 changes: 18 additions & 9 deletions src/PostgrestQueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ export default class PostgrestQueryBuilder<
Relation['Row'],
ResultOne[],
RelationName,
Relationships
Relationships,
'GET'
> {
const method = head ? 'HEAD' : 'GET'
// Remove whitespaces except when quoted
Expand Down Expand Up @@ -124,7 +125,8 @@ export default class PostgrestQueryBuilder<
Relation['Row'],
null,
RelationName,
Relationships
Relationships,
'POST'
>
insert<Row extends Relation extends { Insert: unknown } ? Relation['Insert'] : never>(
values: Row[],
Expand All @@ -138,7 +140,8 @@ export default class PostgrestQueryBuilder<
Relation['Row'],
null,
RelationName,
Relationships
Relationships,
'POST'
>
/**
* Perform an INSERT into the table or view.
Expand Down Expand Up @@ -181,7 +184,8 @@ export default class PostgrestQueryBuilder<
Relation['Row'],
null,
RelationName,
Relationships
Relationships,
'POST'
> {
const method = 'POST'

Expand Down Expand Up @@ -230,7 +234,8 @@ export default class PostgrestQueryBuilder<
Relation['Row'],
null,
RelationName,
Relationships
Relationships,
'POST'
>
upsert<Row extends Relation extends { Insert: unknown } ? Relation['Insert'] : never>(
values: Row[],
Expand All @@ -246,7 +251,8 @@ export default class PostgrestQueryBuilder<
Relation['Row'],
null,
RelationName,
Relationships
Relationships,
'POST'
>
/**
* Perform an UPSERT on the table or view. Depending on the column(s) passed
Expand Down Expand Up @@ -305,7 +311,8 @@ export default class PostgrestQueryBuilder<
Relation['Row'],
null,
RelationName,
Relationships
Relationships,
'POST'
> {
const method = 'POST'

Expand Down Expand Up @@ -376,7 +383,8 @@ export default class PostgrestQueryBuilder<
Relation['Row'],
null,
RelationName,
Relationships
Relationships,
'PATCH'
> {
const method = 'PATCH'
const prefersHeaders = []
Expand Down Expand Up @@ -428,7 +436,8 @@ export default class PostgrestQueryBuilder<
Relation['Row'],
null,
RelationName,
Relationships
Relationships,
'DELETE'
> {
const method = 'DELETE'
const prefersHeaders = []
Expand Down
64 changes: 64 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
export class HeaderManager {
private headers: Map<string, Set<string>> = new Map()

/**
* Create a new HeaderManager, optionally parsing an existing header string
* @param header The header name to manage
* @param existingValue Optional existing header value to parse
*/
constructor(private readonly header: string, existingValue?: string) {
if (existingValue) {
this.parseHeaderString(existingValue)
}
}

/**
* Parse an existing header string into the internal Set
* @param headerString The header string to parse
*/
private parseHeaderString(headerString: string): void {
if (!headerString.trim()) return

const values = headerString.split(',')
values.forEach((value) => {
const trimmedValue = value.trim()
if (trimmedValue) {
this.add(trimmedValue)
}
})
}

/**
* Add a value to the header. If the header doesn't exist, it will be created.
* @param value The value to add
*/
add(value: string): void {
if (!this.headers.has(this.header)) {
this.headers.set(this.header, new Set())
}
this.headers.get(this.header)!.add(value)
}

/**
* Get the formatted string value for the header
*/
get(): string {
const values = this.headers.get(this.header)
return values ? Array.from(values).join(',') : ''
}

/**
* Check if the header has a specific value
* @param value The value to check
*/
has(value: string): boolean {
return this.headers.get(this.header)?.has(value) ?? false
}

/**
* Clear all values for the header
*/
clear(): void {
this.headers.delete(this.header)
}
}
2 changes: 2 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ import './filters'
import './resource-embedding'
import './transforms'
import './rpc'
import './utils'
import './max-affected'
138 changes: 138 additions & 0 deletions test/max-affected.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { PostgrestClient } from '../src/index'
import { Database } from './types.override'
import { expectType } from 'tsd'
import { InvalidMethodError } from '../src/PostgrestFilterBuilder'

const REST_URL_13 = 'http://localhost:3001'
const postgrest13 = new PostgrestClient<Database, { postgrestVersion: 13 }>(REST_URL_13)
const postgrest12 = new PostgrestClient<Database>(REST_URL_13)

describe('maxAffected', () => {
// Type checking tests
test('maxAffected should show type warning on postgrest 12 clients', async () => {
const resUpdate = await postgrest12
.from('messages')
.update({ channel_id: 2 })
.eq('message', 'foo')
.maxAffected(1)
expectType<InvalidMethodError<'maxAffected method only available on postgrest 13+'>>(resUpdate)
})
test('maxAffected should show type warning on non update / delete', async () => {
const resSelect = await postgrest13.from('messages').select('*').maxAffected(10)
const resInsert = await postgrest13
.from('messages')
.insert({ message: 'foo', username: 'supabot', channel_id: 1 })
.maxAffected(10)
const resUpsert = await postgrest13
.from('messages')
.upsert({ id: 3, message: 'foo', username: 'supabot', channel_id: 2 })
.maxAffected(10)
const resUpdate = await postgrest13
.from('messages')
.update({ channel_id: 2 })
.eq('message', 'foo')
.maxAffected(1)
.select()
const resDelete = await postgrest13
.from('messages')
.delete()
.eq('message', 'foo')
.maxAffected(1)
.select()
expectType<InvalidMethodError<'maxAffected method only available on update or delete'>>(
resSelect
)
expectType<InvalidMethodError<'maxAffected method only available on update or delete'>>(
resInsert
)
expectType<InvalidMethodError<'maxAffected method only available on update or delete'>>(
resUpsert
)
expectType<InvalidMethodError<'maxAffected method only available on update or delete'>>(
// @ts-expect-error update method shouldn't return an error
resUpdate
)
expectType<InvalidMethodError<'maxAffected method only available on update or delete'>>(
// @ts-expect-error delete method shouldn't return an error
resDelete
)
})

// Runtime behavior tests
test('update should fail when maxAffected is exceeded', async () => {
// First create multiple rows
await postgrest13.from('messages').insert([
{ message: 'test1', username: 'supabot', channel_id: 1 },
{ message: 'test1', username: 'supabot', channel_id: 1 },
{ message: 'test1', username: 'supabot', channel_id: 1 },
])

// Try to update all rows with maxAffected=2
const result = await postgrest13
.from('messages')
.update({ message: 'updated' })
.eq('message', 'test1')
.maxAffected(2)
const { error } = result
expect(error).toBeDefined()
expect(error?.message).toBe('Query result exceeds max-affected preference constraint')
})

test('update should succeed when within maxAffected limit', async () => {
// First create a single row
await postgrest13
.from('messages')
.insert([{ message: 'test2', username: 'supabot', channel_id: 1 }])

// Try to update with maxAffected=2
const { data, error } = await postgrest13
.from('messages')
.update({ message: 'updated' })
.eq('message', 'test2')
.maxAffected(2)
.select()

expect(error).toBeNull()
expect(data).toHaveLength(1)
expect(data?.[0].message).toBe('updated')
})

test('delete should fail when maxAffected is exceeded', async () => {
// First create multiple rows
await postgrest13.from('messages').insert([
{ message: 'test3', username: 'supabot', channel_id: 1 },
{ message: 'test3', username: 'supabot', channel_id: 1 },
{ message: 'test3', username: 'supabot', channel_id: 1 },
])

// Try to delete all rows with maxAffected=2
const { error } = await postgrest13
.from('messages')
.delete()
.eq('message', 'test3')
.maxAffected(2)
.select()

expect(error).toBeDefined()
expect(error?.message).toBe('Query result exceeds max-affected preference constraint')
})

test('delete should succeed when within maxAffected limit', async () => {
// First create a single row
await postgrest13
.from('messages')
.insert([{ message: 'test4', username: 'supabot', channel_id: 1 }])

// Try to delete with maxAffected=2
const { data, error } = await postgrest13
.from('messages')
.delete()
.eq('message', 'test4')
.maxAffected(2)
.select()

expect(error).toBeNull()
expect(data).toHaveLength(1)
expect(data?.[0].message).toBe('test4')
})
})
Loading