Skip to content

Commit

Permalink
Merge pull request #592 from supabase/feat/add-json-path-type-inference
Browse files Browse the repository at this point in the history
feat(types): add json path type inference
  • Loading branch information
avallete authored Jan 20, 2025
2 parents 633991c + 978d88d commit 7e985d4
Show file tree
Hide file tree
Showing 9 changed files with 364 additions and 82 deletions.
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<
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
34 changes: 34 additions & 0 deletions src/select-query-parser/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading

0 comments on commit 7e985d4

Please sign in to comment.