diff --git a/src/select-query-parser/parser.ts b/src/select-query-parser/parser.ts index a3944d73..c4336fe4 100644 --- a/src/select-query-parser/parser.ts +++ b/src/select-query-parser/parser.ts @@ -2,6 +2,7 @@ // See https://github.com/PostgREST/postgrest/blob/2f91853cb1de18944a4556df09e52450b881cfb3/src/PostgREST/ApiRequest/QueryParams.hs#L282-L284 import { SimplifyDeep } from '../types' +import { JsonPathToAccessor } from './utils' /** * Parses a query. @@ -220,13 +221,24 @@ type ParseNonEmbeddedResourceField = ParseIdentifier${infer _}` + Remainder extends `->${infer PathAndRest}` ? ParseJsonAccessor extends [ infer PropertyName, infer PropertyType, `${infer Remainder}` ] - ? [{ type: 'field'; name: Name; alias: PropertyName; castType: PropertyType }, Remainder] + ? [ + { + type: 'field' + name: Name + alias: PropertyName + castType: PropertyType + jsonPath: JsonPathToAccessor< + PathAndRest extends `${infer Path},${string}` ? Path : PathAndRest + > + }, + Remainder + ] : ParseJsonAccessor : [{ type: 'field'; name: Name }, Remainder] ) extends infer Parsed @@ -401,6 +413,7 @@ export namespace Ast { hint?: string innerJoin?: true castType?: string + jsonPath?: string aggregateFunction?: Token.AggregateFunction children?: Node[] } diff --git a/src/select-query-parser/result.ts b/src/select-query-parser/result.ts index 4af7d05e..c1cd42ef 100644 --- a/src/select-query-parser/result.ts +++ b/src/select-query-parser/result.ts @@ -15,6 +15,8 @@ import { GetFieldNodeResultName, IsAny, IsRelationNullable, + IsStringUnion, + JsonPathToType, ResolveRelationship, SelectQueryError, } from './utils' @@ -239,6 +241,30 @@ type ProcessFieldNode< ? ProcessEmbeddedResource : ProcessSimpleField +type ResolveJsonPathType< + Value, + Path extends string | undefined, + CastType extends PostgreSQLTypes +> = Path extends string + ? JsonPathToType extends never + ? // Always fallback if JsonPathToType returns never + TypeScriptTypes + : JsonPathToType extends infer PathResult + ? 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 + PathResult + : 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 + /** * Processes a simple field (without embedded resources). * @@ -261,8 +287,8 @@ type ProcessSimpleField< } : { // Aliases override the property name in the result - [K in GetFieldNodeResultName]: Field['castType'] extends PostgreSQLTypes // We apply the detected casted as the result type - ? TypeScriptTypes + [K in GetFieldNodeResultName]: Field['castType'] extends PostgreSQLTypes + ? ResolveJsonPathType : Row[Field['name']] } : SelectQueryError<`column '${Field['name']}' does not exist on '${RelationName}'.`> diff --git a/src/select-query-parser/utils.ts b/src/select-query-parser/utils.ts index b3691e93..bcbfef04 100644 --- a/src/select-query-parser/utils.ts +++ b/src/select-query-parser/utils.ts @@ -544,3 +544,37 @@ export type FindFieldMatchingRelationships< name: Field['name'] } : SelectQueryError<'Failed to find matching relation via name'> + +export type JsonPathToAccessor = Path extends `${infer P1}->${infer P2}` + ? P2 extends `>${infer Rest}` // Handle ->> operator + ? JsonPathToAccessor<`${P1}.${Rest}`> + : P2 extends string // Handle -> operator + ? JsonPathToAccessor<`${P1}.${P2}`> + : Path + : Path extends `>${infer Rest}` // Clean up any remaining > characters + ? JsonPathToAccessor + : Path extends `${infer P1}::${infer _}` // Handle type casting + ? JsonPathToAccessor + : Path extends `${infer P1}${')' | ','}${infer _}` // Handle closing parenthesis and comma + ? P1 + : Path + +export type JsonPathToType = Path extends '' + ? T + : ContainsNull extends true + ? JsonPathToType, Path> + : Path extends `${infer Key}.${infer Rest}` + ? Key extends keyof T + ? JsonPathToType + : never + : Path extends keyof T + ? T[Path] + : never + +export type IsStringUnion = string extends T + ? false + : T extends string + ? [T] extends [never] + ? false + : true + : false diff --git a/test/basic.ts b/test/basic.ts index 5e578473..37b8879e 100644 --- a/test/basic.ts +++ b/test/basic.ts @@ -1,5 +1,5 @@ import { PostgrestClient } from '../src/index' -import { Database } from './types' +import { CustomUserDataType, Database } from './types' const REST_URL = 'http://localhost:3000' const postgrest = new PostgrestClient(REST_URL) @@ -1693,7 +1693,10 @@ test('select with no match', async () => { }) test('update with no match - return=minimal', async () => { - const res = await postgrest.from('users').update({ data: '' }).eq('username', 'missing') + const res = await postgrest + .from('users') + .update({ data: '' as unknown as CustomUserDataType }) + .eq('username', 'missing') expect(res).toMatchInlineSnapshot(` Object { "count": null, @@ -1706,7 +1709,11 @@ test('update with no match - return=minimal', async () => { }) test('update with no match - return=representation', async () => { - const res = await postgrest.from('users').update({ data: '' }).eq('username', 'missing').select() + const res = await postgrest + .from('users') + .update({ data: '' as unknown as CustomUserDataType }) + .eq('username', 'missing') + .select() expect(res).toMatchInlineSnapshot(` Object { "count": null, diff --git a/test/index.test-d.ts b/test/index.test-d.ts index 745b8c40..5db92c5d 100644 --- a/test/index.test-d.ts +++ b/test/index.test-d.ts @@ -53,87 +53,93 @@ const postgrest = new PostgrestClient(REST_URL) ) { - const { data, error } = await postgrest.from('users').select('status').eq('status', 'ONLINE') - if (error) { - throw new Error(error.message) + const result = await postgrest.from('users').select('status').eq('status', 'ONLINE') + if (result.error) { + throw new Error(result.error.message) } - expectType<{ status: Database['public']['Enums']['user_status'] | null }[]>(data) + expectType<{ status: Database['public']['Enums']['user_status'] | null }[]>(result.data) } { - const { data, error } = await postgrest.from('users').select('status').neq('status', 'ONLINE') - if (error) { - throw new Error(error.message) + const result = await postgrest.from('users').select('status').neq('status', 'ONLINE') + if (result.error) { + throw new Error(result.error.message) } - expectType<{ status: Database['public']['Enums']['user_status'] | null }[]>(data) + expectType<{ status: Database['public']['Enums']['user_status'] | null }[]>(result.data) } { - const { data, error } = await postgrest + const result = await postgrest .from('users') .select('status') .in('status', ['ONLINE', 'OFFLINE']) - if (error) { - throw new Error(error.message) + if (result.error) { + throw new Error(result.error.message) } - expectType<{ status: Database['public']['Enums']['user_status'] | null }[]>(data) + expectType<{ status: Database['public']['Enums']['user_status'] | null }[]>(result.data) } { - const { data, error } = await postgrest + const result = await postgrest .from('best_friends') .select('users!first_user(status)') .eq('users.status', 'ONLINE') - if (error) { - throw new Error(error.message) + if (result.error) { + throw new Error(result.error.message) } - expectType<{ users: { status: Database['public']['Enums']['user_status'] | null } }[]>(data) + expectType<{ users: { status: Database['public']['Enums']['user_status'] | null } }[]>( + result.data + ) } { - const { data, error } = await postgrest + const result = await postgrest .from('best_friends') .select('users!first_user(status)') .neq('users.status', 'ONLINE') - if (error) { - throw new Error(error.message) + if (result.error) { + throw new Error(result.error.message) } - expectType<{ users: { status: Database['public']['Enums']['user_status'] | null } }[]>(data) + expectType<{ users: { status: Database['public']['Enums']['user_status'] | null } }[]>( + result.data + ) } { - const { data, error } = await postgrest + const result = await postgrest .from('best_friends') .select('users!first_user(status)') .in('users.status', ['ONLINE', 'OFFLINE']) - if (error) { - throw new Error(error.message) + if (result.error) { + throw new Error(result.error.message) } - expectType<{ users: { status: Database['public']['Enums']['user_status'] | null } }[]>(data) + expectType<{ users: { status: Database['public']['Enums']['user_status'] | null } }[]>( + result.data + ) } } // can override result type { - const { data, error } = await postgrest + const result = await postgrest .from('users') .select('*, messages(*)') .returns<{ messages: { foo: 'bar' }[] }[]>() - if (error) { - throw new Error(error.message) + if (result.error) { + throw new Error(result.error.message) } - expectType<{ foo: 'bar' }[]>(data[0].messages) + expectType<{ foo: 'bar' }[]>(result.data[0].messages) } { - const { data, error } = await postgrest + const result = await postgrest .from('users') .insert({ username: 'foo' }) .select('*, messages(*)') .returns<{ messages: { foo: 'bar' }[] }[]>() - if (error) { - throw new Error(error.message) + if (result.error) { + throw new Error(result.error.message) } - expectType<{ foo: 'bar' }[]>(data[0].messages) + expectType<{ foo: 'bar' }[]>(result.data[0].messages) } // cannot update non-updatable views @@ -148,60 +154,54 @@ const postgrest = new PostgrestClient(REST_URL) // spread resource with single column in select query { - const { data, error } = await postgrest - .from('messages') - .select('message, ...users(status)') - .single() - if (error) { - throw new Error(error.message) + const result = await postgrest.from('messages').select('message, ...users(status)').single() + if (result.error) { + throw new Error(result.error.message) } expectType<{ message: string | null; status: Database['public']['Enums']['user_status'] | null }>( - data + result.data ) } // spread resource with all columns in select query { - const { data, error } = await postgrest.from('messages').select('message, ...users(*)').single() - if (error) { - throw new Error(error.message) + const result = await postgrest.from('messages').select('message, ...users(*)').single() + if (result.error) { + throw new Error(result.error.message) } expectType>( - data + result.data ) } // `count` in embedded resource { - const { data, error } = await postgrest.from('messages').select('message, users(count)').single() - if (error) { - throw new Error(error.message) + const result = await postgrest.from('messages').select('message, users(count)').single() + if (result.error) { + throw new Error(result.error.message) } - expectType<{ message: string | null; users: { count: number } }>(data) + expectType<{ message: string | null; users: { count: number } }>(result.data) } // json accessor in select query { - const { data, error } = await postgrest - .from('users') - .select('data->foo->bar, data->foo->>baz') - .single() - if (error) { - throw new Error(error.message) + const result = await postgrest.from('users').select('data->foo->bar, data->foo->>baz').single() + if (result.error) { + throw new Error(result.error.message) } // getting this w/o the cast, not sure why: // Parameter type Json is declared too wide for argument type Json - expectType(data.bar) - expectType(data.baz) + expectType(result.data.bar) + expectType(result.data.baz) } // rpc return type { - const { data, error } = await postgrest.rpc('get_status') - if (error) { - throw new Error(error.message) + const result = await postgrest.rpc('get_status') + if (result.error) { + throw new Error(result.error.message) } - expectType<'ONLINE' | 'OFFLINE'>(data) + expectType<'ONLINE' | 'OFFLINE'>(result.data) } // PostgrestBuilder's children retains class when using inherited methods @@ -276,3 +276,40 @@ const postgrest = new PostgrestClient(REST_URL) expectType>(true) error } + +// Json Accessor with custom types overrides +{ + const result = await postgrest + .schema('personal') + .from('users') + .select('data->bar->baz, data->en, data->bar') + if (result.error) { + throw new Error(result.error.message) + } + expectType< + { + baz: number + en: 'ONE' | 'TWO' | 'THREE' + bar: { + baz: number + } + }[] + >(result.data) +} +// Json string Accessor with custom types overrides +{ + const result = await postgrest + .schema('personal') + .from('users') + .select('data->bar->>baz, data->>en, data->>bar') + if (result.error) { + throw new Error(result.error.message) + } + expectType< + { + baz: string + en: 'ONE' | 'TWO' | 'THREE' + bar: string + }[] + >(result.data) +} diff --git a/test/select-query-parser/parser.test-d.ts b/test/select-query-parser/parser.test-d.ts index 8c7291ac..b241e13d 100644 --- a/test/select-query-parser/parser.test-d.ts +++ b/test/select-query-parser/parser.test-d.ts @@ -81,17 +81,53 @@ import { selectParams } from '../relationships' // Select with JSON accessor { expectTypepreferences->theme'>>([ - { type: 'field', name: 'data', alias: 'theme', castType: 'json' }, + { + type: 'field', + name: 'data', + alias: 'theme', + castType: 'json', + jsonPath: 'preferences.theme', + }, ]) } // Select with JSON accessor and text conversion { expectTypepreferences->>theme'>>([ - { type: 'field', name: 'data', alias: 'theme', castType: 'text' }, + { + type: 'field', + name: 'data', + alias: 'theme', + castType: 'text', + jsonPath: 'preferences.theme', + }, + ]) +} +{ + expectTypepreferences->>theme, data->>some, data->foo->bar->>biz'>>([ + { + type: 'field', + name: 'data', + alias: 'theme', + castType: 'text', + jsonPath: 'preferences.theme', + }, + { + type: 'field', + name: 'data', + alias: 'some', + castType: 'text', + jsonPath: 'some', + }, + { + type: 'field', + name: 'data', + alias: 'biz', + castType: 'text', + jsonPath: 'foo.bar.biz', + }, ]) } - // Select with spread { expectType>([ @@ -196,7 +232,13 @@ import { selectParams } from '../relationships' }, ], }, - { type: 'field', name: 'profile', alias: 'theme', castType: 'text' }, + { + type: 'field', + name: 'profile', + alias: 'theme', + castType: 'text', + jsonPath: 'settings.theme', + }, ]) } { @@ -327,7 +369,13 @@ import { selectParams } from '../relationships' // Select with nested JSON accessors { expectTypepreferences->theme->color'>>([ - { type: 'field', name: 'data', alias: 'color', castType: 'json' }, + { + type: 'field', + name: 'data', + alias: 'color', + castType: 'json', + jsonPath: 'preferences.theme.color', + }, ]) } @@ -464,7 +512,7 @@ import { selectParams } from '../relationships' expectTypeage::int'>>([ { type: 'field', name: 'id', castType: 'text' }, { type: 'field', name: 'created_at', castType: 'date' }, - { type: 'field', name: 'data', alias: 'age', castType: 'int' }, + { type: 'field', name: 'data', alias: 'age', castType: 'int', jsonPath: 'age' }, ]) } @@ -480,8 +528,8 @@ import { selectParams } from '../relationships' // select JSON accessor { expect>([ - { type: 'field', name: 'data', alias: 'bar', castType: 'json' }, - { type: 'field', name: 'data', alias: 'baz', castType: 'text' }, + { type: 'field', name: 'data', alias: 'bar', castType: 'json', jsonPath: 'foo.bar' }, + { type: 'field', name: 'data', alias: 'baz', castType: 'text', jsonPath: 'foo.baz' }, ]) } @@ -614,3 +662,36 @@ import { selectParams } from '../relationships' 0 as any as ParserError<'Unexpected input: ->->theme'> ) } + +// JSON accessor within embedded tables +{ + expectTypebar->>baz, data->>en, data->bar)'>>([ + { + type: 'field', + name: 'users', + children: [ + { + type: 'field', + name: 'data', + alias: 'baz', + castType: 'text', + jsonPath: 'bar.baz', + }, + { + type: 'field', + name: 'data', + alias: 'en', + castType: 'text', + jsonPath: 'en', + }, + { + type: 'field', + name: 'data', + alias: 'bar', + castType: 'json', + jsonPath: 'bar', + }, + ], + }, + ]) +} diff --git a/test/select-query-parser/result.test-d.ts b/test/select-query-parser/result.test-d.ts index 74a40836..508424f0 100644 --- a/test/select-query-parser/result.test-d.ts +++ b/test/select-query-parser/result.test-d.ts @@ -118,3 +118,79 @@ type SelectQueryFromTableResult< expectType(result2!) expectType(result3!) } + +{ + type SelectQueryFromPersonalTableResult< + TableName extends keyof Database['personal']['Tables'], + Q extends string + > = GetResult< + Database['personal'], + Database['personal']['Tables'][TableName]['Row'], + TableName, + Database['personal']['Tables'][TableName]['Relationships'], + Q + > + // Should work with Json object accessor + { + let result: SelectQueryFromPersonalTableResult<'users', `data->bar->baz, data->en, data->bar`> + let expected: { + baz: number + en: 'ONE' | 'TWO' | 'THREE' + bar: { + baz: number + } + } + expectType>(true) + } + // Should work with Json string accessor + { + let result: SelectQueryFromPersonalTableResult< + 'users', + `data->bar->>baz, data->>en, data->>bar` + > + let expected: { + baz: string + en: 'ONE' | 'TWO' | 'THREE' + bar: string + } + expectType>(true) + } + // Should fallback to defaults if unknown properties are mentionned + { + let result: SelectQueryFromPersonalTableResult<'users', `data->bar->>nope, data->neither`> + let expected: { + nope: string + neither: Json + } + expectType>(true) + } + // Should work with embeded Json object accessor + { + let result: SelectQueryFromTableResult<'messages', `users(data->bar->baz, data->en, data->bar)`> + let expected: { + users: { + baz: number + en: 'ONE' | 'TWO' | 'THREE' + bar: { + baz: number + } + } + } + expectType>(true) + } + // Should work with embeded Json string accessor + { + let result: SelectQueryFromTableResult< + 'messages', + `users(data->bar->>baz, data->>en, data->>bar)` + > + let expected: { + users: { + baz: string + en: 'ONE' | 'TWO' | 'THREE' + bar: string + } + } + expectType>(true) + } +} diff --git a/test/select-query-parser/select.test-d.ts b/test/select-query-parser/select.test-d.ts index 2343fa6f..adc54416 100644 --- a/test/select-query-parser/select.test-d.ts +++ b/test/select-query-parser/select.test-d.ts @@ -3,7 +3,7 @@ import { TypeEqual } from 'ts-expect' import { Json } from '../../src/select-query-parser/types' import { SelectQueryError } from '../../src/select-query-parser/utils' import { Prettify } from '../../src/types' -import { Database } from '../types' +import { CustomUserDataType, Database } from '../types' import { selectQueries } from '../relationships' // This test file is here to ensure that for a query against a specfic datatabase @@ -617,7 +617,7 @@ type Schema = Database['public'] users: { age_range: unknown | null catchphrase: unknown | null - data: Json | null + data: CustomUserDataType | null status: Database['public']['Enums']['user_status'] | null username: string } diff --git a/test/types.ts b/test/types.ts index 8f84eced..47d9bde3 100644 --- a/test/types.ts +++ b/test/types.ts @@ -1,24 +1,32 @@ export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] +export type CustomUserDataType = { + foo: string + bar: { + baz: number + } + en: 'ONE' | 'TWO' | 'THREE' +} + export type Database = { personal: { Tables: { users: { Row: { age_range: unknown | null - data: Json | null + data: CustomUserDataType | null status: Database['public']['Enums']['user_status'] | null username: string } Insert: { age_range?: unknown | null - data?: Json | null + data?: CustomUserDataType | null status?: Database['public']['Enums']['user_status'] | null username: string } Update: { age_range?: unknown | null - data?: Json | null + data?: CustomUserDataType | null status?: Database['public']['Enums']['user_status'] | null username?: string } @@ -422,21 +430,21 @@ export type Database = { Row: { age_range: unknown | null catchphrase: unknown | null - data: Json | null + data: CustomUserDataType | null status: Database['public']['Enums']['user_status'] | null username: string } Insert: { age_range?: unknown | null catchphrase?: unknown | null - data?: Json | null + data?: CustomUserDataType | null status?: Database['public']['Enums']['user_status'] | null username: string } Update: { age_range?: unknown | null catchphrase?: unknown | null - data?: Json | null + data?: CustomUserDataType | null status?: Database['public']['Enums']['user_status'] | null username?: string }