From 76a9a94afc88c4346de7fed7c8196080a52a575f Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Sat, 8 Apr 2023 17:10:23 -0500 Subject: [PATCH 1/7] test: postgrest 11 pre-release --- test/db/docker-compose.yml | 2 +- test/transforms.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/db/docker-compose.yml b/test/db/docker-compose.yml index 7dcb8c15..ac61a3b1 100644 --- a/test/db/docker-compose.yml +++ b/test/db/docker-compose.yml @@ -3,7 +3,7 @@ version: '3' services: rest: - image: postgrest/postgrest:v10.1.2 + image: postgrest/postgrest:v10.2.0.20230407 ports: - '3000:3000' environment: diff --git a/test/transforms.ts b/test/transforms.ts index 8d82fa66..c1074cc8 100644 --- a/test/transforms.ts +++ b/test/transforms.ts @@ -298,7 +298,7 @@ test('explain with json/text format', async () => { ], "Startup Cost": 17.65, "Strategy": "Plain", - "Total Cost": 17.68, + "Total Cost": 17.67, }, }, ], @@ -310,7 +310,7 @@ test('explain with json/text format', async () => { const res2 = await postgrest.from('users').select().explain() expect(res2.data).toMatch( - `Aggregate (cost=17.65..17.68 rows=1 width=112) + `Aggregate (cost=17.65..17.67 rows=1 width=112) -> Seq Scan on users (cost=0.00..15.10 rows=510 width=132) ` ) @@ -332,7 +332,7 @@ test('explain with options', async () => { "Output": Array [ "NULL::bigint", "count(ROW(users.username, users.data, users.age_range, users.status, users.catchphrase))", - "(COALESCE(json_agg(ROW(users.username, users.data, users.age_range, users.status, users.catchphrase)), '[]'::json))::character varying", + "COALESCE(json_agg(ROW(users.username, users.data, users.age_range, users.status, users.catchphrase)), '[]'::json)", "NULLIF(current_setting('response.headers'::text, true), ''::text)", "NULLIF(current_setting('response.status'::text, true), ''::text)", ], @@ -364,9 +364,9 @@ test('explain with options', async () => { ], "Startup Cost": 17.65, "Strategy": "Plain", - "Total Cost": 17.68, + "Total Cost": 17.67, }, - "Query Identifier": -6192475787150577000, + "Query Identifier": 3302819211508333000, "Settings": Object { "effective_cache_size": "128MB", "search_path": "\\"public\\", \\"extensions\\"", From 62b49e10caacca11e5b1cdb59d2fa220e849c204 Mon Sep 17 00:00:00 2001 From: Bobbie Soedirgo Date: Fri, 14 Apr 2023 17:53:46 +0800 Subject: [PATCH 2/7] feat: bulk inserts/upserts w/ column defaults --- src/PostgrestQueryBuilder.ts | 39 ++++++++++++++++++++++++------------ test/basic.ts | 18 +++++++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/PostgrestQueryBuilder.ts b/src/PostgrestQueryBuilder.ts index 53c3d461..d779d54b 100644 --- a/src/PostgrestQueryBuilder.ts +++ b/src/PostgrestQueryBuilder.ts @@ -113,24 +113,31 @@ export default class PostgrestQueryBuilder< * * `"estimated"`: Uses exact count for low numbers and planned count for high * numbers. + * + * @param options.defaultToNull - Make missing fields default to `null`. + * Otherwise, use the default value for the column. */ insert( values: Row | Row[], { count, + defaultToNull = true, }: { count?: 'exact' | 'planned' | 'estimated' + defaultToNull?: boolean } = {} ): PostgrestFilterBuilder { const method = 'POST' const prefersHeaders = [] - const body = values + if (this.headers['Prefer']) { + prefersHeaders.push(this.headers['Prefer']) + } if (count) { prefersHeaders.push(`count=${count}`) } - if (this.headers['Prefer']) { - prefersHeaders.unshift(this.headers['Prefer']) + if (!defaultToNull) { + prefersHeaders.push('missing=default') } this.headers['Prefer'] = prefersHeaders.join(',') @@ -147,7 +154,7 @@ export default class PostgrestQueryBuilder< url: this.url, headers: this.headers, schema: this.schema, - body, + body: values, fetch: this.fetch, allowEmpty: false, } as unknown as PostgrestBuilder) @@ -185,6 +192,9 @@ export default class PostgrestQueryBuilder< * * `"estimated"`: Uses exact count for low numbers and planned count for high * numbers. + * + * @param options.defaultToNull - Make missing fields default to `null`. + * Otherwise, use the default value for the column. */ upsert( values: Row | Row[], @@ -192,10 +202,12 @@ export default class PostgrestQueryBuilder< onConflict, ignoreDuplicates = false, count, + defaultToNull = true, }: { onConflict?: string ignoreDuplicates?: boolean count?: 'exact' | 'planned' | 'estimated' + defaultToNull?: boolean } = {} ): PostgrestFilterBuilder { const method = 'POST' @@ -203,12 +215,14 @@ export default class PostgrestQueryBuilder< const prefersHeaders = [`resolution=${ignoreDuplicates ? 'ignore' : 'merge'}-duplicates`] if (onConflict !== undefined) this.url.searchParams.set('on_conflict', onConflict) - const body = values + if (this.headers['Prefer']) { + prefersHeaders.push(this.headers['Prefer']) + } if (count) { prefersHeaders.push(`count=${count}`) } - if (this.headers['Prefer']) { - prefersHeaders.unshift(this.headers['Prefer']) + if (!defaultToNull) { + prefersHeaders.push('missing=default') } this.headers['Prefer'] = prefersHeaders.join(',') @@ -217,7 +231,7 @@ export default class PostgrestQueryBuilder< url: this.url, headers: this.headers, schema: this.schema, - body, + body: values, fetch: this.fetch, allowEmpty: false, } as unknown as PostgrestBuilder) @@ -254,13 +268,12 @@ export default class PostgrestQueryBuilder< ): PostgrestFilterBuilder { const method = 'PATCH' const prefersHeaders = [] - const body = values + if (this.headers['Prefer']) { + prefersHeaders.push(this.headers['Prefer']) + } if (count) { prefersHeaders.push(`count=${count}`) } - if (this.headers['Prefer']) { - prefersHeaders.unshift(this.headers['Prefer']) - } this.headers['Prefer'] = prefersHeaders.join(',') return new PostgrestFilterBuilder({ @@ -268,7 +281,7 @@ export default class PostgrestQueryBuilder< url: this.url, headers: this.headers, schema: this.schema, - body, + body: values, fetch: this.fetch, allowEmpty: false, } as unknown as PostgrestBuilder) diff --git a/test/basic.ts b/test/basic.ts index 15059ec4..6fb2c9be 100644 --- a/test/basic.ts +++ b/test/basic.ts @@ -1078,6 +1078,24 @@ describe("insert, update, delete with count: 'exact'", () => { `) }) + test('bulk insert with column defaults', async () => { + let res = await postgrest + .from('channels') + .insert([{ id: 100 }, { slug: 'test-slug' }], { defaultToNull: false }) + .select() + .rollback() + expect(res).toMatchInlineSnapshot() + }) + + test('bulk upsert with column defaults', async () => { + let res = await postgrest + .from('channels') + .upsert([{ id: 1 }, { slug: 'test-slug' }], { defaultToNull: false }) + .select() + .rollback() + expect(res).toMatchInlineSnapshot() + }) + test("update with count: 'exact'", async () => { let res = await postgrest .from('messages') From adf4839e87779df24616e407e325de05db1c40dd Mon Sep 17 00:00:00 2001 From: Bobbie Soedirgo Date: Fri, 14 Apr 2023 18:32:06 +0800 Subject: [PATCH 3/7] test: make snapshots more deterministic --- src/types.ts | 4 +- test/index.test-d.ts | 2 +- test/transforms.ts | 102 ++++++++++++------------------------------- 3 files changed, 29 insertions(+), 79 deletions(-) diff --git a/src/types.ts b/src/types.ts index 480e8260..5379b271 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,9 +35,7 @@ export interface PostgrestResponseFailure extends PostgrestResponseBase { // TODO: in v3: // - remove PostgrestResponse and PostgrestMaybeSingleResponse // - rename PostgrestSingleResponse to PostgrestResponse -export type PostgrestSingleResponse = - | PostgrestResponseSuccess - | PostgrestResponseFailure +export type PostgrestSingleResponse = PostgrestResponseSuccess | PostgrestResponseFailure export type PostgrestMaybeSingleResponse = PostgrestSingleResponse export type PostgrestResponse = PostgrestSingleResponse diff --git a/test/index.test-d.ts b/test/index.test-d.ts index b6d388d0..b8bc95c7 100644 --- a/test/index.test-d.ts +++ b/test/index.test-d.ts @@ -52,7 +52,7 @@ const postgrest = new PostgrestClient(REST_URL) if (error) { throw new Error(error.message) } - expectType<{ bar: Json, baz: string }>(data) + expectType<{ bar: Json; baz: string }>(data) } // rpc return type diff --git a/test/transforms.ts b/test/transforms.ts index c1074cc8..b50d7728 100644 --- a/test/transforms.ts +++ b/test/transforms.ts @@ -270,50 +270,31 @@ test('abort signal', async () => { test('explain with json/text format', async () => { const res1 = await postgrest.from('users').select().explain({ format: 'json' }) - expect(res1).toMatchInlineSnapshot(` + expect(res1).toMatchInlineSnapshot( + { + data: [ + { + Plan: expect.any(Object), + }, + ], + }, + ` Object { "count": null, "data": Array [ Object { - "Plan": Object { - "Async Capable": false, - "Node Type": "Aggregate", - "Parallel Aware": false, - "Partial Mode": "Simple", - "Plan Rows": 1, - "Plan Width": 112, - "Plans": Array [ - Object { - "Alias": "users", - "Async Capable": false, - "Node Type": "Seq Scan", - "Parallel Aware": false, - "Parent Relationship": "Outer", - "Plan Rows": 510, - "Plan Width": 132, - "Relation Name": "users", - "Startup Cost": 0, - "Total Cost": 15.1, - }, - ], - "Startup Cost": 17.65, - "Strategy": "Plain", - "Total Cost": 17.67, - }, + "Plan": Any, }, ], "error": null, "status": 200, "statusText": "OK", } - `) + ` + ) const res2 = await postgrest.from('users').select().explain() - expect(res2.data).toMatch( - `Aggregate (cost=17.65..17.67 rows=1 width=112) - -> Seq Scan on users (cost=0.00..15.10 rows=510 width=132) -` - ) + expect(res2.data).toMatch(/Aggregate \(cost=.*/) }) test('explain with options', async () => { @@ -321,52 +302,22 @@ test('explain with options', async () => { .from('users') .select() .explain({ verbose: true, settings: true, format: 'json' }) - expect(res).toMatchInlineSnapshot(` + expect(res).toMatchInlineSnapshot( + { + data: [ + { + Plan: expect.any(Object), + 'Query Identifier': expect.any(Number), + }, + ], + }, + ` Object { "count": null, "data": Array [ Object { - "Plan": Object { - "Async Capable": false, - "Node Type": "Aggregate", - "Output": Array [ - "NULL::bigint", - "count(ROW(users.username, users.data, users.age_range, users.status, users.catchphrase))", - "COALESCE(json_agg(ROW(users.username, users.data, users.age_range, users.status, users.catchphrase)), '[]'::json)", - "NULLIF(current_setting('response.headers'::text, true), ''::text)", - "NULLIF(current_setting('response.status'::text, true), ''::text)", - ], - "Parallel Aware": false, - "Partial Mode": "Simple", - "Plan Rows": 1, - "Plan Width": 112, - "Plans": Array [ - Object { - "Alias": "users", - "Async Capable": false, - "Node Type": "Seq Scan", - "Output": Array [ - "users.username", - "users.data", - "users.age_range", - "users.status", - "users.catchphrase", - ], - "Parallel Aware": false, - "Parent Relationship": "Outer", - "Plan Rows": 510, - "Plan Width": 132, - "Relation Name": "users", - "Schema": "public", - "Startup Cost": 0, - "Total Cost": 15.1, - }, - ], - "Startup Cost": 17.65, - "Strategy": "Plain", - "Total Cost": 17.67, - }, - "Query Identifier": 3302819211508333000, + "Plan": Any, + "Query Identifier": Any, "Settings": Object { "effective_cache_size": "128MB", "search_path": "\\"public\\", \\"extensions\\"", @@ -377,7 +328,8 @@ test('explain with options', async () => { "status": 200, "statusText": "OK", } - `) + ` + ) }) test('rollback insert/upsert', async () => { From 5ad7562c5a115647e86f41f0e0756c53e168348a Mon Sep 17 00:00:00 2001 From: Steve Chavez Date: Sun, 16 Apr 2023 17:16:36 -0500 Subject: [PATCH 4/7] Update docker-compose.yml to postgrest v11 --- test/db/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/db/docker-compose.yml b/test/db/docker-compose.yml index ac61a3b1..fd8da688 100644 --- a/test/db/docker-compose.yml +++ b/test/db/docker-compose.yml @@ -3,7 +3,7 @@ version: '3' services: rest: - image: postgrest/postgrest:v10.2.0.20230407 + image: postgrest/postgrest:v11.0.0 ports: - '3000:3000' environment: From a9c3629698e25e6571436c60ed4bb5b15fb1357d Mon Sep 17 00:00:00 2001 From: Bobbie Soedirgo Date: Mon, 17 Apr 2023 16:34:30 +0800 Subject: [PATCH 5/7] fix: use `?columns=` on upsert --- src/PostgrestQueryBuilder.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/PostgrestQueryBuilder.ts b/src/PostgrestQueryBuilder.ts index d779d54b..956d473a 100644 --- a/src/PostgrestQueryBuilder.ts +++ b/src/PostgrestQueryBuilder.ts @@ -194,7 +194,9 @@ export default class PostgrestQueryBuilder< * numbers. * * @param options.defaultToNull - Make missing fields default to `null`. - * Otherwise, use the default value for the column. + * Otherwise, use the default value for the column. This only applies when + * inserting new rows, not when merging with existing rows under + * `ignoreDuplicates: false`. */ upsert( values: Row | Row[], @@ -226,6 +228,14 @@ export default class PostgrestQueryBuilder< } this.headers['Prefer'] = prefersHeaders.join(',') + if (Array.isArray(values)) { + const columns = values.reduce((acc, x) => acc.concat(Object.keys(x)), [] as string[]) + if (columns.length > 0) { + const uniqueColumns = [...new Set(columns)].map((column) => `"${column}"`) + this.url.searchParams.set('columns', uniqueColumns.join(',')) + } + } + return new PostgrestFilterBuilder({ method, url: this.url, From 05577526fa3126f8923dbecd828527d48f36fa15 Mon Sep 17 00:00:00 2001 From: Bobbie Soedirgo Date: Mon, 17 Apr 2023 16:35:46 +0800 Subject: [PATCH 6/7] test: update snapshots --- test/basic.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/test/basic.ts b/test/basic.ts index 6fb2c9be..9e7951fd 100644 --- a/test/basic.ts +++ b/test/basic.ts @@ -1084,7 +1084,26 @@ describe("insert, update, delete with count: 'exact'", () => { .insert([{ id: 100 }, { slug: 'test-slug' }], { defaultToNull: false }) .select() .rollback() - expect(res).toMatchInlineSnapshot() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "data": null, + "id": 100, + "slug": null, + }, + Object { + "data": null, + "id": 4, + "slug": "test-slug", + }, + ], + "error": null, + "status": 201, + "statusText": "Created", + } + `) }) test('bulk upsert with column defaults', async () => { @@ -1093,7 +1112,26 @@ describe("insert, update, delete with count: 'exact'", () => { .upsert([{ id: 1 }, { slug: 'test-slug' }], { defaultToNull: false }) .select() .rollback() - expect(res).toMatchInlineSnapshot() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "data": null, + "id": 1, + "slug": null, + }, + Object { + "data": null, + "id": 6, + "slug": "test-slug", + }, + ], + "error": null, + "status": 201, + "statusText": "Created", + } + `) }) test("update with count: 'exact'", async () => { From d4e03a005d68c2d73ac344951a455e4ef95ebeef Mon Sep 17 00:00:00 2001 From: Bobbie Soedirgo Date: Mon, 17 Apr 2023 17:14:30 +0800 Subject: [PATCH 7/7] feat(filters): likeAllOf, likeAnyOf, ilikeAllOf, ilikeAnyOf --- src/PostgrestFilterBuilder.ts | 52 +++++++++++++++++++++ test/filters.ts | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/src/PostgrestFilterBuilder.ts b/src/PostgrestFilterBuilder.ts index 1e227786..6c93b969 100644 --- a/src/PostgrestFilterBuilder.ts +++ b/src/PostgrestFilterBuilder.ts @@ -123,6 +123,32 @@ export default class PostgrestFilterBuilder< return this } + likeAllOf(column: ColumnName, patterns: string[]): this + likeAllOf(column: string, patterns: string[]): this + /** + * Match only rows where `column` matches all of `patterns` case-sensitively. + * + * @param column - The column to filter on + * @param patterns - The patterns to match with + */ + likeAllOf(column: string, patterns: string[]): this { + this.url.searchParams.append(column, `like(all).{${patterns.join(',')}}`) + return this + } + + likeAnyOf(column: ColumnName, patterns: string[]): this + likeAnyOf(column: string, patterns: string[]): this + /** + * Match only rows where `column` matches any of `patterns` case-sensitively. + * + * @param column - The column to filter on + * @param patterns - The patterns to match with + */ + likeAnyOf(column: string, patterns: string[]): this { + this.url.searchParams.append(column, `like(any).{${patterns.join(',')}}`) + return this + } + ilike(column: ColumnName, pattern: string): this ilike(column: string, pattern: string): this /** @@ -136,6 +162,32 @@ export default class PostgrestFilterBuilder< return this } + ilikeAllOf(column: ColumnName, patterns: string[]): this + ilikeAllOf(column: string, patterns: string[]): this + /** + * Match only rows where `column` matches all of `patterns` case-insensitively. + * + * @param column - The column to filter on + * @param patterns - The patterns to match with + */ + ilikeAllOf(column: string, patterns: string[]): this { + this.url.searchParams.append(column, `ilike(all).{${patterns.join(',')}}`) + return this + } + + ilikeAnyOf(column: ColumnName, patterns: string[]): this + ilikeAnyOf(column: string, patterns: string[]): this + /** + * Match only rows where `column` matches any of `patterns` case-insensitively. + * + * @param column - The column to filter on + * @param patterns - The patterns to match with + */ + ilikeAnyOf(column: string, patterns: string[]): this { + this.url.searchParams.append(column, `ilike(any).{${patterns.join(',')}}`) + return this + } + is( column: ColumnName, value: Row[ColumnName] & (boolean | null) diff --git a/test/filters.ts b/test/filters.ts index 5ec35b86..ac91a935 100644 --- a/test/filters.ts +++ b/test/filters.ts @@ -182,6 +182,49 @@ test('like', async () => { `) }) +test('likeAllOf', async () => { + const res = await postgrest + .from('users') + .select('username') + .likeAllOf('username', ['%supa%', '%bot%']) + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "username": "supabot", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('likeAnyOf', async () => { + const res = await postgrest + .from('users') + .select('username') + .likeAnyOf('username', ['%supa%', '%kiwi%']) + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "username": "supabot", + }, + Object { + "username": "kiwicopple", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + test('ilike', async () => { const res = await postgrest.from('users').select('username').ilike('username', '%SUPA%') expect(res).toMatchInlineSnapshot(` @@ -199,6 +242,49 @@ test('ilike', async () => { `) }) +test('ilikeAllOf', async () => { + const res = await postgrest + .from('users') + .select('username') + .ilikeAllOf('username', ['%SUPA%', '%bot%']) + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "username": "supabot", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('ilikeAnyOf', async () => { + const res = await postgrest + .from('users') + .select('username') + .ilikeAnyOf('username', ['%supa%', '%KIWI%']) + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "username": "supabot", + }, + Object { + "username": "kiwicopple", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + test('is', async () => { const res = await postgrest.from('users').select('data').is('data', null) expect(res).toMatchInlineSnapshot(`