diff --git a/jest.config.js b/jest.config.js index 87c30aa4..bc64026a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + collectCoverageFrom: ['src/**/*'], } diff --git a/src/PostgrestBuilder.ts b/src/PostgrestBuilder.ts index 2c3863e2..416adc1b 100644 --- a/src/PostgrestBuilder.ts +++ b/src/PostgrestBuilder.ts @@ -1,7 +1,14 @@ // @ts-ignore import nodeFetch from '@supabase/node-fetch' -import type { Fetch, PostgrestSingleResponse, PostgrestResponseSuccess } from './types' +import type { + Fetch, + PostgrestSingleResponse, + PostgrestResponseSuccess, + CheckMatchingArrayTypes, + MergePartialResult, + IsValidResultOverride, +} from './types' import PostgrestError from './PostgrestError' export default abstract class PostgrestBuilder @@ -209,4 +216,57 @@ export default abstract class PostgrestBuilder() method at the end of your call chain instead + */ + returns(): PostgrestBuilder, ThrowOnError> { + /* istanbul ignore next */ + return this as unknown as PostgrestBuilder< + CheckMatchingArrayTypes, + ThrowOnError + > + } + + /** + * Override the type of the returned `data` field in the response. + * + * @typeParam NewResult - The new type to cast the response data to + * @typeParam Options - Optional type configuration (defaults to { merge: true }) + * @typeParam Options.merge - When true, merges the new type with existing return type. When false, replaces the existing types entirely (defaults to true) + * @example + * ```typescript + * // Merge with existing types (default behavior) + * const query = supabase + * .from('users') + * .select() + * .overrideTypes<{ custom_field: string }>() + * + * // Replace existing types completely + * const replaceQuery = supabase + * .from('users') + * .select() + * .overrideTypes<{ id: number; name: string }, { merge: false }>() + * ``` + * @returns A PostgrestBuilder instance with the new type + */ + overrideTypes< + NewResult, + Options extends { merge?: boolean } = { merge: true } + >(): PostgrestBuilder< + IsValidResultOverride extends true + ? MergePartialResult + : CheckMatchingArrayTypes, + ThrowOnError + > { + return this as unknown as PostgrestBuilder< + IsValidResultOverride extends true + ? MergePartialResult + : CheckMatchingArrayTypes, + ThrowOnError + > + } } diff --git a/src/PostgrestTransformBuilder.ts b/src/PostgrestTransformBuilder.ts index 2be085c8..c9fb5781 100644 --- a/src/PostgrestTransformBuilder.ts +++ b/src/PostgrestTransformBuilder.ts @@ -1,6 +1,6 @@ import PostgrestBuilder from './PostgrestBuilder' import { GetResult } from './select-query-parser/result' -import { GenericSchema } from './types' +import { GenericSchema, CheckMatchingArrayTypes } from './types' export default class PostgrestTransformBuilder< Schema extends GenericSchema, @@ -307,18 +307,19 @@ export default class PostgrestTransformBuilder< * Override the type of the returned `data`. * * @typeParam NewResult - The new result type to override with + * @deprecated Use overrideTypes() method at the end of your call chain instead */ returns(): PostgrestTransformBuilder< Schema, Row, - NewResult, + CheckMatchingArrayTypes, RelationName, Relationships > { return this as unknown as PostgrestTransformBuilder< Schema, Row, - NewResult, + CheckMatchingArrayTypes, RelationName, Relationships > diff --git a/src/types.ts b/src/types.ts index fd1378ce..2341524e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ import PostgrestError from './PostgrestError' +import { ContainsNull } from './select-query-parser/types' +import { SelectQueryError } from './select-query-parser/utils' export type Fetch = typeof fetch @@ -89,3 +91,66 @@ type ConditionalSimplifyDeep< type NonRecursiveType = BuiltIns | Function | (new (...arguments_: any[]) => unknown) type BuiltIns = Primitive | void | Date | RegExp type Primitive = null | undefined | string | number | boolean | symbol | bigint + +export type IsValidResultOverride = + Result extends any[] + ? NewResult extends any[] + ? // Both are arrays - valid + Ok + : ErrorResult + : NewResult extends any[] + ? ErrorNewResult + : // Neither are arrays - valid + // Preserve the optionality of the result if the overriden type is an object (case of chaining with `maybeSingle`) + ContainsNull extends true + ? Ok | null + : Ok + +/** + * Utility type to check if array types match between Result and NewResult. + * Returns either the valid NewResult type or an error message type. + */ +export type CheckMatchingArrayTypes = + // If the result is a QueryError we allow the user to override anyway + Result extends SelectQueryError + ? NewResult + : IsValidResultOverride< + Result, + NewResult, + NewResult, + { + Error: 'Type mismatch: Cannot cast array result to a single object. Use .returns> for array results or .single() to convert the result to a single object' + }, + { + Error: 'Type mismatch: Cannot cast single object to array type. Remove Array wrapper from return type or make sure you are not using .single() up in the calling chain' + } + > + +type Simplify = T extends object ? { [K in keyof T]: T[K] } : T + +type MergeDeep = { + [K in keyof New | keyof Row]: K extends keyof Row + ? K extends keyof New + ? IsPlainObject extends true + ? IsPlainObject extends true + ? MergeDeep + : Row[K] + : Row[K] + : Row[K] + : K extends keyof New + ? New[K] + : never +} + +// Helper to check if a type is a plain object (not an array) +type IsPlainObject = T extends any[] ? false : T extends object ? true : false + +// Merge the new result with the original (Result) when merge option is true. +// If NewResult is an array, merge each element. +export type MergePartialResult = Options extends { merge: true } + ? Result extends any[] + ? NewResult extends any[] + ? Array>> + : never + : Simplify> + : NewResult diff --git a/test/basic.ts b/test/basic.ts index 37b8879e..71d884fa 100644 --- a/test/basic.ts +++ b/test/basic.ts @@ -116,6 +116,90 @@ test('basic select returns types override', async () => { `) }) +test('basic select returns from builder', async () => { + const res = await postgrest + .from('users') + .select() + .eq('username', 'supabot') + .single() + .returns<{ status: 'ONLINE' | 'OFFLINE' }>() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Object { + "age_range": "[1,2)", + "catchphrase": "'cat' 'fat'", + "data": null, + "status": "ONLINE", + "username": "supabot", + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('basic select overrideTypes from builder', async () => { + const res = await postgrest + .from('users') + .select() + .eq('username', 'supabot') + .single() + .overrideTypes<{ status: 'ONLINE' | 'OFFLINE' }>() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Object { + "age_range": "[1,2)", + "catchphrase": "'cat' 'fat'", + "data": null, + "status": "ONLINE", + "username": "supabot", + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('basic select with maybeSingle yielding more than one result', async () => { + const res = await postgrest.from('users').select().maybeSingle() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": null, + "error": Object { + "code": "PGRST116", + "details": "Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row", + "hint": null, + "message": "JSON object requested, multiple (or no) rows returned", + }, + "status": 406, + "statusText": "Not Acceptable", + } + `) +}) + +test('basic select with single yielding more than one result', async () => { + const res = await postgrest.from('users').select().single() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": null, + "error": Object { + "code": "PGRST116", + "details": "The result contains 5 rows", + "hint": null, + "message": "JSON object requested, multiple (or no) rows returned", + }, + "status": 406, + "statusText": "Not Acceptable", + } + `) +}) + test('basic select view', async () => { const res = await postgrest.from('updatable_view').select() expect(res).toMatchInlineSnapshot(` @@ -1963,3 +2047,187 @@ test('join on 1-1 relation with nullables', async () => { } `) }) + +test('custom fetch function', async () => { + const customFetch = jest.fn().mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve('[]'), + }) + ) + + const postgrestWithCustomFetch = new PostgrestClient(REST_URL, { + fetch: customFetch, + }) + + await postgrestWithCustomFetch.from('users').select() + + expect(customFetch).toHaveBeenCalledWith( + expect.stringContaining(REST_URL), + expect.objectContaining({ + method: 'GET', + headers: expect.any(Object), + }) + ) +}) + +test('handles undefined global fetch', async () => { + // Store original fetch + const originalFetch = globalThis.fetch + // Delete global fetch to simulate environments where it's undefined + delete (globalThis as any).fetch + + try { + const postgrestClient = new PostgrestClient(REST_URL) + const result = await postgrestClient.from('users').select() + expect(result).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "age_range": "[1,2)", + "catchphrase": "'cat' 'fat'", + "data": null, + "status": "ONLINE", + "username": "supabot", + }, + Object { + "age_range": "[25,35)", + "catchphrase": "'bat' 'cat'", + "data": null, + "status": "OFFLINE", + "username": "kiwicopple", + }, + Object { + "age_range": "[25,35)", + "catchphrase": "'bat' 'rat'", + "data": null, + "status": "ONLINE", + "username": "awailas", + }, + Object { + "age_range": "[20,30)", + "catchphrase": "'json' 'test'", + "data": Object { + "foo": Object { + "bar": Object { + "nested": "value", + }, + "baz": "string value", + }, + }, + "status": "ONLINE", + "username": "jsonuser", + }, + Object { + "age_range": "[20,30)", + "catchphrase": "'fat' 'rat'", + "data": null, + "status": "ONLINE", + "username": "dragarcia", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) + // Test passes if we reach here without errors, as it means nodeFetch was used + } finally { + // Restore original fetch + globalThis.fetch = originalFetch + } +}) + +test('handles array error with 404 status', async () => { + // Mock the fetch response to return an array error with 404 + const customFetch = jest.fn().mockImplementation(() => + Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('[]'), + }) + ) + + const postgrestWithCustomFetch = new PostgrestClient(REST_URL, { + fetch: customFetch, + }) + + const res = await postgrestWithCustomFetch.from('users').select() + + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('handles empty body with 404 status', async () => { + // Mock the fetch response to return an empty body with 404 + const customFetch = jest.fn().mockImplementation(() => + Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve(''), + }) + ) + + const postgrestWithCustomFetch = new PostgrestClient(REST_URL, { + fetch: customFetch, + }) + + const res = await postgrestWithCustomFetch.from('users').select() + + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": null, + "error": null, + "status": 204, + "statusText": "No Content", + } + `) +}) + +test('maybeSingle handles zero rows error', async () => { + const customFetch = jest.fn().mockImplementation(() => + Promise.resolve({ + ok: false, + status: 406, + statusText: 'Not Acceptable', + text: () => + Promise.resolve( + JSON.stringify({ + code: 'PGRST116', + details: '0 rows', + hint: null, + message: 'JSON object requested, multiple (or no) rows returned', + }) + ), + }) + ) + + const postgrestWithCustomFetch = new PostgrestClient(REST_URL, { + fetch: customFetch, + }) + + const res = await postgrestWithCustomFetch.from('users').select().maybeSingle() + + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": null, + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) diff --git a/test/override-types.test-d.ts b/test/override-types.test-d.ts new file mode 100644 index 00000000..e22aeadf --- /dev/null +++ b/test/override-types.test-d.ts @@ -0,0 +1,126 @@ +import { expectType } from 'tsd' +import { TypeEqual } from 'ts-expect' +import { PostgrestClient } from '../src' +import { CustomUserDataType, Database } from './types' + +const REST_URL = 'http://localhost:54321' +const postgrest = new PostgrestClient(REST_URL) + +// Test merge array result to object should error +{ + const singleResult = await postgrest + .from('users') + .select() + .overrideTypes<{ custom_field: string }>() + if (singleResult.error) { + throw new Error(singleResult.error.message) + } + let result: typeof singleResult.data + expectType< + TypeEqual< + typeof result, + { + Error: 'Type mismatch: Cannot cast array result to a single object. Use .returns> for array results or .single() to convert the result to a single object' + } + > + >(true) +} + +// Test merge object result to array type should error +{ + const singleResult = await postgrest + .from('users') + .select() + .single() + .overrideTypes<{ custom_field: string }[]>() + if (singleResult.error) { + throw new Error(singleResult.error.message) + } + let result: typeof singleResult.data + expectType< + TypeEqual< + typeof result, + { + Error: 'Type mismatch: Cannot cast single object to array type. Remove Array wrapper from return type or make sure you are not using .single() up in the calling chain' + } + > + >(true) +} + +// Test with single() / maybeSingle() +{ + const singleResult = await postgrest + .from('users') + .select() + .single() + .overrideTypes<{ custom_field: string }>() + if (singleResult.error) { + throw new Error(singleResult.error.message) + } + let result: typeof singleResult.data + expectType>(true) +} +// Test with maybeSingle() +{ + const maybeSingleResult = await postgrest + .from('users') + .select() + .maybeSingle() + .overrideTypes<{ custom_field: string }>() + if (maybeSingleResult.error) { + throw new Error(maybeSingleResult.error.message) + } + let maybeSingleResultType: typeof maybeSingleResult.data + let expectedType: { custom_field: string } | null + expectType>(true) +} +// Test replacing behavior +{ + const singleResult = await postgrest + .from('users') + .select() + .single() + .overrideTypes<{ custom_field: string }, { merge: false }>() + if (singleResult.error) { + throw new Error(singleResult.error.message) + } + let result: typeof singleResult.data + expectType>(true) +} + +// Test with select() +{ + const singleResult = await postgrest + .from('users') + .select() + .overrideTypes<{ custom_field: string }[]>() + if (singleResult.error) { + throw new Error(singleResult.error.message) + } + let result: typeof singleResult.data + expectType< + TypeEqual< + typeof result, + { + username: string + data: CustomUserDataType | null + age_range: unknown + catchphrase: unknown + status: 'ONLINE' | 'OFFLINE' | null + custom_field: string + }[] + > + >(true) +} +// Test replacing select behavior +{ + const singleResult = await postgrest + .from('users') + .select() + .overrideTypes<{ custom_field: string }[], { merge: false }>() + if (singleResult.error) { + throw new Error(singleResult.error.message) + } + let result: typeof singleResult.data + expectType>(true) +} diff --git a/test/returns.test-d.ts b/test/returns.test-d.ts new file mode 100644 index 00000000..551a0e22 --- /dev/null +++ b/test/returns.test-d.ts @@ -0,0 +1,120 @@ +import { expectType } from 'tsd' +import { PostgrestBuilder, PostgrestClient } from '../src/index' +import { Database } from './types' +import { TypeEqual } from 'ts-expect' + +const REST_URL = 'http://localhost:3000' +const postgrest = new PostgrestClient(REST_URL) + +// Test returns() with different end methods +{ + // Test with single() + const singleResult = await postgrest + .from('users') + .select() + .single() + .returns<{ username: string }>() + if (singleResult.error) { + throw new Error(singleResult.error.message) + } + let result: typeof singleResult.data + let expected: { username: string } + expectType>(true) + + // Test with maybeSingle() + const maybeSingleResult = await postgrest + .from('users') + .select() + .maybeSingle() + .returns<{ username: string }>() + if (maybeSingleResult.error) { + throw new Error(maybeSingleResult.error.message) + } + let maybeSingleResultType: typeof maybeSingleResult.data + let maybeSingleExpected: { username: string } | null + expectType>(true) + + // Test array to non-array type casting error + const invalidCastArray = await postgrest.from('users').select().returns<{ username: string }>() + if (invalidCastArray.error) { + throw new Error(invalidCastArray.error.message) + } + let resultType: typeof invalidCastArray.data + let resultExpected: { + Error: 'Type mismatch: Cannot cast array result to a single object. Use .returns> for array results or .single() to convert the result to a single object' + } + expectType>(true) + + // Test non-array to array type casting error + const invalidCastSingle = postgrest + .from('users') + .select() + .single() + .returns<{ username: string }[]>() + expectType< + PostgrestBuilder< + { + Error: 'Type mismatch: Cannot cast single object to array type. Remove Array wrapper from return type or make sure you are not using .single() up in the calling chain' + }, + false + > + >(invalidCastSingle) + + // Test with csv() + const csvResult = await postgrest.from('users').select().csv().returns() + if (csvResult.error) { + throw new Error(csvResult.error.message) + } + let csvResultType: typeof csvResult.data + let csvExpected: string + expectType>(true) + + // Test with throwOnError() + const throwResult = await postgrest + .from('users') + .select() + .returns<{ username: string }[]>() + .throwOnError() + let throwResultType: typeof throwResult.data + let throwExpected: { username: string }[] + expectType>(true) + let throwErrorType: typeof throwResult.error + let throwErrorExpected: null + expectType>(true) +} + +// Test returns() with nested selects and relationships +{ + const result = await postgrest + .from('users') + .select('username, messages(id, content)') + .single() + .returns<{ + username: string + messages: { id: number; content: string }[] + }>() + if (result.error) { + throw new Error(result.error.message) + } + let resultType: typeof result.data + let expected: { + username: string + messages: { id: number; content: string }[] + } + expectType>(true) +} + +// Test returns() with JSON operations +{ + const result = await postgrest + .from('users') + .select('data->settings') + .single() + .returns<{ settings: { theme: 'light' | 'dark' } }>() + if (result.error) { + throw new Error(result.error.message) + } + let resultType: typeof result.data + let expected: { settings: { theme: 'light' | 'dark' } } + expectType>(true) +}