Skip to content

Add qblike_3 example.#42

Merged
dnwpark merged 15 commits intomainfrom
qblike_3
Jan 30, 2026
Merged

Add qblike_3 example.#42
dnwpark merged 15 commits intomainfrom
qblike_3

Conversation

@dnwpark
Copy link
Contributor

@dnwpark dnwpark commented Jan 16, 2026

Add example that demonstrates the typing of a SQL-alchemy like query builder, allowing selects on tables and fields.

Single table queries such as session.execute(select(User)) will return a list of

    class Select[User, tuple[...]]:
        id: int
        name: str
        email: str
        age: int | None
        active: bool
        posts: list[Post]

Multi table queries such as session.execute(select(User.name, Post.content)) will return a list of

    class QueryRow[...]:
        User: Select[User, tuple[...]]]:
        Post: Select[Post, tuple[...]]]:

where

    class Select[User, tuple[...]]:
        name: str

    class Select[Post, tuple[...]]:
        content: str

Things to think about:

  • Field currently needs both the table name and the attr name. This could be improved via some sort of UpdateClass?
  • I am getting the name of a type using:
type TypeName[T] = Literal[T.__name__]
  • GetAttr[Lhs, Prop] just gets the type of a member, not the full Member. This is annoying if what I want is the init or quals.
  • Get all attrs with names:
type EntryFieldMembers[T: Table, FieldNames: tuple[Literal[str], ...]] = tuple[
    *[
        m
        for m in Iter[Attrs[T]]
        if any(IsSub[GetName[m], f] for f in Iter[FieldNames])
    ]
]
  • Contains with custom predicate:
type EntriesHasTable[Es: tuple[QueryEntry, ...], T: Table] = (
    Literal[True]
    if any(IsSub[Literal[True], EntryIsTable[e, T]] for e in Iter[Es])
    else Literal[False]
)
  • Need some kind of tail recursion? Maybe Split[T] or Left/Right
type AddEntries[Entries, News: tuple[Table | Field, ...]] = (
    Entries
    if IsSub[Length[News], Literal[0]]
    else AddEntries[
        AddTable[Entries, GetArg[News, tuple, Literal[0]]],
        tuple[*([n for n in Iter[News]][1:])],
    ]
)

Modelling an SQLite database like:

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT UNIQUE NOT NULL,
    age INTEGER,
    active BOOLEAN DEFAULT TRUE
);

CREATE TABLE posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    content TEXT NOT NULL,
    author_id INTEGER NOT NULL,
    FOREIGN KEY (author_id) REFERENCES users (id)
);

as:

class User(Table[Literal["users"]]):
    id: Field[User, Literal["id"], int] = column(
        db_type=DbInteger(), primary_key=True, autoincrement=True
    )
    name: Field[User, Literal["name"], str] = column(
        db_type=DbString(length=150), nullable=False
    )
    email: Field[User, Literal["email"], str] = column(
        db_type=DbString(length=100), unique=True, nullable=False
    )
    age: Field[User, Literal["age"], int | None] = column(db_type=DbInteger())
    active: Field[User, Literal["active"], bool] = column(
        db_type=DbBoolean(), default=True, nullable=False
    )
    posts: Field[User, Literal["posts"], list[Post]] = column(
        db_type=DbLinkSource(source="Post", cardinality=Cardinality.MANY)
    )


class Post(Table[Literal["posts"]]):
    id: Field[Post, Literal["id"], int] = column(
        db_type=DbInteger(), primary_key=True, autoincrement=True
    )
    content: Field[Post, Literal["content"], str] = column(
        db_type=DbString(length=1000), nullable=False
    )
    author: Field[Post, Literal["author"], User] = column(
        db_type=DbLinkTarget(target=User), nullable=False
    )
    comments: Field[Post, Literal["comments"], list[Comment]] = column(
        db_type=DbLinkSource(source="Comment", cardinality=Cardinality.MANY)
    )

@dnwpark dnwpark requested a review from msullivan January 16, 2026 21:36
Comment on lines +69 to +70
if isinstance(ty, dict):
return ty
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed Table to just take a string literal instead of a typed dict.

It turns out that's ends up being really weird to support.



type ReplaceNever[T, D] = T if not Sub[T, Never] else D
type GetFieldItem[T: InitField, K, Default] = ReplaceNever[
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice thought with the Default

Copy link
Collaborator

@msullivan msullivan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an impressive scaling up of the fastapilike approach (good to see it works!), but not quite what I was looking for.

The thing I was interested in seeing modeled better was some of the query builder functions, like https://docs.sqlalchemy.org/en/20/orm/queryguide/select.html#selecting-multiple-orm-entities-simultaneously and https://docs.sqlalchemy.org/en/20/orm/queryguide/select.html#selecting-individual-attributes

@dnwpark dnwpark force-pushed the qblike_3 branch 3 times, most recently from 1221bd6 to 8dc8e62 Compare January 19, 2026 23:19
@dnwpark
Copy link
Contributor Author

dnwpark commented Jan 20, 2026

Possible syntax for fields

Option 1 - Put more parameters in the type annotation

class FieldAttrArgs(TypedDict, total=False):
    db_type: ReadOnly[DbType]

class Field[Args: FieldAttrArgs](InitField[Args]):
    pass

class FieldInitArgs(TypedDict, total=False):
    nullable: ReadOnly[bool]

class field[Args: FieldInitArgs](InitField[Args]):
    pass

class User:
    # either a:
    name: Field(db_type=String(50)) = field(nullable=False)
    # or b:
    name: Field[FieldAttrArgs(db_type=String(50))] = field(nullable=False)

1a doesn't play nice with type checker.
1b needs to support dict in type args, gets kind of gross. Also very wordy.

Option 2 - function which takes InitiField as param

class Field[T]:
    pass

class FieldArgs(TypedDict, total=False):
    db_type: ReadOnly[DbType]
    nullable: ReadOnly[bool]

def field[Args: FieldArgs](**kwargs: InitField[Args]) -> Field[GetAttr[Args, Literal["db_type"]]]:
    ...

class User:
    name = field(db_type=String(50))

we currently skip any unannotated fields?

@vercel
Copy link

vercel bot commented Jan 26, 2026

@dnwpark must be a member of the Vercel Labs team on Vercel to deploy.
- Click here to add @dnwpark to the team.
- If you initiated this build, request access.

Learn more about collaboration on Vercel and other options here.

@vercel
Copy link

vercel bot commented Jan 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
python-typemap Ready Ready Preview, Comment Jan 30, 2026 10:27pm

@msullivan
Copy link
Collaborator

Things to think about:

* `Field` currently needs both the table name and the attr name. This could be improved via some sort of `UpdateClass`?

Yeah, we'll probably want that.

Another option would be to have (probably in addition) a PropName (or something) type that when used in an attribute annotation, resolves to the field's name. This would kind of model __set_name__, maybe.

* I am getting the name of a type using:
type TypeName[T] = Literal[T.__name__]

If we want to keep that, we'll probably need to add it as a built-in. We can't access .__name__.

If we didn't need to support runtime evaluation, and thus weren't restricting Members to things with explicit annotations, then we could have defined it as GetAttr[T, Literal['__name__']], and made __name__ specially inferred to have literal types.

* `GetAttr[Lhs, Prop]` just gets the type of a member, not the full `Member`. This is annoying if what I want is the init or quals.

We could probably add this?
Maybe we don't even need GetAttr, and only really want Members and GetMember?

* Get all attrs with names:
type EntryFieldMembers[T: Table, FieldNames: tuple[Literal[str], ...]] = tuple[
    *[
        m
        for m in Iter[Attrs[T]]
        if any(IsSub[GetName[m], f] for f in Iter[FieldNames])
    ]
]

Another option for implementing this, instead of using any, would be to represent FieldNames as a union instead, possibly? Either on the input side or in the check.

I think something like IsSub[GetName[m], Union[*[f for f in Iter[FieldNames]]].

(Or maybe even just IsSub[GetName[m], Union[*Iter[FieldNames]])

* Contains with custom predicate:
type EntriesHasTable[Es: tuple[QueryEntry, ...], T: Table] = (
    Literal[True]
    if any(IsSub[Literal[True], EntryIsTable[e, T]] for e in Iter[Es])
    else Literal[False]
)

If we do include any, which I have drafted in the PEP but haven't been sure of, I think that I want to do it as a full type operator: an Any[*Ts] that takes variadic arguments and checks them, and so would be written like Any[*[IsSub[Literal[True], EntryIsTable[e, T]] for e in Iter[Es]]].

The big thing here is that I want "type bools" to all be valid types also, and so I want all of the boolean type operators to return typing._BooleanLiteralGenericAlias, which will be a new subtype of typing._LiteralGenericAlias that we add that overrides __bool__ and __not__.

We can make this work with and, or, and not (not gets overloaded while and an or always return one of their arguments) but couldn't with any or all.

* Need some kind of tail recursion? Maybe `Split[T]` or `Left`/`Right`
type AddEntries[Entries, News: tuple[Table | Field, ...]] = (
    Entries
    if IsSub[Length[News], Literal[0]]
    else AddEntries[
        AddTable[Entries, GetArg[News, tuple, Literal[0]]],
        tuple[*([n for n in Iter[News]][1:])],
    ]
)

The PEP calls for Slice to work on either str or tuple, though I think it's only implemented for str. But that would be the obvious way.

Another approach could be to try to represent things with a union instead of a tuple?

Comment on lines +266 to +270
*( # Add entries if not present
[]
if IsSub[Literal[True], EntriesHasTable[Entries, New]]
else [MakeQueryEntryAllFields[New]]
),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This syntax won't be supported in the type language.

It should be possible to make this work with an starred comprehension instead.

(Or also possibly with * and then a tuple type, but I don't think we have that implemented yet)

@dnwpark dnwpark merged commit a44650f into main Jan 30, 2026
5 checks passed
@dnwpark dnwpark deleted the qblike_3 branch January 30, 2026 22:36
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

Successfully merging this pull request may close these issues.

2 participants