piccolo-orm/piccolo

[Litestar] nested not working with create_pydantic_model response

ricksore opened this issue · 8 comments

I have this table:

class Profile(Table):
    class Role(str, Enum):
        STAFF = 'staff'
        SUPERVISOR = 'supervisor'
        ADMIN = 'admin'

    user = ForeignKey(references=BaseUser)
    user = ForeignKey(references=Company)

and created the Pydantic model

ProfileModel = create_pydantic_model(
    Profile,
    nested=(Profile.user)
)

and this endpoint

    @post('register', status_code=201)
    async def register(self, data: RegisterData) -> ProfileModel:
        ...
        profile = Profile(user=user, company=company, activated=datetime.now())
        await profile.save()
        
        return profile.to_dict()

the problem is the response also sends the nested Company instead of just the User field.

Okay I think the problem is with the to_dict() method; apparently it also converts FKs into dictionaries.

@ricksore I'm sorry, but I can't reproduce your problem. If I do this, everything is fine,

class Company(Table):
    name = Varchar()

class Profile(Table):
    class Role(str, Enum):
        STAFF = "staff"
        SUPERVISOR = "supervisor"
        ADMIN = "admin"

    user = ForeignKey(references=BaseUser)
    company = ForeignKey(references=Company)
    role = Varchar(choices=Role)
    activated = Timestamp()


RegisterData = create_pydantic_model(
    Profile,
    model_name="RegisterData",
)
ProfileModel = create_pydantic_model(
    Profile,
    include_default_columns=True,
    model_name="ProfileModel",
    nested=(Profile.user),
)

@post("/profiles", status_code=201, tags=["Profile"])
async def create_profile(data: RegisterData) -> ProfileModel:
    profile = Profile(**data.dict())
    await profile.save()
    return profile.to_dict()

and response is

{
  "id": 1,
  "user": 1,
  "company": 1,
  "role": "staff",
  "activated": "2023-07-10T07:53:00"
}

Can you please post what the json response should look like in your case?

One thing to be careful with is this:

ProfileModel = create_pydantic_model(
    Profile,
    nested=(Profile.user)
)

Make sure there's a trailing comma, otherwise it's not treated as a tuple, so should be:

ProfileModel = create_pydantic_model(
    Profile,
    nested=(Profile.user,)  # <- Note here
)

And with Litestar, I'm not sure if it automatically converts the response to the Pydantic model. You can try doing it explicitly instead:

    @post('register', status_code=201)
    async def register(self, data: RegisterData) -> ProfileModel:
        ...
        profile = Profile(user=user, company=company, activated=datetime.now())
        await profile.save()
        
        return ProfileModel(**profile.to_dict())  # <- Note here

Make sure there's a trailing comma, otherwise it's not treated as a tuple, so should be:

ProfileModel = create_pydantic_model(
    Profile,
    nested=(Profile.user,)  # <- Note here
)

Yes. You're right, I forgot to put a comma when I pasted the code.

And with Litestar, I'm not sure if it automatically converts the response to the Pydantic model. You can try doing it explicitly instead:

    @post('register', status_code=201)
    async def register(self, data: RegisterData) -> ProfileModel:
        ...
        profile = Profile(user=user, company=company, activated=datetime.now())
        await profile.save()
        
        return ProfileModel(**profile.to_dict())  # <- Note here

Neither FastAPI nor Litestar work that way. Sorry if I don't understand the problem, but if we want nested response output (after POST data and save to database) the only way I managed without getting a ValidationError

pydantic.error_wrappers.ValidationError: 1 validation error for ProfileModel
user
  value is not a valid dict (type=type_error.dict)

is to make another query with nested output like this:

@post("/profiles", status_code=201, tags=["Profile"])
async def create_profile(data: RegisterData) -> ProfileModel:
    profile = Profile(**data.dict())
    await profile.save()
    saved_profile = profile.to_dict()
    profile = (
        await Profile.select(
            Profile.all_columns(),
            Profile.user.all_columns(),
            Profile.company,
        )
        .where(Profile.id == saved_profile["id"])
        .first()
        .output(nested=True)
    )
    return ProfileModel(**profile)

nested_output

I apologize again if I missed the point.

Neither FastAPI nor Litestar work that way

I think FastAPI supports this in recent versions. You had to do this in the past:

@app.get('/', response_model=MyModel)
def my_endpoint():
    ...

But now I think this works:

@app.get('/')
def my_endpoint() -> MyModel:
    ...

is to make another query with nested output like this:

You're right - that works. You can do something similar with objects:

# Gets the nested user object:
profile = await Profile.objects(Profile.user).where(Profile.id == some_id).first()
return ProfileModel(**profile.to_dict())

Or lets say you already have the profile, without the nested user object you can get it like this:

profile.user = await profile.get_related(Profile.user)
return ProfileModel(**profile.to_dict())

I think FastAPI supports this in recent versions. You had to do this in the past:

@app.get('/', response_model=MyModel)
def my_endpoint():
    ...

But now I think this works:

@app.get('/')
def my_endpoint() -> MyModel:
    ...

Unfortunately neither method works without additional nested queries with fastapi==0.99.1 which is the last version before Pydantic V2.

You're right - that works. You can do something similar with objects:

# Gets the nested user object:
profile = await Profile.objects(Profile.user).where(Profile.id == some_id).first()
return ProfileModel(**profile.to_dict())

Or lets say you already have the profile, without the nested user object you can get it like this:

profile.user = await profile.get_related(Profile.user)
return ProfileModel(**profile.to_dict())

Both of these methods work perfectly in FastAPI or Litestar and they are much cleaner than the query builder select method (I almost always use select method (query builder), but here it is much better to use objects method (ORM)).
@ricksore Is this what you need for an response?

You got it @sinisaos , I wanted to send the User field as nested but not the Company. It's not being casted properly to ProfileModel because of to_dict(). I wish we could select which FK we can send as nested or as primary_key in the to_dict() method.

@ricksore to_dict() is just a helper method for converting an object to a dictionary. The best way to achieve what you want is, as @dantownsend advised, to let Piccolo find the associated object and pass it to a result like this.

profile.user = await profile.get_related(Profile.user) 
return ProfileModel(**profile.to_dict())

With that one line of code, you get a nicely nested response.