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 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
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
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}`
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 extends `${infer P1}${')' | ','}${infer _}` // Handle closing parenthesis and comma
? 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
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