Skip to content

Proposal: add some type gymnastics for expanded relations #309

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

Closed
K024 opened this issue Sep 2, 2024 · 2 comments
Closed

Proposal: add some type gymnastics for expanded relations #309

K024 opened this issue Sep 2, 2024 · 2 comments

Comments

@K024
Copy link

K024 commented Sep 2, 2024

Motivation

From v5.0, Typescript is capable to analyze very complex types with string operations. It'll be convenient if the expanded relations can be typed effortlessly, i.e. not requiring any extra type annotations for each function call.

Proof of concept

Typescript playground

Click to expand the long code
/* model definitions */

export interface ListResult<T> {
    page: number;
    perPage: number;
    totalItems: number;
    totalPages: number;
    items: Array<T>;
}

export interface BaseModel {
    [key: string]: any;

    id: string;
    created: string;
    updated: string;
}

export interface AdminModel extends BaseModel {
    avatar: number;
    email: string;
}

export interface RecordModel extends BaseModel {
    collectionId: string;
    collectionName: string;
}


namespace PocketBase.Collections {
    // left empty for dev hint
    export interface Models {}
    export interface Relations {}
}


/* EXPECTED TO BE GENERATED */
namespace PocketBase.Collections {
    export interface UserModel extends RecordModel {
        username: string
        email: string
        name: string
        avatar: string
    }

    export interface PostModel extends RecordModel {
        title: string
        content: string
        author: string
    }

    export interface CommentModel extends RecordModel {
        post: string
        user: string
        message: string
    }

    export interface Models {
        users: UserModel
        posts: PostModel
        comments: CommentModel
    }

    export interface Relations {
        posts: {
            author: "users"
        }
        comments: {
            post: "posts"
            user: "users"
        }
    }
}


/* type gymnastics */

type Models = PocketBase.Collections.Models
type Relations = PocketBase.Collections.Relations


// string operations
type Space = ' ' | '\t' | '\n'

type Trim<S extends string> = S extends `${Space}${infer R}` | `${infer R}${Space}` ? Trim<R> : S

type Split<T extends string, Sep extends string> = T extends `${infer L}${Sep}${infer R}` ? [L, ...Split<R, Sep>] : [T]


// list operations
type FilterList<List extends string[]> = List extends [string, ...any[]] ? List : never

type ProceedPrefix<List extends string[], Prefix> = List extends [Prefix, ...infer Rest extends string[]] ? Rest : never

type IsNever<Test, T, F> = [Test] extends [never] ? T : F


// implementation
type CollectionTypeByName<CollectionName> = CollectionName extends keyof Models ? Models[CollectionName] : never

type ResolveExpansionCollection<CollectionName, Expansion extends string> =
    Expansion extends `${infer RefCollection}_via_${any}`
        ? RefCollection
        : CollectionName extends keyof Relations
            ? Expansion extends keyof Relations[CollectionName]
                ? Relations[CollectionName][Expansion]
                : never
        : never


type WrapListField<T, Expansion extends string> = Expansion extends `${any}_via_${any}` ? T[] : T

type ExpansionField<CollectionName, Expansions extends string[], CurrentField extends string> = 
    WrapListField<
        ExpansionType<
            ResolveExpansionCollection<CollectionName, CurrentField>,
            ProceedPrefix<Expansions, CurrentField>
        >,
        CurrentField
    >

type ExpansionType<CollectionName, Expansions extends string[], Filterd extends string[] = FilterList<Expansions>> =
    & CollectionTypeByName<CollectionName>
    & IsNever<Filterd, { }, { expand: { [K in Filterd[0]]: ExpansionField<CollectionName, Filterd, K> } }>


// "author, comments_via_post.user" => ["author"] | ["comments_via_post", "user"]
type PreprocessExpansionString<T> =
    T extends string
        ? Trim<T> extends ""
            ? never
            : Split<Trim<Split<T, ",">[number]>, ".">
        : never

export type ExpandedCollection<CollectionName extends string, ExpansionString extends string | undefined> =
    CollectionName extends keyof Models
        ? ExpansionType<CollectionName, PreprocessExpansionString<ExpansionString>>
        : any


/* test cases */

import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
    // simple use case
    Expect<Equal<
        ExpandedCollection<"comments", "user, post">,
        PocketBase.Collections.CommentModel & {
            expand: {
                post: PocketBase.Collections.PostModel
                user: PocketBase.Collections.UserModel
            }
        }
    >>,

    // general use case
    Expect<Equal<
        ExpandedCollection<"posts", "author, comments_via_post.user">,
        PocketBase.Collections.PostModel & {
            expand: {
                author: PocketBase.Collections.UserModel
                comments_via_post: (PocketBase.Collections.CommentModel & {
                    expand: {
                        user: PocketBase.Collections.UserModel
                    }
                })[]
            }
        }
    >>,

    // empty fallback
    Expect<Equal<
        ExpandedCollection<"comments", never>,
        PocketBase.Collections.CommentModel
    >>,
    Expect<Equal<
        ExpandedCollection<"comments", "   ">,
        PocketBase.Collections.CommentModel
    >>,

    // deep resolution
    Expect<Equal<
        ExpandedCollection<"comments", "post.author.comments_via_user">,
        PocketBase.Collections.CommentModel & {
            expand: {
                post: PocketBase.Collections.PostModel & {
                    expand: {
                        author: PocketBase.Collections.UserModel & {
                            expand: {
                                comments_via_user: PocketBase.Collections.CommentModel[];
                            };
                        }
                    };
                }
            };
        }
    >>,

    // duplicated names
    Expect<Equal<
        ExpandedCollection<"posts", "author, comments_via_post, comments_via_post.post, comments_via_post.user">,
        PocketBase.Collections.PostModel & {
            expand: {
                author: PocketBase.Collections.UserModel
                comments_via_post: (PocketBase.Collections.CommentModel & {
                    expand: {
                        user: PocketBase.Collections.UserModel
                        post: PocketBase.Collections.PostModel
                    }
                })[]
            }
        }
    >>,

    // bad fields
    Expect<Equal<
        ExpandedCollection<"comments", "a_bad_field">,
        PocketBase.Collections.CommentModel & {
            expand: {
                a_bad_field: never;
            };
        }
    >>,
]


/* usage */

function collection<T extends keyof PocketBase.Collections.Models>(collection: T) {

    interface ViewOptions {
        expand?: string
        // ...
    }

    type ExtractExpand<T> = T extends { expand: infer E } ? E : never

    // use `const TOptions` to prefer constant string inference
    function getOne<const TOptions extends ViewOptions>(id: string, options?: TOptions): Promise<ExpandedCollection<T, ExtractExpand<TOptions>>> {
        throw new Error("Not implemented")
    }

    return {
        getOne
    }
}

const res = await collection("posts").getOne("post_id", { expand: "author, comments_via_post.user" })

const authorName = res.expand.author.name
const commentedUsers = res.expand.comments_via_post.map(x => x.expand.user)

Preview screenshot:

image

Related issues

#152 Auto-generate record types

Since it's not about type generation, it can be coded into the repo instead of having a code generator to generate the types.

@ganigeorgiev
Copy link
Member

No, sorry. This looks way too complicated for very little to none benefit.

@ganigeorgiev ganigeorgiev closed this as not planned Won't fix, can't repro, duplicate, stale Sep 2, 2024
@K024
Copy link
Author

K024 commented Sep 2, 2024

Result type for string queries is generally complicated, but is really satisfying for devs in a rush.

An example from Supabase js sdk with 600+ sloc:
https://github.com/supabase/postgrest-js/blob/9f91e72c4c02ea55d647e15ed865791cf90eb50e/src/select-query-parser.ts

And this achieves strong types for arbitrary string queries as documented:

const countriesWithCitiesQuery = supabase.from('countries').select(`
  id,
  name,
  cities (
    id,
    name
  )
`)
type CountriesWithCities = QueryData<typeof countriesWithCitiesQuery> // well typed

BTW I don't take this as an urgent feature request. Would be nice-to-have after #152 got integrated into the official sdk. Just put it aside now.

And waiting for good news from the server refactorization 😋

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants