diff --git a/package.json b/package.json index 136fc994..d7dff530 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "build": "run-s clean format build:*", "build:main": "tsc -p tsconfig.json", "build:module": "tsc -p tsconfig.module.json", - "test": "run-s test:db && jest -i", + "test": "run-s test:db && jest --runInBand", + "test:clean": "cd test/db && docker-compose down", "test:db": "cd test/db && docker-compose down && docker-compose up -d && sleep 5", "docs": "typedoc --mode file --target ES6 --theme minimal", "docs:json": "typedoc --json docs/spec.json --mode modules --includeDeclarations --excludeExternals" diff --git a/src/PostgrestClient.ts b/src/PostgrestClient.ts new file mode 100644 index 00000000..84f261df --- /dev/null +++ b/src/PostgrestClient.ts @@ -0,0 +1,57 @@ +import PostgrestQueryBuilder from './lib/PostgrestQueryBuilder' +import { PostgrestBuilder } from './lib/types' + +export default class PostgrestClient { + url: string + headers: { [key: string]: string } + schema?: string + + /** + * Creates a PostgREST client. + * + * @param url URL of the PostgREST endpoint. + * @param headers Custom headers. + * @param schema Postgres schema to switch to. + */ + constructor( + url: string, + { headers = {}, schema }: { headers?: { [key: string]: string }; schema?: string } = {} + ) { + this.url = url + this.headers = headers + this.schema = schema + } + + /** + * Authenticates the request with JWT. + * + * @param token The JWT token to use. + */ + auth(token: string): this { + this.headers['Authorization'] = `Bearer ${token}` + return this + } + + /** + * Perform a table operation. + * + * @param table The table name to operate on. + */ + from(table: string): PostgrestQueryBuilder { + const url = `${this.url}/${table}` + return new PostgrestQueryBuilder(url, { headers: this.headers, schema: this.schema }) + } + + /** + * Perform a stored procedure call. + * + * @param fn The function name to call. + * @param params The parameters to pass to the function call. + */ + rpc(fn: string, params?: object): PostgrestBuilder { + const url = `${this.url}/rpc/${fn}` + return new PostgrestQueryBuilder(url, { headers: this.headers, schema: this.schema }).rpc( + params + ) + } +} diff --git a/src/index.ts b/src/index.ts index 35244fd8..3579dc30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,56 +1,6 @@ -import { PostgrestBuilder, PostgrestQueryBuilder } from './builder' +import PostgrestClient from './PostgrestClient' +import PostgrestFilterBuilder from './lib/PostgrestFilterBuilder' +import PostgrestQueryBuilder from './lib/PostgrestQueryBuilder' +import { PostgrestBuilder } from './lib/types' -export class PostgrestClient { - url: string - headers: { [key: string]: string } - schema?: string - - /** - * Creates a PostgREST client. - * - * @param url URL of the PostgREST endpoint. - * @param headers Custom headers. - * @param schema Postgres schema to switch to. - */ - constructor( - url: string, - { headers = {}, schema }: { headers?: { [key: string]: string }; schema?: string } = {} - ) { - this.url = url - this.headers = headers - this.schema = schema - } - - /** - * Authenticates the request with JWT. - * - * @param token The JWT token to use. - */ - auth(token: string): this { - this.headers['Authorization'] = `Bearer ${token}` - return this - } - - /** - * Perform a table operation. - * - * @param table The table name to operate on. - */ - from(table: string): PostgrestQueryBuilder { - const url = `${this.url}/${table}` - return new PostgrestQueryBuilder(url, { headers: this.headers, schema: this.schema }) - } - - /** - * Perform a stored procedure call. - * - * @param fn The function name to call. - * @param params The parameters to pass to the function call. - */ - rpc(fn: string, params?: object): PostgrestBuilder { - const url = `${this.url}/rpc/${fn}` - return new PostgrestQueryBuilder(url, { headers: this.headers, schema: this.schema }).rpc( - params - ) - } -} +export { PostgrestClient, PostgrestFilterBuilder, PostgrestQueryBuilder, PostgrestBuilder } diff --git a/src/builder.ts b/src/lib/PostgrestFilterBuilder.ts similarity index 61% rename from src/builder.ts rename to src/lib/PostgrestFilterBuilder.ts index 2763574b..94c83eb8 100644 --- a/src/builder.ts +++ b/src/lib/PostgrestFilterBuilder.ts @@ -1,247 +1,4 @@ -import fetch from 'cross-fetch' - -/** - * Error format - * - * {@link https://postgrest.org/en/stable/api.html?highlight=options#errors-and-http-status-codes} - */ -interface PostgrestError { - message: string - details: string - hint: string - code: string -} - -/** - * Response format - * - * {@link https://github.com/supabase/supabase-js/issues/32} - */ -interface PostgrestResponse { - error: PostgrestError | null - data: T | T[] | null - status: number - statusText: string - // For backward compatibility: body === data - body: T | T[] | null -} - -/** - * Base builder - */ - -export abstract class PostgrestBuilder implements PromiseLike { - method!: 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'DELETE' - url!: URL - headers!: { [key: string]: string } - schema?: string - body?: Partial | Partial[] - - constructor(builder: PostgrestBuilder) { - Object.assign(this, builder) - } - - then(onfulfilled?: (value: any) => any, onrejected?: (value: any) => any): Promise { - // https://postgrest.org/en/stable/api.html#switching-schemas - if (typeof this.schema === 'undefined') { - // skip - } else if (['GET', 'HEAD'].includes(this.method)) { - this.headers['Accept-Profile'] = this.schema - } else { - this.headers['Content-Profile'] = this.schema - } - if (this.method !== 'GET' && this.method !== 'HEAD') { - this.headers['Content-Type'] = 'application/json' - } - - return fetch(this.url.toString(), { - method: this.method, - headers: this.headers, - body: JSON.stringify(this.body), - }) - .then(async (res) => { - let error, data - if (res.ok) { - error = null - data = await res.json() - } else { - error = await res.json() - data = null - } - return { - error, - data, - status: res.status, - statusText: res.statusText, - body: data, - } as PostgrestResponse - }) - .then(onfulfilled, onrejected) - } -} - -/** - * CRUD - */ - -export class PostgrestQueryBuilder extends PostgrestBuilder { - constructor( - url: string, - { headers = {}, schema }: { headers?: { [key: string]: string }; schema?: string } = {} - ) { - super({} as PostgrestBuilder) - this.url = new URL(url) - this.headers = { ...headers } - this.schema = schema - } - - /** - * Performs horizontal filtering with SELECT. - * - * @param columns The columns to retrieve, separated by commas. - */ - select(columns = '*'): PostgrestFilterBuilder { - this.method = 'GET' - // Remove whitespaces except when quoted - let quoted = false - const cleanedColumns = columns - .split('') - .map((c) => { - if (/\s/.test(c) && !quoted) { - return '' - } - if (c === '"') { - quoted = !quoted - } - return c - }) - .join('') - this.url.searchParams.set('select', cleanedColumns) - return new PostgrestFilterBuilder(this) - } - - /** - * Performs an INSERT into the table. - * - * @param values The values to insert. - * @param upsert If `true`, performs an UPSERT. - * @param onConflict By specifying the `on_conflict` query parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. - */ - insert( - values: Partial | Partial[], - { upsert = false, onConflict }: { upsert?: boolean; onConflict?: string } = {} - ): PostgrestFilterBuilder { - this.method = 'POST' - this.headers['Prefer'] = upsert - ? 'return=representation,resolution=merge-duplicates' - : 'return=representation' - if (upsert && onConflict !== undefined) this.url.searchParams.set('on_conflict', onConflict) - this.body = values - return new PostgrestFilterBuilder(this) - } - - /** - * Performs an UPDATE on the table. - * - * @param values The values to update. - */ - update(values: Partial): PostgrestFilterBuilder { - this.method = 'PATCH' - this.headers['Prefer'] = 'return=representation' - this.body = values - return new PostgrestFilterBuilder(this) - } - - /** - * Performs a DELETE on the table. - */ - delete(): PostgrestFilterBuilder { - this.method = 'DELETE' - this.headers['Prefer'] = 'return=representation' - return new PostgrestFilterBuilder(this) - } - - /** @internal */ - rpc(params?: object): PostgrestBuilder { - this.method = 'POST' - this.body = params - return this - } -} - -/** - * Post-filters (transforms) - */ - -class PostgrestTransformBuilder extends PostgrestBuilder { - /** - * Orders the result with the specified `column`. - * - * @param column The column to order on. - * @param ascending If `true`, the result will be in ascending order. - * @param nullsFirst If `true`, `null`s appear first. - * @param foreignTable The foreign table to use (if `column` is a foreign column). - */ - order( - column: keyof T, - { - ascending = true, - nullsFirst = false, - foreignTable, - }: { ascending?: boolean; nullsFirst?: boolean; foreignTable?: string } = {} - ): PostgrestTransformBuilder { - const key = typeof foreignTable === 'undefined' ? 'order' : `"${foreignTable}".order` - this.url.searchParams.set( - key, - `"${column}".${ascending ? 'asc' : 'desc'}.${nullsFirst ? 'nullsfirst' : 'nullslast'}` - ) - return this - } - - /** - * Limits the result with the specified `count`. - * - * @param count The maximum no. of rows to limit to. - * @param foreignTable The foreign table to use (for foreign columns). - */ - limit( - count: number, - { foreignTable }: { foreignTable?: string } = {} - ): PostgrestTransformBuilder { - const key = typeof foreignTable === 'undefined' ? 'limit' : `"${foreignTable}".limit` - this.url.searchParams.set(key, `${count}`) - return this - } - - /** - * Limits the result to rows within the specified range, inclusive. - * - * @param from The starting index from which to limit the result, inclusive. - * @param to The last index to which to limit the result, inclusive. - * @param foreignTable The foreign table to use (for foreign columns). - */ - range( - from: number, - to: number, - { foreignTable }: { foreignTable?: string } = {} - ): PostgrestTransformBuilder { - const keyOffset = typeof foreignTable === 'undefined' ? 'offset' : `"${foreignTable}".offset` - const keyLimit = typeof foreignTable === 'undefined' ? 'limit' : `"${foreignTable}".limit` - this.url.searchParams.set(keyOffset, `${from}`) - // Range is inclusive, so add 1 - this.url.searchParams.set(keyLimit, `${to - from + 1}`) - return this - } - - /** - * Retrieves only one row from the result. Result must be one row (e.g. using - * `limit`), otherwise this will result in an error. - */ - single(): PostgrestTransformBuilder { - this.headers['Accept'] = 'application/vnd.pgrst.object+json' - return this - } -} +import PostgrestTransformBuilder from './PostgrestTransformBuilder' /** * Filters @@ -273,7 +30,7 @@ type FilterOperator = | 'phfts' | 'wfts' -class PostgrestFilterBuilder extends PostgrestTransformBuilder { +export default class PostgrestFilterBuilder extends PostgrestTransformBuilder { /** * Finds all rows which doesn't satisfy the filter. * diff --git a/src/lib/PostgrestQueryBuilder.ts b/src/lib/PostgrestQueryBuilder.ts new file mode 100644 index 00000000..121e9bfc --- /dev/null +++ b/src/lib/PostgrestQueryBuilder.ts @@ -0,0 +1,91 @@ +import { PostgrestBuilder } from './types' +import PostgrestFilterBuilder from './PostgrestFilterBuilder' + +/** + * CRUD + */ + +export default class PostgrestQueryBuilder extends PostgrestBuilder { + constructor( + url: string, + { headers = {}, schema }: { headers?: { [key: string]: string }; schema?: string } = {} + ) { + super({} as PostgrestBuilder) + this.url = new URL(url) + this.headers = { ...headers } + this.schema = schema + } + + /** + * Performs horizontal filtering with SELECT. + * + * @param columns The columns to retrieve, separated by commas. + */ + select(columns = '*'): PostgrestFilterBuilder { + this.method = 'GET' + // Remove whitespaces except when quoted + let quoted = false + const cleanedColumns = columns + .split('') + .map((c) => { + if (/\s/.test(c) && !quoted) { + return '' + } + if (c === '"') { + quoted = !quoted + } + return c + }) + .join('') + this.url.searchParams.set('select', cleanedColumns) + return new PostgrestFilterBuilder(this) + } + + /** + * Performs an INSERT into the table. + * + * @param values The values to insert. + * @param upsert If `true`, performs an UPSERT. + * @param onConflict By specifying the `on_conflict` query parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. + */ + insert( + values: Partial | Partial[], + { upsert = false, onConflict }: { upsert?: boolean; onConflict?: string } = {} + ): PostgrestFilterBuilder { + this.method = 'POST' + this.headers['Prefer'] = upsert + ? 'return=representation,resolution=merge-duplicates' + : 'return=representation' + if (upsert && onConflict !== undefined) this.url.searchParams.set('on_conflict', onConflict) + this.body = values + return new PostgrestFilterBuilder(this) + } + + /** + * Performs an UPDATE on the table. + * + * @param values The values to update. + */ + update(values: Partial): PostgrestFilterBuilder { + this.method = 'PATCH' + this.headers['Prefer'] = 'return=representation' + this.body = values + return new PostgrestFilterBuilder(this) + } + + /** + * Performs a DELETE on the table. + */ + delete(): PostgrestFilterBuilder { + this.method = 'DELETE' + this.headers['Prefer'] = 'return=representation' + return new PostgrestFilterBuilder(this) + } + + /** @internal */ + rpc(params?: object): PostgrestBuilder { + this.method = 'POST' + this.body = params + return this + } +} diff --git a/src/lib/PostgrestTransformBuilder.ts b/src/lib/PostgrestTransformBuilder.ts new file mode 100644 index 00000000..b45208b6 --- /dev/null +++ b/src/lib/PostgrestTransformBuilder.ts @@ -0,0 +1,75 @@ +import { PostgrestBuilder } from './types' + +/** + * Post-filters (transforms) + */ + +export default class PostgrestTransformBuilder extends PostgrestBuilder { + /** + * Orders the result with the specified `column`. + * + * @param column The column to order on. + * @param ascending If `true`, the result will be in ascending order. + * @param nullsFirst If `true`, `null`s appear first. + * @param foreignTable The foreign table to use (if `column` is a foreign column). + */ + order( + column: keyof T, + { + ascending = true, + nullsFirst = false, + foreignTable, + }: { ascending?: boolean; nullsFirst?: boolean; foreignTable?: string } = {} + ): PostgrestTransformBuilder { + const key = typeof foreignTable === 'undefined' ? 'order' : `"${foreignTable}".order` + this.url.searchParams.set( + key, + `"${column}".${ascending ? 'asc' : 'desc'}.${nullsFirst ? 'nullsfirst' : 'nullslast'}` + ) + return this + } + + /** + * Limits the result with the specified `count`. + * + * @param count The maximum no. of rows to limit to. + * @param foreignTable The foreign table to use (for foreign columns). + */ + limit( + count: number, + { foreignTable }: { foreignTable?: string } = {} + ): PostgrestTransformBuilder { + const key = typeof foreignTable === 'undefined' ? 'limit' : `"${foreignTable}".limit` + this.url.searchParams.set(key, `${count}`) + return this + } + + /** + * Limits the result to rows within the specified range, inclusive. + * + * @param from The starting index from which to limit the result, inclusive. + * @param to The last index to which to limit the result, inclusive. + * @param foreignTable The foreign table to use (for foreign columns). + */ + range( + from: number, + to: number, + { foreignTable }: { foreignTable?: string } = {} + ): PostgrestTransformBuilder { + const keyOffset = typeof foreignTable === 'undefined' ? 'offset' : `"${foreignTable}".offset` + const keyLimit = typeof foreignTable === 'undefined' ? 'limit' : `"${foreignTable}".limit` + this.url.searchParams.set(keyOffset, `${from}`) + // Range is inclusive, so add 1 + this.url.searchParams.set(keyLimit, `${to - from + 1}`) + return this + } + + /** + * Retrieves only one row from the result. Result must be one row (e.g. using + * `limit`), otherwise this will result in an error. + */ + single(): PostgrestTransformBuilder { + this.headers['Accept'] = 'application/vnd.pgrst.object+json' + return this + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 00000000..5e5ceaed --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,77 @@ +import fetch from 'cross-fetch' + +/** + * Error format + * + * {@link https://postgrest.org/en/stable/api.html?highlight=options#errors-and-http-status-codes} + */ +interface PostgrestError { + message: string + details: string + hint: string + code: string +} + +/** + * Response format + * + * {@link https://github.com/supabase/supabase-js/issues/32} + */ +interface PostgrestResponse { + error: PostgrestError | null + data: T | T[] | null + status: number + statusText: string + // For backward compatibility: body === data + body: T | T[] | null +} + +export abstract class PostgrestBuilder implements PromiseLike { + method!: 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'DELETE' + url!: URL + headers!: { [key: string]: string } + schema?: string + body?: Partial | Partial[] + + constructor(builder: PostgrestBuilder) { + Object.assign(this, builder) + } + + then(onfulfilled?: (value: any) => any, onrejected?: (value: any) => any): Promise { + // https://postgrest.org/en/stable/api.html#switching-schemas + if (typeof this.schema === 'undefined') { + // skip + } else if (['GET', 'HEAD'].includes(this.method)) { + this.headers['Accept-Profile'] = this.schema + } else { + this.headers['Content-Profile'] = this.schema + } + if (this.method !== 'GET' && this.method !== 'HEAD') { + this.headers['Content-Type'] = 'application/json' + } + + return fetch(this.url.toString(), { + method: this.method, + headers: this.headers, + body: JSON.stringify(this.body), + }) + .then(async (res) => { + let error, data + if (res.ok) { + error = null + data = await res.json() + } else { + error = await res.json() + data = null + } + return { + error, + data, + status: res.status, + statusText: res.statusText, + body: data, + } as PostgrestResponse + }) + .then(onfulfilled, onrejected) + } +}