Skip to content

feat(types): add json path type inference #592

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jan 20, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/select-query-parser/parser.ts
Original file line number Diff line number Diff line change
@@ -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<Input extends string> = ParseIdentifier<Input
]
? // Parse optional JSON path.
(
Remainder extends `->${infer _}`
Remainder extends `->${infer PathAndRest}`
? ParseJsonAccessor<Remainder> 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<Remainder>
: [{ 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[]
}
30 changes: 28 additions & 2 deletions src/select-query-parser/result.ts
Original file line number Diff line number Diff line change
@@ -15,6 +15,8 @@ import {
GetFieldNodeResultName,
IsAny,
IsRelationNullable,
IsStringUnion,
JsonPathToType,
ResolveRelationship,
SelectQueryError,
} from './utils'
@@ -239,6 +241,30 @@ type ProcessFieldNode<
? ProcessEmbeddedResource<Schema, Relationships, Field, RelationName>
: ProcessSimpleField<Row, RelationName, Field>

type ResolveJsonPathType<
Value,
Path extends string | undefined,
CastType extends PostgreSQLTypes
> = Path extends string
? JsonPathToType<Value, Path> extends never
? // Always fallback if JsonPathToType returns never
TypeScriptTypes<CastType>
: JsonPathToType<Value, Path> 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<PathResult> 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<CastType>
: TypeScriptTypes<CastType>
: // No json path, use regular type casting
TypeScriptTypes<CastType>

/**
* 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>]: Field['castType'] extends PostgreSQLTypes // We apply the detected casted as the result type
? TypeScriptTypes<Field['castType']>
[K in GetFieldNodeResultName<Field>]: Field['castType'] extends PostgreSQLTypes
? ResolveJsonPathType<Row[Field['name']], Field['jsonPath'], Field['castType']>
: Row[Field['name']]
}
: SelectQueryError<`column '${Field['name']}' does not exist on '${RelationName}'.`>
34 changes: 34 additions & 0 deletions src/select-query-parser/utils.ts
Original file line number Diff line number Diff line change
@@ -544,3 +544,37 @@ export type FindFieldMatchingRelationships<
name: Field['name']
}
: SelectQueryError<'Failed to find matching relation via name'>

export type JsonPathToAccessor<Path extends string> = 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<Rest>
: Path extends `${infer P1}::${infer _}` // Handle type casting
? JsonPathToAccessor<P1>
: Path extends `${infer P1}${')' | ','}${infer _}` // Handle closing parenthesis and comma
? P1
: Path

export type JsonPathToType<T, Path extends string> = Path extends ''
? T
: ContainsNull<T> extends true
? JsonPathToType<Exclude<T, null>, Path>
: Path extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? JsonPathToType<T[Key], Rest>
: never
: Path extends keyof T
? T[Path]
: never

export type IsStringUnion<T> = string extends T
? false
: T extends string
? [T] extends [never]
? false
: true
: false
13 changes: 10 additions & 3 deletions test/basic.ts
Original file line number Diff line number Diff line change
@@ -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<Database>(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,
155 changes: 96 additions & 59 deletions test/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -53,87 +53,93 @@ const postgrest = new PostgrestClient<Database>(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<Database>(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<Prettify<{ message: string | null } & Database['public']['Tables']['users']['Row']>>(
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<Json>(data.bar)
expectType<string>(data.baz)
expectType<Json>(result.data.bar)
expectType<string>(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<Database>(REST_URL)
expectType<TypeEqual<typeof error, null>>(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)
}
97 changes: 89 additions & 8 deletions test/select-query-parser/parser.test-d.ts
Original file line number Diff line number Diff line change
@@ -81,17 +81,53 @@ import { selectParams } from '../relationships'
// Select with JSON accessor
{
expectType<ParseQuery<'data->preferences->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
{
expectType<ParseQuery<'data->preferences->>theme'>>([
{ type: 'field', name: 'data', alias: 'theme', castType: 'text' },
{
type: 'field',
name: 'data',
alias: 'theme',
castType: 'text',
jsonPath: 'preferences.theme',
},
])
}
{
expectType<ParseQuery<'data->preferences->>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<ParseQuery<'username, ...posts(id, title)'>>([
@@ -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
{
expectType<ParseQuery<'data->preferences->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'
expectType<ParseQuery<'id::text, created_at::date, data->age::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<ParseQuery<typeof selectParams.selectJsonAccessor.select>>([
{ 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
{
expectType<ParseQuery<'users(data->bar->>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',
},
],
},
])
}
76 changes: 76 additions & 0 deletions test/select-query-parser/result.test-d.ts
Original file line number Diff line number Diff line change
@@ -118,3 +118,79 @@ type SelectQueryFromTableResult<
expectType<typeof result1>(result2!)
expectType<typeof result2>(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<TypeEqual<typeof result, typeof expected>>(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<TypeEqual<typeof result, typeof expected>>(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<TypeEqual<typeof result, typeof expected>>(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<TypeEqual<typeof result, typeof expected>>(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<TypeEqual<typeof result, typeof expected>>(true)
}
}
4 changes: 2 additions & 2 deletions test/select-query-parser/select.test-d.ts
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 14 additions & 6 deletions test/types.ts
Original file line number Diff line number Diff line change
@@ -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
}