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
}