jhnnsrs / rath

rath is an apollo-like graphql client that supports links, multipart uploads, websocket subscriptions with sync and async interface
https://jhnnsrs.github.io/rath/
MIT License
9 stars 3 forks source link

Better Qt support. #19

Closed nrbnlulu closed 1 year ago

nrbnlulu commented 1 year ago

Intro

Qt-QML IMO is a big game changer

One of the big disadvantages in Qt-QML is that Qt-C++ API is very repititive and hard to maintain for data-driven applications.

although it is tempting to just use relay or other JS graphql lib there is a point where you would suffer from preformance issues (:roll_eyes: react-native).

Solutions I had so far

To overcome Qt's "great" API I wrote qtgql Initially it was just ment for API hacks i.e

# instead of
@Slot(arguments=(str, int, str), result=str)
def some_slot(self, a, b, c):
    return "foo"

# gets return type from annotations
from qtgql import slot

@slot
def some_slot(self, a: str, b: int, c: list[str]) -> str:
    return "foo"

Also I have made generic models so that you won't have to define roles data update manage signals emition and so much boilerplate code yourself.

you would just declare your model like this

schema = get_base_type()
class Worm(schema):
    name: str = role()
    family: str = role()
    size: int = role()

class Apple(schema):
    size: int = role()
    owner: str = role()
    color: str = role()
    worms: GenericModel[Worm] = role(default=None)

schema = Schema(query=Apple)

apple_model: GenericModel[Apple] = Apple.Model(schema=schema)
apple_model.initialize_data(data)  # dict with the correct fields

GraphQL support

As I have proggresed with the codebase I realized that I can do better and possiblly mimic some features of graphql relay at the cost of making this project more opinionated. So I decided to rename it to qtgql (previouslly cuter :cat: ). Some of the current features:

Future vision

Collaboration

@jhnnsrs What is you opinion about possibly merging the two projects or creating a new organization maybe...

jhnnsrs commented 1 year ago

Hey,

and sorry for the late reply. As I am a complete newbie to the QML ecosystem in qt, please excuse some questions about the general purpose of what you are aiming for with qtql and how you see rath /turms fit into this.

Do you want to have a sort of databinding between a graphql query with a qml view? And that there is a sort of generalized cache (similar to apollos cache), that would act as the model? What would you mean with auto mutations? Also I am not entirely sure why you would like a Qobject for every graphql type, as then you wouldn't get the benefits of typesafe queries?

I like the idea of having a Qt native websockets implementation, but as of now all transport is asynchronous (and i feel like i would like to keep it that way, to easily enable patterns like retry and buffering) so it would require putting the websocket layer behind a threadpool, which i feel would drastically decrease performance.

nrbnlulu commented 1 year ago

I am still expirimenting with whats possible and what is not but the main idea is, yes, have a global object pool for each type. models are QAbstractListModel that have only one data type, meaning there is only one model for each graphql type.

as then you wouldn't get the benefits of typesafe queries?

In order to expose data to QML you have to either generate roles for each field you have on the model or use properties for each field. originally I went for implmenting dynamic roles thats what @define_roles does BTS... though I had few limitations

  1. setting and getting attributes on a items of a model is a pain to maintain.
  2. if one object was in a model and I now need to use it as is in QML (without model) it is impossible without doing some bad hacks.

This is why I went for generating properties instead of roles even though it might (reduce preformance though I am not sure because pypy might compile it better) It is much more flexible.

Note that this is only type generation no querying and mutations etc are generated yet

as then you wouldn't get the benefits of typesafe queries?

As you can see from the example it is type-safe ~ though there is much more work TBD...

What would you mean with auto mutations?

generated mutation functions ig.

Example

For this schema ```py import asyncio import random from typing import AsyncGenerator, Optional import strawberry from aiohttp import web from strawberry.aiohttp.views import GraphQLView from strawberry.types import Info from tests.conftest import fake @strawberry.type class Worm: name: str = strawberry.field(default_factory=fake.name) family: str = strawberry.field( default_factory=lambda: random.choice( ["Platyhelminthes", "Annelida", "Nemertea", "Nematoda", "Acanthocephala"] ) ) size: int = strawberry.field(default_factory=lambda: random.randint(10, 100)) @strawberry.type class Apple: size: int = strawberry.field(default_factory=lambda: random.randint(10, 100)) owner: str = strawberry.field(default_factory=fake.name) worms: Optional[list[Worm]] = strawberry.field() color: str = strawberry.field(default_factory=fake.color) @strawberry.type class Query: @strawberry.field def hello(self) -> str: return "world" @strawberry.field def is_authenticated(self, info: Info) -> str: return info.context["request"].headers["Authorization"] @strawberry.field def apples(self) -> list[Apple]: return [Apple(worms=[Worm() for _ in range(5)] if fake.pybool() else []) for _ in range(30)] @strawberry.type class Mutation: @strawberry.mutation def pseudo_mutation(self) -> bool: return True @strawberry.type class Subscription: @strawberry.subscription async def count(self, target: int = 10, raise_on_5: bool = False) -> AsyncGenerator[int, None]: for i in range(target): if raise_on_5 and i == 5: raise Exception("Test Gql Error") yield i await asyncio.sleep(0.001) schema = strawberry.Schema(query=Query, subscription=Subscription, mutation=Mutation) ```
This is what I generate currently ```py from __future__ import annotations from PySide6.QtCore import Property, QObject, Signal from qtgql.codegen.py.bases import BaseModel, get_base_graphql_object BaseObject = get_base_graphql_object() class Query(BaseObject): """None.""" def __init__( self, parent: QObject = None, hello: str = None, isAuthenticated: str = None, apples: list[Apple] = None, ): super().__init__(parent) self._hello = hello self._isAuthenticated = isAuthenticated self._apples = apples @classmethod def from_dict(cls, parent, data: dict) -> Query: return cls( parent=parent, hello=data.get("hello", None), isAuthenticated=data.get("isAuthenticated", None), apples=cls.deserialize_list_of(parent, data, AppleModel, "apples", Apple), ) helloChanged = Signal() def hello_setter(self, v: str) -> None: self._hello = v self.helloChanged.emit() @Property(type=str, fset=hello_setter, notify=helloChanged) def hello(self) -> str: return self._hello isAuthenticatedChanged = Signal() def isAuthenticated_setter(self, v: str) -> None: self._isAuthenticated = v self.isAuthenticatedChanged.emit() @Property(type=str, fset=isAuthenticated_setter, notify=isAuthenticatedChanged) def isAuthenticated(self) -> str: return self._isAuthenticated applesChanged = Signal() def apples_setter(self, v: list[Apple]) -> None: self._apples = v self.applesChanged.emit() @Property(type=QObject, fset=apples_setter, notify=applesChanged) def apples(self) -> list[Apple]: return self._apples class QueryModel(BaseModel): def __init__(self, data: list[Query], parent: BaseObject | None = None): super().__init__(data, parent) class Apple(BaseObject): """None.""" def __init__( self, parent: QObject = None, size: int = None, owner: str = None, worms: list[Worm] | None = None, color: str = None, ): super().__init__(parent) self._size = size self._owner = owner self._worms = worms self._color = color @classmethod def from_dict(cls, parent, data: dict) -> Apple: return cls( parent=parent, size=data.get("size", None), owner=data.get("owner", None), worms=cls.deserialize_list_of(parent, data, WormModel, "worms", Worm), color=data.get("color", None), ) sizeChanged = Signal() def size_setter(self, v: int) -> None: self._size = v self.sizeChanged.emit() @Property(type=int, fset=size_setter, notify=sizeChanged) def size(self) -> int: return self._size ownerChanged = Signal() def owner_setter(self, v: str) -> None: self._owner = v self.ownerChanged.emit() @Property(type=str, fset=owner_setter, notify=ownerChanged) def owner(self) -> str: return self._owner wormsChanged = Signal() def worms_setter(self, v: list[Worm] | None) -> None: self._worms = v self.wormsChanged.emit() @Property(type=QObject, fset=worms_setter, notify=wormsChanged) def worms(self) -> list[Worm] | None: return self._worms colorChanged = Signal() def color_setter(self, v: str) -> None: self._color = v self.colorChanged.emit() @Property(type=str, fset=color_setter, notify=colorChanged) def color(self) -> str: return self._color class AppleModel(BaseModel): def __init__(self, data: list[Apple], parent: BaseObject | None = None): super().__init__(data, parent) class Worm(BaseObject): """None.""" def __init__( self, parent: QObject = None, name: str = None, family: str = None, size: int = None, ): super().__init__(parent) self._name = name self._family = family self._size = size @classmethod def from_dict(cls, parent, data: dict) -> Worm: return cls( parent=parent, name=data.get("name", None), family=data.get("family", None), size=data.get("size", None), ) nameChanged = Signal() def name_setter(self, v: str) -> None: self._name = v self.nameChanged.emit() @Property(type=str, fset=name_setter, notify=nameChanged) def name(self) -> str: return self._name familyChanged = Signal() def family_setter(self, v: str) -> None: self._family = v self.familyChanged.emit() @Property(type=str, fset=family_setter, notify=familyChanged) def family(self) -> str: return self._family sizeChanged = Signal() def size_setter(self, v: int) -> None: self._size = v self.sizeChanged.emit() @Property(type=int, fset=size_setter, notify=sizeChanged) def size(self) -> int: return self._size class WormModel(BaseModel): def __init__(self, data: list[Worm], parent: BaseObject | None = None): super().__init__(data, parent) class Mutation(BaseObject): """None.""" def __init__( self, parent: QObject = None, pseudoMutation: bool = None, ): super().__init__(parent) self._pseudoMutation = pseudoMutation @classmethod def from_dict(cls, parent, data: dict) -> Mutation: return cls( parent=parent, pseudoMutation=data.get("pseudoMutation", None), ) pseudoMutationChanged = Signal() def pseudoMutation_setter(self, v: bool) -> None: self._pseudoMutation = v self.pseudoMutationChanged.emit() @Property(type=bool, fset=pseudoMutation_setter, notify=pseudoMutationChanged) def pseudoMutation(self) -> bool: return self._pseudoMutation class MutationModel(BaseModel): def __init__(self, data: list[Mutation], parent: BaseObject | None = None): super().__init__(data, parent) class Subscription(BaseObject): """None.""" def __init__( self, parent: QObject = None, count: int = None, ): super().__init__(parent) self._count = count @classmethod def from_dict(cls, parent, data: dict) -> Subscription: return cls( parent=parent, count=data.get("count", None), ) countChanged = Signal() def count_setter(self, v: int) -> None: self._count = v self.countChanged.emit() @Property(type=int, fset=count_setter, notify=countChanged) def count(self) -> int: return self._count class SubscriptionModel(BaseModel): def __init__(self, data: list[Subscription], parent: BaseObject | None = None): super().__init__(data, parent) ```
nrbnlulu commented 1 year ago

As you can see, no pydantic or dataclasses involved, and desrialization logic is builtin.