Skip to main content
Ctrl+K

Cannula Documentation

  • Tutorial
  • User Guide
  • CLI
  • GraphQL-Codegen
  • GitHub
  • PyPI
  • Tutorial
  • User Guide
  • CLI
  • GraphQL-Codegen
  • GitHub
  • PyPI

Section Navigation

  • Tutorial Setup
  • Part1: Schema
  • Part2: Resolvers
  • Part3: Code Generation
  • Part4: Related Field Resolution
  • Part5: Datasources
  • Tutorial
  • Part4: Related Field Resolution

Part4: Related Field Resolution#

In many web applications not everything fits in the same data types or structures. We usually have a relation from one thing to another and that relation can be either hard or soft. For instance in a relational database you might have a foreign key from a parent to their child. That would be a hard relation and it is easy to reason about and code up. However what if your manager asked you to find all the cousins of a given person, well that is not so simple, the data is there but your gonna have to do a bunch of work to get the answer.

Cannula has built into the codegen logic the concept of Related fields. These are fields you want to expose on the model but you don’t have an easy attribute to lookup. While it doesn’t make the work of finding cousins easier, it does make it possible to do in an easy to follow structure. We already saw this in the generated Python, all the Query and Mutations are examples of this. Any field that has arguments will be rendered as a function on the base type. In the case of SQLAlchemy types if any field’s return type is a SQLAlchemy model Cannula will automatically add a resolver for it.

In some cases where there is no clear foreign key relation we must provide a where clause to preform the query. This uses a text query to allow a great deal of flexibility to write queries. Then Cannula will use bindparams to map all the graphql arguments plus any defined by the directive. For example the graphql query arguments might include a secondary rule but the primary lookup would be the id of the parent.

type User {
    id: ID!
    # imaging you have a lookup that finds the users best bestFriend
    # by some sort of ranking system
    bestFriends(ranking: Int): Friend
        @field_meta(
            # Query the 'friends' table sorted by rank
            where: "user_id = :id AND ranking >= :ranking ORDER BY ranking ASC",
            # "id" here is the id of the user aka `self.id` since this is a field
            # on the user object it does not make since to require it in the graphql
            # query but we have to tell the system which field to use to relate them.
            args: ["id"],
        )
}

Starting with the schema from part3, we have added a new type ‘Quota’ that is has a foreign_key to the ‘User’ type for a simple example of how the relations work:

scalar UUID

type User @db_sql(table_name: "users_part4") {
  id: UUID! @field_meta(primary_key: true)
  name: String!
  email: String! @field_meta(index: true, unique: true)
  quota: [Quota!] @field_meta(where: "user_id = :id", args: ["id"])
  overQuota(resource: String!): Quota
    @field_meta(where: "user_id = :id AND resource = :resource", args: ["id"])
}

type Quota @db_sql(table_name: "quotas_part4") {
  id: UUID! @field_meta(primary_key: true)
  user_id: UUID! @field_meta(foreign_key: "users_part4.id")
  user: User
  resource: String!
  limit: Int!
  count: Int!
}

type Query {
  people: [User]
  person(id: UUID!): User @field_meta(where: "id = :id")
}

Now when we run cannula codegen we get these types and sql:

from __future__ import annotations
from abc import ABC
from cannula import ResolveInfo
from dataclasses import dataclass
from typing import Optional, Protocol, Sequence, TYPE_CHECKING
from typing_extensions import TypedDict
from uuid import UUID

if TYPE_CHECKING:
    from .context import Context


@dataclass(kw_only=True)
class Quota(ABC):
    __typename = "Quota"
    id: UUID
    user_id: UUID
    resource: str
    limit: int
    count: int

    async def user(self, info: ResolveInfo["Context"]) -> Optional[User]:
        return await info.context.users.get_model_by_pk(self.user_id)


@dataclass(kw_only=True)
class User(ABC):
    __typename = "User"
    id: UUID
    name: str
    email: str

    async def quota(self, info: ResolveInfo["Context"]) -> Optional[Sequence[Quota]]:
        return await info.context.quotas.user_quota(id=self.id)

    async def overQuota(
        self, info: ResolveInfo["Context"], resource: str
    ) -> Optional[Quota]:
        return await info.context.quotas.user_overQuota(id=self.id, resource=resource)


class peopleQuery(Protocol):

    async def __call__(
        self, info: ResolveInfo["Context"]
    ) -> Optional[Sequence[User]]: ...


class personQuery(Protocol):

    async def __call__(
        self, info: ResolveInfo["Context"], id: UUID
    ) -> Optional[User]: ...


class RootType(TypedDict, total=False):
    people: Optional[peopleQuery]
    person: Optional[personQuery]
from __future__ import annotations
from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from uuid import UUID


class Base(DeclarativeBase):
    pass


class DBQuota(Base):
    __tablename__ = "quotas_part4"
    id: Mapped[UUID] = mapped_column(primary_key=True)
    user_id: Mapped[UUID] = mapped_column(
        foreign_key=ForeignKey("users_part4.id"), nullable=False
    )
    resource: Mapped[str] = mapped_column(nullable=False)
    limit: Mapped[int] = mapped_column(nullable=False)
    count: Mapped[int] = mapped_column(nullable=False)


class DBUser(Base):
    __tablename__ = "users_part4"
    id: Mapped[UUID] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(nullable=False)
    email: Mapped[str] = mapped_column(index=True, unique=True, nullable=False)

You can see that we have foreign_key relations in the db and our types now have resolvers that call the context which is where all the magic happens. let’s look at the context that was generated to see our custom queries in action:

from __future__ import annotations
from cannula.context import Context as BaseContext
from cannula.datasource.orm import DatabaseRepository
from sqlalchemy import text, true
from sqlalchemy.ext.asyncio import async_sessionmaker
from typing import Optional, Sequence
from uuid import UUID
from .sql import DBQuota, DBUser
from .types import Quota, User


class QuotaDatasource(
    DatabaseRepository[DBQuota, Quota], graph_model=Quota, db_model=DBQuota
):

    async def user_quota(self, id: UUID) -> Optional[Sequence[Quota]]:
        return await self.get_models(text("user_id = :id").bindparams(id=id))

    async def user_overQuota(self, id: UUID, resource: str) -> Optional[Quota]:
        return await self.get_model(
            text("user_id = :id AND resource = :resource").bindparams(
                id=id, resource=resource
            )
        )


class UserDatasource(
    DatabaseRepository[DBUser, User], graph_model=User, db_model=DBUser
):

    async def query_people(self) -> Optional[Sequence[User]]:
        return await self.get_models(true())

    async def query_person(self, id: UUID) -> Optional[User]:
        return await self.get_model(text("id = :id").bindparams(id=id))


class Context(BaseContext):
    quotas: QuotaDatasource
    users: UserDatasource

    def __init__(self, session_maker: async_sessionmaker):
        self.quotas = QuotaDatasource(session_maker)
        self.users = UserDatasource(session_maker)

Now we just need to add in the new query we added to the RootType:

import pathlib
import uuid
from typing import Sequence, Optional

import cannula
from cannula.scalars.util import UUID

from ..core.config import config
from .gql.types import User, RootType
from .gql.context import Context


async def resolve_people(
    info: cannula.ResolveInfo[Context],
) -> Optional[Sequence[User]]:
    return await info.context.users.query_people()


async def resolve_person(
    info: cannula.ResolveInfo[Context],
    id: uuid.UUID,
) -> Optional[User]:
    return await info.context.users.query_person(id=id)


root_value: RootType = {
    "people": resolve_people,
    "person": resolve_person,
}

cannula_app = cannula.CannulaAPI[RootType](
    schema=pathlib.Path(config.root / "part4"),
    scalars=[UUID],
    root_value=root_value,
)

You can now update the route to http://localhost:8000/part4/graph and try the following query:

query People {
    people {
        name
        email
        quota {
            resource
            limit
        }
        overQuota(resource: "water") {
            count
        }
    }
}

And get results like this:

{
    "data": {
        "people": [
        {
            "name": "Normal User",
            "email": "user@email.com",
            "quota": [
            {
                "resource": "fire",
                "limit": 10
            },
            {
                "resource": "water",
                "limit": 15
            }
            ],
            "overQuota": {
            "count": 4
            }
        },
        {
            "name": "Admin User",
            "email": "admin@example.com",
            "quota": [
            {
                "resource": "fire",
                "limit": 5
            }
            ],
            "overQuota": null
        }
        ]
    },
    "errors": null,
    "extensions": null
}

previous

Part3: Code Generation

next

Part5: Datasources

Edit on GitHub

This Page

  • Show Source

© Copyright 2024, Robert Myers.

Created using Sphinx 8.1.3.

Built with the PyData Sphinx Theme 0.16.1.