Skip to content
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

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

Merged
merged 7 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from 5 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
Expand Up @@ -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.
Expand Down Expand Up @@ -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<
avallete marked this conversation as resolved.
Show resolved Hide resolved
PathAndRest extends `${infer Path},${string}` ? Path : PathAndRest
>
},
Remainder
]
: ParseJsonAccessor<Remainder>
: [{ type: 'field'; name: Name }, Remainder]
) extends infer Parsed
Expand Down Expand Up @@ -401,6 +413,7 @@ export namespace Ast {
hint?: string
innerJoin?: true
castType?: string
jsonPath?: string
aggregateFunction?: Token.AggregateFunction
children?: Node[]
}
Expand Down
30 changes: 28 additions & 2 deletions src/select-query-parser/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
GetFieldNodeResultName,
IsAny,
IsRelationNullable,
IsStringUnion,
JsonPathToType,
ResolveRelationship,
SelectQueryError,
} from './utils'
Expand Down Expand Up @@ -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).
*
Expand All @@ -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}'.`>
Expand Down
32 changes: 32 additions & 0 deletions src/select-query-parser/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,35 @@ 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}`
avallete marked this conversation as resolved.
Show resolved Hide resolved
? 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

export type JsonPathToType<T, Path extends string> = Path extends ''
avallete marked this conversation as resolved.
Show resolved Hide resolved
? 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
155 changes: 96 additions & 59 deletions test/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
avallete marked this conversation as resolved.
Show resolved Hide resolved
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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Loading