diff --git a/packages/core/postgrest-js/src/select-query-parser/result.ts b/packages/core/postgrest-js/src/select-query-parser/result.ts index 22207afdf..f6de5f0ea 100644 --- a/packages/core/postgrest-js/src/select-query-parser/result.ts +++ b/packages/core/postgrest-js/src/select-query-parser/result.ts @@ -308,14 +308,17 @@ type ResolveJsonPathType< ? PathResult extends string ? // Use the result if it's a string as we know that even with the string accessor ->> it's a valid type PathResult - : IsStringUnion extends true - ? // Use the result if it's a union of strings + : IsStringUnion> extends true + ? // Use the result if it's a union of strings (even if nullable) PathResult - : CastType extends 'json' - ? // If the type is not a string, ensure it was accessed with json accessor -> + : IsStringUnion extends true + ? // Use the result if it's a union of strings (non-nullable) PathResult - : // Otherwise it means non-string value accessed with string accessor ->> use the TypeScriptTypes result - TypeScriptTypes + : CastType extends 'json' + ? // If the type is not a string, ensure it was accessed with json accessor -> + PathResult + : // Otherwise it means non-string value accessed with string accessor ->> use the TypeScriptTypes result + TypeScriptTypes : TypeScriptTypes : // No json path, use regular type casting TypeScriptTypes diff --git a/packages/core/postgrest-js/src/select-query-parser/utils.ts b/packages/core/postgrest-js/src/select-query-parser/utils.ts index b766454d4..647b2fa93 100644 --- a/packages/core/postgrest-js/src/select-query-parser/utils.ts +++ b/packages/core/postgrest-js/src/select-query-parser/utils.ts @@ -618,7 +618,7 @@ export type JsonPathToAccessor = Path extends `${infer P1}- export type JsonPathToType = Path extends '' ? T : ContainsNull extends true - ? JsonPathToType, Path> + ? JsonPathToType, Path> | null : Path extends `${infer Key}.${infer Rest}` ? Key extends keyof T ? JsonPathToType @@ -627,13 +627,13 @@ export type JsonPathToType = Path extends '' ? T[Path] : never -export type IsStringUnion = string extends T +export type IsStringUnion = [T] extends [never] ? false - : T extends string - ? [T] extends [never] - ? false - : true - : false + : string extends T + ? false + : T extends string + ? true + : false type MatchingFunctionBySetofFrom< Fn extends GenericFunction, diff --git a/packages/core/postgrest-js/test/advanced_rpc.test.ts b/packages/core/postgrest-js/test/advanced_rpc.test.ts index 7b1269816..5f008fa41 100644 --- a/packages/core/postgrest-js/test/advanced_rpc.test.ts +++ b/packages/core/postgrest-js/test/advanced_rpc.test.ts @@ -1169,7 +1169,8 @@ describe('advanced rpc', () => { "age_range": "[20,30)", "catchphrase": "'json' 'test'", "data": { - "foo": { + "foo": "string value", + "fooRecord": { "bar": { "nested": "value", }, @@ -1179,6 +1180,49 @@ describe('advanced rpc', () => { "status": "ONLINE", "username": "jsonuser", }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'null' 'test'", + "data": { + "bar": null, + "en": "ONE", + "foo": "string value", + "fooRecord": { + "bar": null, + "baz": "string value", + }, + }, + "status": "ONLINE", + "username": "jsonusernull", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'obj' 'test'", + "data": { + "bar": { + "baz": 42, + }, + "en": "TWO", + "fooRecord": { + "bar": { + "nested": "deep", + }, + "baz": "test", + }, + }, + "status": "ONLINE", + "username": "jsonuserobj", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'missing' 'test'", + "data": { + "en": "THREE", + "foo": "string", + }, + "status": "ONLINE", + "username": "jsonusermissing", + }, ], "error": null, "status": 200, diff --git a/packages/core/postgrest-js/test/basic.test.ts b/packages/core/postgrest-js/test/basic.test.ts index 363b095ab..ce02376fc 100644 --- a/packages/core/postgrest-js/test/basic.test.ts +++ b/packages/core/postgrest-js/test/basic.test.ts @@ -42,7 +42,8 @@ test('basic select table', async () => { "age_range": "[20,30)", "catchphrase": "'json' 'test'", "data": { - "foo": { + "foo": "string value", + "fooRecord": { "bar": { "nested": "value", }, @@ -52,6 +53,49 @@ test('basic select table', async () => { "status": "ONLINE", "username": "jsonuser", }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'null' 'test'", + "data": { + "bar": null, + "en": "ONE", + "foo": "string value", + "fooRecord": { + "bar": null, + "baz": "string value", + }, + }, + "status": "ONLINE", + "username": "jsonusernull", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'obj' 'test'", + "data": { + "bar": { + "baz": 42, + }, + "en": "TWO", + "fooRecord": { + "bar": { + "nested": "deep", + }, + "baz": "test", + }, + }, + "status": "ONLINE", + "username": "jsonuserobj", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'missing' 'test'", + "data": { + "en": "THREE", + "foo": "string", + }, + "status": "ONLINE", + "username": "jsonusermissing", + }, ], "error": null, "status": 200, @@ -98,7 +142,8 @@ test('basic select returns types override', async () => { "age_range": "[20,30)", "catchphrase": "'json' 'test'", "data": { - "foo": { + "foo": "string value", + "fooRecord": { "bar": { "nested": "value", }, @@ -108,6 +153,49 @@ test('basic select returns types override', async () => { "status": "ONLINE", "username": "jsonuser", }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'null' 'test'", + "data": { + "bar": null, + "en": "ONE", + "foo": "string value", + "fooRecord": { + "bar": null, + "baz": "string value", + }, + }, + "status": "ONLINE", + "username": "jsonusernull", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'obj' 'test'", + "data": { + "bar": { + "baz": 42, + }, + "en": "TWO", + "fooRecord": { + "bar": { + "nested": "deep", + }, + "baz": "test", + }, + }, + "status": "ONLINE", + "username": "jsonuserobj", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'missing' 'test'", + "data": { + "en": "THREE", + "foo": "string", + }, + "status": "ONLINE", + "username": "jsonusermissing", + }, ], "error": null, "status": 200, @@ -172,7 +260,7 @@ test('basic select with maybeSingle yielding more than one result', async () => "data": null, "error": { "code": "PGRST116", - "details": "Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row", + "details": "Results contain 8 rows, application/vnd.pgrst.object+json requires 1 row", "hint": null, "message": "JSON object requested, multiple (or no) rows returned", }, @@ -190,7 +278,7 @@ test('basic select with single yielding more than one result', async () => { "data": null, "error": { "code": "PGRST116", - "details": "The result contains 5 rows", + "details": "The result contains 8 rows", "hint": null, "message": "Cannot coerce the result to a single JSON object", }, @@ -226,6 +314,18 @@ test('basic select view', async () => { "non_updatable_column": 1, "username": "jsonuser", }, + { + "non_updatable_column": 1, + "username": "jsonusernull", + }, + { + "non_updatable_column": 1, + "username": "jsonuserobj", + }, + { + "non_updatable_column": 1, + "username": "jsonusermissing", + }, ], "error": null, "status": 200, @@ -1039,7 +1139,8 @@ test('allow ordering on JSON column', async () => { "age_range": "[20,30)", "catchphrase": "'json' 'test'", "data": { - "foo": { + "foo": "string value", + "fooRecord": { "bar": { "nested": "value", }, @@ -1049,6 +1150,49 @@ test('allow ordering on JSON column', async () => { "status": "ONLINE", "username": "jsonuser", }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'null' 'test'", + "data": { + "bar": null, + "en": "ONE", + "foo": "string value", + "fooRecord": { + "bar": null, + "baz": "string value", + }, + }, + "status": "ONLINE", + "username": "jsonusernull", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'obj' 'test'", + "data": { + "bar": { + "baz": 42, + }, + "en": "TWO", + "fooRecord": { + "bar": { + "nested": "deep", + }, + "baz": "test", + }, + }, + "status": "ONLINE", + "username": "jsonuserobj", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'missing' 'test'", + "data": { + "en": "THREE", + "foo": "string", + }, + "status": "ONLINE", + "username": "jsonusermissing", + }, { "age_range": "[20,30)", "catchphrase": "'fat' 'rat'", @@ -1084,7 +1228,7 @@ test('select with head:true, count:exact', async () => { const res = await postgrest.from('users').select('*', { head: true, count: 'exact' }) expect(res).toMatchInlineSnapshot(` { - "count": 5, + "count": 8, "data": null, "error": null, "status": 200, @@ -1133,7 +1277,7 @@ test('select with count:exact', async () => { const res = await postgrest.from('users').select('*', { count: 'exact' }) expect(res).toMatchInlineSnapshot(` { - "count": 5, + "count": 8, "data": [ { "age_range": "[1,2)", @@ -1160,7 +1304,8 @@ test('select with count:exact', async () => { "age_range": "[20,30)", "catchphrase": "'json' 'test'", "data": { - "foo": { + "foo": "string value", + "fooRecord": { "bar": { "nested": "value", }, @@ -1170,6 +1315,49 @@ test('select with count:exact', async () => { "status": "ONLINE", "username": "jsonuser", }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'null' 'test'", + "data": { + "bar": null, + "en": "ONE", + "foo": "string value", + "fooRecord": { + "bar": null, + "baz": "string value", + }, + }, + "status": "ONLINE", + "username": "jsonusernull", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'obj' 'test'", + "data": { + "bar": { + "baz": 42, + }, + "en": "TWO", + "fooRecord": { + "bar": { + "nested": "deep", + }, + "baz": "test", + }, + }, + "status": "ONLINE", + "username": "jsonuserobj", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'missing' 'test'", + "data": { + "en": "THREE", + "foo": "string", + }, + "status": "ONLINE", + "username": "jsonusermissing", + }, { "age_range": "[20,30)", "catchphrase": "'fat' 'rat'", diff --git a/packages/core/postgrest-js/test/embeded_functions_join.test.ts b/packages/core/postgrest-js/test/embeded_functions_join.test.ts index 332207162..506c3c68b 100644 --- a/packages/core/postgrest-js/test/embeded_functions_join.test.ts +++ b/packages/core/postgrest-js/test/embeded_functions_join.test.ts @@ -182,6 +182,18 @@ describe('embeded functions select', () => { "all_user_messages": [], "username": "jsonuser", }, + { + "all_user_messages": [], + "username": "jsonusernull", + }, + { + "all_user_messages": [], + "username": "jsonuserobj", + }, + { + "all_user_messages": [], + "username": "jsonusermissing", + }, { "all_user_messages": [], "username": "dragarcia", @@ -250,6 +262,18 @@ describe('embeded functions select', () => { "all_user_messages": [], "username": "jsonuser", }, + { + "all_user_messages": [], + "username": "jsonusernull", + }, + { + "all_user_messages": [], + "username": "jsonuserobj", + }, + { + "all_user_messages": [], + "username": "jsonusermissing", + }, { "all_user_messages": [], "username": "dragarcia", @@ -318,6 +342,18 @@ describe('embeded functions select', () => { "all_user_messages": [], "username": "jsonuser", }, + { + "all_user_messages": [], + "username": "jsonusernull", + }, + { + "all_user_messages": [], + "username": "jsonuserobj", + }, + { + "all_user_messages": [], + "username": "jsonusermissing", + }, { "all_user_messages": [], "username": "dragarcia", @@ -374,6 +410,18 @@ describe('embeded functions select', () => { "setof_rows_one": null, "username": "jsonuser", }, + { + "setof_rows_one": null, + "username": "jsonusernull", + }, + { + "setof_rows_one": null, + "username": "jsonuserobj", + }, + { + "setof_rows_one": null, + "username": "jsonusermissing", + }, { "setof_rows_one": null, "username": "dragarcia", @@ -438,6 +486,27 @@ describe('embeded functions select', () => { }, "username": "jsonuser", }, + { + "returns_row": { + "id": null, + "username": null, + }, + "username": "jsonusernull", + }, + { + "returns_row": { + "id": null, + "username": null, + }, + "username": "jsonuserobj", + }, + { + "returns_row": { + "id": null, + "username": null, + }, + "username": "jsonusermissing", + }, { "returns_row": { "id": null, @@ -542,6 +611,24 @@ describe('embeded functions select', () => { }, "username": "jsonuser", }, + { + "user_called_profile": { + "username": null, + }, + "username": "jsonusernull", + }, + { + "user_called_profile": { + "username": null, + }, + "username": "jsonuserobj", + }, + { + "user_called_profile": { + "username": null, + }, + "username": "jsonusermissing", + }, { "user_called_profile": { "username": null, @@ -688,6 +775,18 @@ describe('embeded functions select', () => { "user_messages": [], "username": "jsonuser", }, + { + "user_messages": [], + "username": "jsonusernull", + }, + { + "user_messages": [], + "username": "jsonuserobj", + }, + { + "user_messages": [], + "username": "jsonusermissing", + }, { "user_messages": [], "username": "dragarcia", @@ -761,6 +860,18 @@ describe('embeded functions select', () => { "active_user_messages": [], "username": "jsonuser", }, + { + "active_user_messages": [], + "username": "jsonusernull", + }, + { + "active_user_messages": [], + "username": "jsonuserobj", + }, + { + "active_user_messages": [], + "username": "jsonusermissing", + }, { "active_user_messages": [], "username": "dragarcia", @@ -838,6 +949,18 @@ describe('embeded functions select', () => { "recent_messages": [], "username": "jsonuser", }, + { + "recent_messages": [], + "username": "jsonusernull", + }, + { + "recent_messages": [], + "username": "jsonuserobj", + }, + { + "recent_messages": [], + "username": "jsonusermissing", + }, { "recent_messages": [], "username": "dragarcia", @@ -911,6 +1034,18 @@ describe('embeded functions select', () => { "recent_messages": [], "username": "jsonuser", }, + { + "recent_messages": [], + "username": "jsonusernull", + }, + { + "recent_messages": [], + "username": "jsonuserobj", + }, + { + "recent_messages": [], + "username": "jsonusermissing", + }, { "recent_messages": [], "username": "dragarcia", @@ -982,6 +1117,18 @@ describe('embeded functions select', () => { "user_messages": [], "username": "jsonuser", }, + { + "user_messages": [], + "username": "jsonusernull", + }, + { + "user_messages": [], + "username": "jsonuserobj", + }, + { + "user_messages": [], + "username": "jsonusermissing", + }, { "user_messages": [], "username": "dragarcia", @@ -1142,6 +1289,27 @@ describe('embeded functions select', () => { }, "username": "jsonuser", }, + { + "profile": { + "id": null, + "username": null, + }, + "username": "jsonusernull", + }, + { + "profile": { + "id": null, + "username": null, + }, + "username": "jsonuserobj", + }, + { + "profile": { + "id": null, + "username": null, + }, + "username": "jsonusermissing", + }, { "profile": { "id": null, @@ -1198,6 +1366,18 @@ describe('embeded functions select', () => { "profile": null, "username": "jsonuser", }, + { + "profile": null, + "username": "jsonusernull", + }, + { + "profile": null, + "username": "jsonuserobj", + }, + { + "profile": null, + "username": "jsonusermissing", + }, { "profile": null, "username": "dragarcia", diff --git a/packages/core/postgrest-js/test/filters.test.ts b/packages/core/postgrest-js/test/filters.test.ts index 5fcb9e69e..f3e76c103 100644 --- a/packages/core/postgrest-js/test/filters.test.ts +++ b/packages/core/postgrest-js/test/filters.test.ts @@ -21,6 +21,15 @@ test('not', async () => { { "status": "ONLINE", }, + { + "status": "ONLINE", + }, + { + "status": "ONLINE", + }, + { + "status": "ONLINE", + }, ], "error": null, "status": 200, @@ -86,6 +95,15 @@ test('neq', async () => { { "username": "jsonuser", }, + { + "username": "jsonusernull", + }, + { + "username": "jsonuserobj", + }, + { + "username": "jsonusermissing", + }, { "username": "dragarcia", }, @@ -345,6 +363,15 @@ test('in', async () => { { "status": "ONLINE", }, + { + "status": "ONLINE", + }, + { + "status": "ONLINE", + }, + { + "status": "ONLINE", + }, ], "error": null, "status": 200, @@ -392,18 +419,7 @@ test('contains with json', async () => { expect(res).toMatchInlineSnapshot(` { "count": null, - "data": [ - { - "data": { - "foo": { - "bar": { - "nested": "value", - }, - "baz": "string value", - }, - }, - }, - ], + "data": [], "error": null, "status": 200, "statusText": "OK", @@ -538,6 +554,15 @@ test('rangeGte', async () => { { "age_range": "[20,30)", }, + { + "age_range": "[20,30)", + }, + { + "age_range": "[20,30)", + }, + { + "age_range": "[20,30)", + }, ], "error": null, "status": 200, @@ -598,6 +623,15 @@ test('overlaps', async () => { { "age_range": "[20,30)", }, + { + "age_range": "[20,30)", + }, + { + "age_range": "[20,30)", + }, + { + "age_range": "[20,30)", + }, ], "error": null, "status": 200, diff --git a/packages/core/postgrest-js/test/index.test-d.ts b/packages/core/postgrest-js/test/index.test-d.ts index 846beeeb5..ad6a2ddc0 100644 --- a/packages/core/postgrest-js/test/index.test-d.ts +++ b/packages/core/postgrest-js/test/index.test-d.ts @@ -1,7 +1,6 @@ import { expectType, TypeEqual } from './types' import { PostgrestClient, PostgrestError } from '../src/index' import { Prettify } from '../src/types/types' -import { Json } from '../src/select-query-parser/types' import { Database } from './types.override' import { Database as DatabaseWithOptions } from './types.override-with-options-postgrest14' @@ -191,12 +190,15 @@ const postgrestWithOptions = new PostgrestClient(REST_URL) // json accessor in select query { - const result = await postgrest.from('users').select('data->foo->bar, data->foo->>baz').single() + const result = await postgrest + .from('users') + .select('data->fooRecord->bar, data->fooRecord->>baz') + .single() if (result.error) { throw new Error(result.error.message) } - expectType(result.data.bar) - expectType(result.data.baz) + expectType | null>(result.data.bar) + expectType(result.data.baz) } // PostgrestBuilder's children retains class when using inherited methods @@ -283,11 +285,11 @@ const postgrestWithOptions = new PostgrestClient(REST_URL) TypeEqual< typeof result.data, { - baz: number - en: 'ONE' | 'TWO' | 'THREE' + baz: number | null + en: 'ONE' | 'TWO' | 'THREE' | null bar: { baz: number - } + } | null }[] > >(true) @@ -311,9 +313,9 @@ const postgrestWithOptions = new PostgrestClient(REST_URL) } expectType< { - baz: string - en: 'ONE' | 'TWO' | 'THREE' - bar: string + baz: string | null + en: 'ONE' | 'TWO' | 'THREE' | null + bar: string | null }[] >(result.data) } diff --git a/packages/core/postgrest-js/test/issue-1354.test-d.ts b/packages/core/postgrest-js/test/issue-1354.test-d.ts index 42ae11e34..1d162b3b7 100644 --- a/packages/core/postgrest-js/test/issue-1354.test-d.ts +++ b/packages/core/postgrest-js/test/issue-1354.test-d.ts @@ -10,7 +10,7 @@ export type Database = { foo: { Row: { created_at: string | null - bar: Json + bar: Record id: string baz: Json game_id: string @@ -19,7 +19,7 @@ export type Database = { } Insert: { created_at?: string | null - bar: Json + bar: Record id?: string baz: Json game_id: string @@ -28,7 +28,7 @@ export type Database = { } Update: { created_at?: string | null - bar?: Json + bar?: Record id?: string baz?: Json game_id?: string @@ -197,7 +197,7 @@ const postgrestOverrideTypes = new PostgrestClient( { const res = await postgrest.from('foo').select('id, bar, baz').eq('bar->version', 31).single() - const bar = {} as Json + const bar = {} as Record const baz = {} as Json if (res.error) { throw new Error(res.error.message) @@ -219,7 +219,7 @@ const postgrestOverrideTypes = new PostgrestClient( if (resIn.error) { throw new Error(resIn.error.message) } - expectType<{ id: string; bar: Json; baz: Json }>(resIn.data) + expectType<{ id: string; bar: Record; baz: Json }>(resIn.data) } // extended types diff --git a/packages/core/postgrest-js/test/override-types.test-d.ts b/packages/core/postgrest-js/test/override-types.test-d.ts index 367e25b76..e082e92a1 100644 --- a/packages/core/postgrest-js/test/override-types.test-d.ts +++ b/packages/core/postgrest-js/test/override-types.test-d.ts @@ -74,7 +74,7 @@ const postgrest = new PostgrestClient(REST_URL) let expectedType: { age_range: unknown catchphrase: unknown - data: CustomUserDataType | null + data: CustomUserDataType status: 'ONLINE' | 'OFFLINE' | null username: string custom_field: string @@ -95,7 +95,7 @@ const postgrest = new PostgrestClient(REST_URL) let expectedType: { age_range: unknown catchphrase: string - data: CustomUserDataType | null + data: CustomUserDataType status: 'ONLINE' | 'OFFLINE' | null username: string } | null @@ -146,7 +146,7 @@ const postgrest = new PostgrestClient(REST_URL) typeof result, { username: string - data: CustomUserDataType | null + data: CustomUserDataType age_range: unknown catchphrase: unknown status: 'ONLINE' | 'OFFLINE' | null @@ -193,7 +193,7 @@ const postgrest = new PostgrestClient(REST_URL) typeof data, { username: number - data: CustomUserDataType | null + data: CustomUserDataType age_range: unknown catchphrase: unknown status: 'ONLINE' | 'OFFLINE' | null @@ -218,7 +218,7 @@ const postgrest = new PostgrestClient(REST_URL) typeof data, { username: number - data: CustomUserDataType | null + data: CustomUserDataType age_range: unknown catchphrase: unknown status: 'ONLINE' | 'OFFLINE' | null @@ -244,6 +244,12 @@ const postgrest = new PostgrestClient(REST_URL) username: string data: { foo: number + fooRecord: { + bar: { + [x: string]: unknown + } + baz: string + } bar: { baz: number } en: 'ONE' | 'TWO' | 'THREE' record: Record | null @@ -275,6 +281,12 @@ const postgrest = new PostgrestClient(REST_URL) username: string data: { foo: number + fooRecord: { + bar: { + [x: string]: unknown + } + baz: string + } bar: { baz: number } en: 'ONE' | 'TWO' | 'THREE' record: Record | null @@ -317,7 +329,7 @@ const postgrest = new PostgrestClient(REST_URL) typeof data, { username: string - data: CustomUserDataType | null + data: CustomUserDataType age_range: unknown catchphrase: unknown status: 'ONLINE' | 'OFFLINE' | null @@ -344,6 +356,12 @@ const postgrest = new PostgrestClient(REST_URL) username: string data: { foo: string + fooRecord: { + bar: { + [x: string]: unknown + } + baz: string + } bar: { baz: number; newBaz: string } en: 'FOUR' // Overridden enum value record: Record | null @@ -374,6 +392,12 @@ const postgrest = new PostgrestClient(REST_URL) username: string data: { foo: string + fooRecord: { + bar: { + [x: string]: unknown + } + baz: string + } bar: { baz: number } @@ -411,7 +435,7 @@ const postgrest = new PostgrestClient(REST_URL) typeof data, { username: string - data: CustomUserDataType | null + data: CustomUserDataType age_range: unknown catchphrase: unknown status: 'ONLINE' | 'OFFLINE' | null @@ -466,7 +490,7 @@ const postgrest = new PostgrestClient(REST_URL) typeof data, { username: string - data: CustomUserDataType | null + data: CustomUserDataType age_range: unknown catchphrase: unknown status: 'ONLINE' | 'OFFLINE' | null @@ -503,7 +527,7 @@ const postgrest = new PostgrestClient(REST_URL) { age_range: unknown catchphrase: unknown - data: CustomUserDataType | null + data: CustomUserDataType status: 'ONLINE' | 'OFFLINE' | null username: string messages: { @@ -539,7 +563,7 @@ const postgrest = new PostgrestClient(REST_URL) typeof data, { username: string - data: CustomUserDataType | null + data: CustomUserDataType age_range: unknown catchphrase: unknown status: 'ONLINE' | 'OFFLINE' | null diff --git a/packages/core/postgrest-js/test/relationships.test.ts b/packages/core/postgrest-js/test/relationships.test.ts index 2740e58aa..938f51c41 100644 --- a/packages/core/postgrest-js/test/relationships.test.ts +++ b/packages/core/postgrest-js/test/relationships.test.ts @@ -2,7 +2,6 @@ import { PostgrestClient } from '../src/index' import { CustomUserDataTypeSchema, Database } from './types.override' import { expectType, TypeEqual } from './types' import { z } from 'zod' -import { Json } from '../src/select-query-parser/types' import { RequiredDeep } from 'type-fest' const REST_URL = 'http://localhost:54321/rest/v1' @@ -374,21 +373,193 @@ test('embed resource with no fields', async () => { ExpectedSchema.parse(res.data) }) -test('select JSON accessor', async () => { +test('select JSON object accessor on non-object fields (->)', async () => { const res = await postgrest .from('users') .select('data->foo->bar, data->foo->>baz') .limit(1) .filter('username', 'eq', 'jsonuser') .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "bar": null, + "baz": null, + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + baz: z.null(), + bar: z.null(), + }) + let expected: z.infer + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON string accessor on non-object fields (->>)', async () => { + const res = await postgrest + .from('users') + .select('data->>foo, foo_json:data->foo') + .limit(1) + .filter('username', 'eq', 'jsonuser') + .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "foo": "string value", + "foo_json": "string value", + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + foo: z.string().nullable(), + foo_json: z.string().nullable(), + }) + let expected: z.infer + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON object accessor on object fields (->)', async () => { + const res = await postgrest + .from('users') + .select('data->bar, bar_nested:data->fooRecord->bar, data->fooRecord') + .limit(1) + .filter('username', 'eq', 'jsonuserobj') + .single() expect(res).toMatchInlineSnapshot(` { "count": null, "data": { "bar": { - "nested": "value", + "baz": 42, + }, + "bar_nested": { + "nested": "deep", + }, + "fooRecord": { + "bar": { + "nested": "deep", + }, + "baz": "test", }, + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + bar: z + .object({ + baz: z.number(), + }) + .nullable(), + bar_nested: z.record(z.string(), z.unknown()).nullable(), + fooRecord: z + .object({ + bar: z.record(z.string(), z.unknown()), + baz: z.string(), + }) + .nullable(), + }) + let expected: z.infer + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON string accessor on object fields (->>)', async () => { + const res = await postgrest + .from('users') + .select('data->bar->>baz, data->>en, baz_nested:data->fooRecord->>baz') + .limit(1) + .filter('username', 'eq', 'jsonuserobj') + .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "baz": "42", + "baz_nested": "test", + "en": "TWO", + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + baz: z.string().nullable(), + en: z.enum(['ONE', 'TWO', 'THREE']).nullable(), + baz_nested: z.string().nullable(), + }) + let expected: z.infer + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON accessor with null fields', async () => { + const res = await postgrest + .from('users') + .select('data->bar->baz, data->en, data->bar') + .limit(1) + .filter('username', 'eq', 'jsonusernull') + .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "bar": null, + "baz": null, + "en": "ONE", + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + baz: z.number().nullable(), + en: z.enum(['ONE', 'TWO', 'THREE']).nullable(), + bar: z + .object({ + baz: z.number(), + }) + .nullable(), + }) + let expected: z.infer + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON accessor with null nested object fields', async () => { + const res = await postgrest + .from('users') + .select('data->fooRecord->bar, data->fooRecord->bar->nested, data->fooRecord->>baz') + .limit(1) + .filter('username', 'eq', 'jsonusernull') + .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "bar": null, "baz": "string value", + "nested": null, }, "error": null, "status": 200, @@ -397,19 +568,244 @@ test('select JSON accessor', async () => { `) let result: Exclude const ExpectedSchema = z.object({ - bar: z.unknown(), - baz: z.string(), + bar: z.record(z.string(), z.unknown()).nullable(), + nested: z.unknown(), + baz: z.string().nullable(), }) - // Cannot have a zod schema that match the Json type - // TODO: refactor the Json type to be unknown - let expected: { - bar: Json - baz: string - } + // unknown get infered to `nested?: unknown` by zod, so we need to make it required + let expected: Required> + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON accessor with missing properties', async () => { + const res = await postgrest + .from('users') + .select('data->bar->baz, data->missing->field, data->>missing, data->bar') + .limit(1) + .filter('username', 'eq', 'jsonusermissing') + .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "bar": null, + "baz": null, + "field": null, + "missing": null, + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + baz: z.number().nullable(), + field: z.null(), + missing: z.null(), + bar: z + .object({ + baz: z.number(), + }) + .nullable(), + }) + let expected: z.infer + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON accessor with null data field', async () => { + const res = await postgrest + .from('users') + .select('data->bar->baz, data->en, data->bar') + .limit(1) + .is('data', null) + .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "bar": null, + "baz": null, + "en": null, + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + baz: z.number().nullable(), + en: z.enum(['ONE', 'TWO', 'THREE']).nullable(), + bar: z + .object({ + baz: z.number(), + }) + .nullable(), + }) + let expected: z.infer + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON accessor on required (non-nullable) field - direct accessor', async () => { + const res = await postgrest + .from('users_with_required_json') + .select('required_data->id, required_data->name, required_data->nested') + .limit(1) + .filter('username', 'eq', 'testuser1') + .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "id": 1, + "name": "testuser1", + "nested": { + "count": 10, + "value": "test", + }, + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + id: z.number(), + name: z.string(), + nested: z.object({ + value: z.string(), + count: z.number(), + }), + }) + let expected: z.infer + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON accessor on required (non-nullable) field - string accessor', async () => { + const res = await postgrest + .from('users_with_required_json') + .select('required_data->>id, required_data->>name, required_data->>status') + .limit(1) + .filter('username', 'eq', 'testuser1') + .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "id": "1", + "name": "testuser1", + "status": "ACTIVE", + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + id: z.string(), + name: z.string(), + status: z.enum(['ACTIVE', 'INACTIVE']), + }) + let expected: z.infer + expectType>(true) + ExpectedSchema.parse(res.data) +}) + +test('select JSON accessor on required (non-nullable) field - nested paths', async () => { + const res = await postgrest + .from('users_with_required_json') + .select('required_data->nested->value, required_data->nested->>count, required_data->nested') + .limit(1) + .filter('username', 'eq', 'testuser1') + .single() + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "count": "10", + "nested": { + "count": 10, + "value": "test", + }, + "value": "test", + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let result: Exclude + const ExpectedSchema = z.object({ + value: z.string(), + count: z.string(), + nested: z.object({ + value: z.string(), + count: z.number(), + }), + }) + let expected: z.infer expectType>(true) ExpectedSchema.parse(res.data) }) +test('select JSON accessor - compare nullable vs required fields', async () => { + const resNullable = await postgrest + .from('users') + .select('data->en') + .limit(1) + .filter('username', 'eq', 'jsonuser') + .single() + const resRequired = await postgrest + .from('users_with_required_json') + .select('required_data->>status') + .limit(1) + .filter('username', 'eq', 'testuser1') + .single() + expect(resNullable).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "en": null, + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + expect(resRequired).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "status": "ACTIVE", + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) + let resultNullable: Exclude + let resultRequired: Exclude + const ExpectedSchemaNullable = z.object({ + en: z.enum(['ONE', 'TWO', 'THREE']).nullable(), + }) + const ExpectedSchemaRequired = z.object({ + status: z.enum(['ACTIVE', 'INACTIVE']), + }) + let expectedNullable: z.infer + let expectedRequired: z.infer + expectType>(true) + expectType>(true) + ExpectedSchemaNullable.parse(resNullable.data) + ExpectedSchemaRequired.parse(resRequired.data) +}) + test('self reference relation', async () => { const res = await postgrest.from('collections').select('*, collections(*)').limit(1).single() expect(res).toMatchInlineSnapshot(` diff --git a/packages/core/postgrest-js/test/resource-embedding.test.ts b/packages/core/postgrest-js/test/resource-embedding.test.ts index 554442a95..c23a704a2 100644 --- a/packages/core/postgrest-js/test/resource-embedding.test.ts +++ b/packages/core/postgrest-js/test/resource-embedding.test.ts @@ -50,6 +50,15 @@ test('embedded select', async () => { { "messages": [], }, + { + "messages": [], + }, + { + "messages": [], + }, + { + "messages": [], + }, ], "error": null, "status": 200, @@ -124,6 +133,15 @@ test('embedded select with computed field explicit selection', async () => { { "messages": [], }, + { + "messages": [], + }, + { + "messages": [], + }, + { + "messages": [], + }, ], "error": null, "status": 200, @@ -186,6 +204,15 @@ describe('embedded filters', () => { { "messages": [], }, + { + "messages": [], + }, + { + "messages": [], + }, + { + "messages": [], + }, ], "error": null, "status": 200, @@ -251,6 +278,15 @@ describe('embedded filters', () => { { "messages": [], }, + { + "messages": [], + }, + { + "messages": [], + }, + { + "messages": [], + }, ], "error": null, "status": 200, @@ -318,6 +354,15 @@ describe('embedded filters', () => { { "messages": [], }, + { + "messages": [], + }, + { + "messages": [], + }, + { + "messages": [], + }, ], "error": null, "status": 200, @@ -393,6 +438,15 @@ describe('embedded transforms', () => { { "messages": [], }, + { + "messages": [], + }, + { + "messages": [], + }, + { + "messages": [], + }, ], "error": null, "status": 200, @@ -467,6 +521,15 @@ describe('embedded transforms', () => { { "messages": [], }, + { + "messages": [], + }, + { + "messages": [], + }, + { + "messages": [], + }, ], "error": null, "status": 200, @@ -526,6 +589,15 @@ describe('embedded transforms', () => { { "messages": [], }, + { + "messages": [], + }, + { + "messages": [], + }, + { + "messages": [], + }, ], "error": null, "status": 200, @@ -585,6 +657,15 @@ describe('embedded transforms', () => { { "messages": [], }, + { + "messages": [], + }, + { + "messages": [], + }, + { + "messages": [], + }, ], "error": null, "status": 200, diff --git a/packages/core/postgrest-js/test/returns.test-d.ts b/packages/core/postgrest-js/test/returns.test-d.ts index 9059bc5ca..78e734005 100644 --- a/packages/core/postgrest-js/test/returns.test-d.ts +++ b/packages/core/postgrest-js/test/returns.test-d.ts @@ -374,7 +374,7 @@ const postgrest = new PostgrestClient(REST_URL) let resultType: Exclude let resultFifty: (typeof resultType)['fifty'] let resultUsernameNested: (typeof resultType)['messages'][0]['username'] - let expected: string + let expected: null expectType>(true) - expectType>(true) + expectType>(true) } diff --git a/packages/core/postgrest-js/test/select-query-parser/json-path.test-d.ts b/packages/core/postgrest-js/test/select-query-parser/json-path.test-d.ts new file mode 100644 index 000000000..3e75164c8 --- /dev/null +++ b/packages/core/postgrest-js/test/select-query-parser/json-path.test-d.ts @@ -0,0 +1,50 @@ +import { expectType, TypeEqual } from '../types' +import { JsonPathToType } from '../../src/select-query-parser/utils' + +// Test JsonPathToType with non-nullable JSON +{ + type Data = { + a: { + b: number + } + } + + type result = JsonPathToType + expectType>(true) +} + +// Test JsonPathToType with nullable JSON +{ + type Data = { + a: { + b: number + } + } | null + + type result = JsonPathToType + expectType>(true) +} + +// Test JsonPathToType with nested nullable JSON +{ + type Data = { + a: { + b: { + c: string + } | null + } + } + + type result = JsonPathToType + expectType>(true) +} + +// Test JsonPathToType with nullable root and path +{ + type Data = { + key: string + } | null + + type result = JsonPathToType + expectType>(true) +} diff --git a/packages/core/postgrest-js/test/supabase/migrations/00000000000000_schema.sql b/packages/core/postgrest-js/test/supabase/migrations/00000000000000_schema.sql index 337bf300e..9ff1a5bb9 100644 --- a/packages/core/postgrest-js/test/supabase/migrations/00000000000000_schema.sql +++ b/packages/core/postgrest-js/test/supabase/migrations/00000000000000_schema.sql @@ -15,6 +15,13 @@ CREATE TABLE public.users ( status user_status DEFAULT 'ONLINE'::public.user_status, catchphrase tsvector DEFAULT null ); + +-- Table for testing required (non-nullable) JSON fields +CREATE TABLE public.users_with_required_json ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + username text NOT NULL, + required_data jsonb NOT NULL +); ALTER TABLE public.users REPLICA IDENTITY FULL; -- Send "previous data" to supabase COMMENT ON COLUMN public.users.data IS 'For unstructured data and prototyping.'; diff --git a/packages/core/postgrest-js/test/supabase/seed.sql b/packages/core/postgrest-js/test/supabase/seed.sql index d15609ca0..460e3bb2d 100644 --- a/packages/core/postgrest-js/test/supabase/seed.sql +++ b/packages/core/postgrest-js/test/supabase/seed.sql @@ -5,7 +5,17 @@ VALUES ('kiwicopple', 'OFFLINE', '[25,35)'::int4range, 'cat bat'::tsvector, NUlL), ('awailas', 'ONLINE', '[25,35)'::int4range, 'bat rat'::tsvector, NULL), ('dragarcia', 'ONLINE', '[20,30)'::int4range, 'rat fat'::tsvector, NULL), - ('jsonuser', 'ONLINE', '[20,30)'::int4range, 'json test'::tsvector, '{"foo": {"bar": {"nested": "value"}, "baz": "string value"}}'::jsonb); + ('jsonuser', 'ONLINE', '[20,30)'::int4range, 'json test'::tsvector, '{"foo": "string value", "fooRecord": {"bar": {"nested": "value"}, "baz": "string value"}}'::jsonb), + ('jsonusernull', 'ONLINE', '[20,30)'::int4range, 'json null test'::tsvector, '{"foo": "string value", "bar": null, "en": "ONE", "fooRecord": {"bar": null, "baz": "string value"}}'::jsonb), + ('jsonuserobj', 'ONLINE', '[20,30)'::int4range, 'json obj test'::tsvector, '{"bar": {"baz": 42}, "en": "TWO", "fooRecord": {"bar": {"nested": "deep"}, "baz": "test"}}'::jsonb), + ('jsonusermissing', 'ONLINE', '[20,30)'::int4range, 'json missing test'::tsvector, '{"foo": "string", "en": "THREE"}'::jsonb); + +INSERT INTO + public.users_with_required_json (username, required_data) +VALUES + ('testuser1', '{"id": 1, "name": "testuser1", "nested": {"value": "test", "count": 10}, "status": "ACTIVE"}'::jsonb), + ('testuser2', '{"id": 2, "name": "testuser2", "nested": {"value": "data", "count": 20}, "status": "INACTIVE"}'::jsonb), + ('testuser3', '{"id": 3, "name": "testuser3", "nested": {"value": "info", "count": 30}, "status": "ACTIVE"}'::jsonb); INSERT INTO public.channels (slug) diff --git a/packages/core/postgrest-js/test/transforms.test.ts b/packages/core/postgrest-js/test/transforms.test.ts index 1b4a3a76e..8f5b50134 100644 --- a/packages/core/postgrest-js/test/transforms.test.ts +++ b/packages/core/postgrest-js/test/transforms.test.ts @@ -25,11 +25,55 @@ test('order', async () => { "status": "OFFLINE", "username": "kiwicopple", }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'obj' 'test'", + "data": { + "bar": { + "baz": 42, + }, + "en": "TWO", + "fooRecord": { + "bar": { + "nested": "deep", + }, + "baz": "test", + }, + }, + "status": "ONLINE", + "username": "jsonuserobj", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'null' 'test'", + "data": { + "bar": null, + "en": "ONE", + "foo": "string value", + "fooRecord": { + "bar": null, + "baz": "string value", + }, + }, + "status": "ONLINE", + "username": "jsonusernull", + }, + { + "age_range": "[20,30)", + "catchphrase": "'json' 'missing' 'test'", + "data": { + "en": "THREE", + "foo": "string", + }, + "status": "ONLINE", + "username": "jsonusermissing", + }, { "age_range": "[20,30)", "catchphrase": "'json' 'test'", "data": { - "foo": { + "foo": "string value", + "fooRecord": { "bar": { "nested": "value", }, @@ -145,7 +189,8 @@ test('range', async () => { "age_range": "[20,30)", "catchphrase": "'json' 'test'", "data": { - "foo": { + "foo": "string value", + "fooRecord": { "bar": { "nested": "value", }, @@ -285,7 +330,10 @@ test('csv', async () => { supabot,,"[1,2)",ONLINE,"'cat' 'fat'" kiwicopple,,"[25,35)",OFFLINE,"'bat' 'cat'" awailas,,"[25,35)",ONLINE,"'bat' 'rat'" - jsonuser,"{""foo"": {""bar"": {""nested"": ""value""}, ""baz"": ""string value""}}","[20,30)",ONLINE,"'json' 'test'" + jsonuser,"{""foo"": ""string value"", ""fooRecord"": {""bar"": {""nested"": ""value""}, ""baz"": ""string value""}}","[20,30)",ONLINE,"'json' 'test'" + jsonusernull,"{""en"": ""ONE"", ""bar"": null, ""foo"": ""string value"", ""fooRecord"": {""bar"": null, ""baz"": ""string value""}}","[20,30)",ONLINE,"'json' 'null' 'test'" + jsonuserobj,"{""en"": ""TWO"", ""bar"": {""baz"": 42}, ""fooRecord"": {""bar"": {""nested"": ""deep""}, ""baz"": ""test""}}","[20,30)",ONLINE,"'json' 'obj' 'test'" + jsonusermissing,"{""en"": ""THREE"", ""foo"": ""string""}","[20,30)",ONLINE,"'json' 'missing' 'test'" dragarcia,,"[20,30)",ONLINE,"'fat' 'rat'"", "error": null, "status": 200, diff --git a/packages/core/postgrest-js/test/types.generated.ts b/packages/core/postgrest-js/test/types.generated.ts index c3700b3d5..e20392c26 100644 --- a/packages/core/postgrest-js/test/types.generated.ts +++ b/packages/core/postgrest-js/test/types.generated.ts @@ -571,6 +571,24 @@ export type Database = { } Relationships: [] } + users_with_required_json: { + Row: { + id: number + required_data: Json + username: string + } + Insert: { + id?: number + required_data: Json + username: string + } + Update: { + id?: number + required_data?: Json + username?: string + } + Relationships: [] + } } Views: { active_users: { diff --git a/packages/core/postgrest-js/test/types.override.ts b/packages/core/postgrest-js/test/types.override.ts index df1047891..785107a84 100644 --- a/packages/core/postgrest-js/test/types.override.ts +++ b/packages/core/postgrest-js/test/types.override.ts @@ -3,17 +3,32 @@ import { MergeDeep } from 'type-fest' import { z } from 'zod' -export const CustomUserDataTypeSchema = z.object({ - foo: z.string(), - bar: z.object({ - baz: z.number(), +export const CustomUserDataTypeSchema = z + .object({ + foo: z.string(), + fooRecord: z.object({ bar: z.record(z.string(), z.unknown()), baz: z.string() }), + bar: z.object({ + baz: z.number(), + }), + en: z.enum(['ONE', 'TWO', 'THREE']), + record: z.record(z.string(), z.unknown()).nullable(), + recordNumber: z.record(z.number(), z.unknown()).nullable(), + }) + .nullable() + +export type CustomUserDataType = z.infer + +export const RequiredUserDataTypeSchema = z.object({ + id: z.number(), + name: z.string(), + nested: z.object({ + value: z.string(), + count: z.number(), }), - en: z.enum(['ONE', 'TWO', 'THREE']), - record: z.record(z.string(), z.unknown()).nullable(), - recordNumber: z.record(z.number(), z.unknown()).nullable(), + status: z.enum(['ACTIVE', 'INACTIVE']), }) -export type CustomUserDataType = z.infer +export type RequiredUserDataType = z.infer export type Database = MergeDeep< GeneratedDatabase, @@ -22,13 +37,13 @@ export type Database = MergeDeep< Tables: { users: { Row: { - data: CustomUserDataType | null + data: CustomUserDataType } Insert: { - data?: CustomUserDataType | null + data?: CustomUserDataType } Update: { - data?: CustomUserDataType | null + data?: CustomUserDataType } } } @@ -52,13 +67,30 @@ export type Database = MergeDeep< Tables: { users: { Row: { - data: CustomUserDataType | null + data: CustomUserDataType + } + Insert: { + data?: CustomUserDataType + } + Update: { + data?: CustomUserDataType + } + } + users_with_required_json: { + Row: { + id: number + username: string + required_data: RequiredUserDataType } Insert: { - data?: CustomUserDataType | null + id?: number + username: string + required_data: RequiredUserDataType } Update: { - data?: CustomUserDataType | null + id?: number + username?: string + required_data?: RequiredUserDataType } } }