RobertCraigie / prisma-client-py

Prisma Client Python is an auto-generated and fully type-safe database client designed for ease of use
https://prisma-client-py.readthedocs.io
Apache License 2.0
1.82k stars 77 forks source link

Improve experience working with Json fields #63

Open RobertCraigie opened 3 years ago

RobertCraigie commented 3 years ago

Problem

Currently the following code will generate a slew of type errors.

user = await client.user.find_unique(where={'id': 'abc'})
print(user.extra['pets'][0]['name'])

While I do think that naively working with JSON objects should raise type errors, it should be easy to cast the data to the expected types.

For example, the above code written in a type safe manner would look like this:

class Extra(TypedDict, total=False):
  pets: List['Pet']

class Pet(TypedDict):
  name: str

user = await client.user.find_unique(where={'id': 'abc'})
extra = cast(Extra, user.extra)
pets = extra.get('pets')
if pets:
  print(pets[0]['name'])

However there are a few problems with this:

Suggested solution

Once #59 is implemented, this would be easier, for example, this should work:

class User(prisma.models.User):
  extra: Extra

user = await User.prisma().find_unique(where={'id': 'abc'})
pets = user.extra.get('pets')
if pets:
  print(pets[0]['name'])

And for client based access, #24 would make this easier, for example:

user = await client.user.find_unique(where={'id': 'abc'})
extra = prisma.validate(Extra, user.extra)
pets = extra.get('pets')
if pets:
  print(pets[0]['name'])

The purpose of this issue is for tracking the aforementioned issues and documentation.

caelx commented 1 year ago

This would be super helpful, it's also difficult to implement the Json field into my workflow.

Here's an example of what I'm trying to do:

# prisma/prisma.schema

model User {
  id      String   @id @default(cuid())
  name    String
  scores Json
}
# prisma/partial_types.py

from prisma.models import User

User.create_partial("UserWriteBase", exclude={"id"}, exclude_relational_fields=True)

I'm using Pydantic to validate the field when I load it in via FastAPI.

# model.py

from pydantic import BaseModel
from prisma.partials import UserWriteBase

class ScoresModel(BaseModel):
    score1: int = 0
    score2: int = 0

class UserWrite(UserWriteBase):
    scores: Optional[ScoresModel]
# api.py

@router.post("/user", response_model=UserRead)
async def create_user(user: UserWrite, db: Prisma = Depends(get_db)):
    """Create a user."""
    data = user.dict(exclude_unset=True)
    if "scores" in data:
        data["scores"] = Json(data["scores"])
    return await db.target.create(data=data)  # type:ignore

I'm not sure if this is the best way to do this but it's a bit of a pain to have to manually cast the field to a Json object.

Also, I have to use # type:ignore because I get this error when loading the data, not sure if there's a better way to get rid of this.

Argument of type "DictStrAny" cannot be assigned to parameter "data" of type "UserUpdateInput" in function "update"
  "DictStrAny" is incompatible with "UserUpdateInput"Pylance[reportGeneralTypeIssues](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#reportGeneralTypeIssues)

Lastly, it would be super helpful if create_partial allowed me to override a field configuration directly. I've looked though and Field doesn't support the ability to pass type or annotation so it would have to be hacked in and isn't that big of a deal. This is really only useful for Json fields so I figured I'd put it here.

# prisma/partial_types.py

from prisma.models import User
from pydantic import BaseModel, Field

class ScoresModel(BaseModel):
    score1: Optional[int]
    score2: Optional[int]

User.create_partial("UserWriteBase", exclude={"id"}, exclude_relational_fields=True, override={"scores": Field(type=Optional[ScoresModel])})
RobertCraigie commented 1 year ago

I'm not sure if this is the best way to do this but it's a bit of a pain to have to manually cast the field to a Json object.

Yeah this is a pretty reasonable approach.

The reason you have to make the field a Json object is essentially a limitation of the internal Prisma query builder implementation. It needs to know that the field is a json type so that it serializes it to a JSON string. See #99 for fixing this.

Also, I have to use # type:ignore because I get this error when loading the data, not sure if there's a better way to get rid of this.

This is a limitation of the Python typing system unfortunately. Even something like this will generate a type error:

data = {
  'name': 'Robert',
  'scores': Json(...)
}
await db.target.create(data=data)

This is because the type of data is inferred to dict[str, str | Json] which is incompatible with the TypedDict type that we generate.

To properly fix this you have to provide an explicit type hint, e.g.

data: prisma.types.TargetCreateInput = {
  'name': 'Robert',
  'scores': Json(...)
}
await db.target.create(data=data)

This won't work with your code though as you're constructing the initial dictionary dynamically.

You could rewrite your code like this to be type safe:

async def create_user(user: UserWrite, db: Prisma = Depends(get_db)):
    """Create a user."""
    data = user.dict(exclude_unset=True)
    if "scores" in data:
        data["scores"] = Json(data["scores"])
    return await db.target.create(
        data=prisma.validate(
            prisma.types.TargetCreateInput,
            data,
        )
    )

Also I'd recommend not using # type: ignore unless you have to as it can mask other errors. e.g. say you rename the model in the Prisma schema or rename the db variable, Pyright won't report any errors. Instead you should use cast(Any, ...) as that means all other errors will still be caught!