From cc5251291d5e0c1a07b13783d19ae3667d14c115 Mon Sep 17 00:00:00 2001 From: Suren Khorenyan Date: Sat, 22 Mar 2025 19:09:46 +0300 Subject: [PATCH] Fix read model id field + other fixes + tests --- .../api_for_sqlalchemy/models/__init__.py | 4 + .../api_for_sqlalchemy/models/age_rating.py | 30 ++++++ examples/api_for_sqlalchemy/models/base.py | 6 +- examples/api_for_sqlalchemy/models/movie.py | 54 ++++++++++ .../api_for_sqlalchemy/schemas/__init__.py | 24 +++++ .../api_for_sqlalchemy/schemas/age_rating.py | 49 +++++++++ examples/api_for_sqlalchemy/schemas/movie.py | 60 +++++++++++ examples/api_for_sqlalchemy/urls.py | 29 +++++ fastapi_jsonapi/api/schemas.py | 8 +- fastapi_jsonapi/data_layers/base.py | 2 + .../data_layers/sqla/base_model.py | 3 +- fastapi_jsonapi/data_layers/sqla/orm.py | 1 + fastapi_jsonapi/views/view_base.py | 7 +- tests/conftest.py | 1 + tests/fixtures/app.py | 29 +++++ tests/fixtures/entities.py | 31 ++++++ tests/test_api/test_model_wo_id.py | 101 ++++++++++++++++++ tests/test_atomic/test_create_objects.py | 100 ++++++++++++++++- 18 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 examples/api_for_sqlalchemy/models/age_rating.py create mode 100644 examples/api_for_sqlalchemy/models/movie.py create mode 100644 examples/api_for_sqlalchemy/schemas/age_rating.py create mode 100644 examples/api_for_sqlalchemy/schemas/movie.py create mode 100644 tests/test_api/test_model_wo_id.py diff --git a/examples/api_for_sqlalchemy/models/__init__.py b/examples/api_for_sqlalchemy/models/__init__.py index 5891ad73..ae8f63a8 100644 --- a/examples/api_for_sqlalchemy/models/__init__.py +++ b/examples/api_for_sqlalchemy/models/__init__.py @@ -1,5 +1,7 @@ +from examples.api_for_sqlalchemy.models.age_rating import AgeRating from examples.api_for_sqlalchemy.models.child import Child from examples.api_for_sqlalchemy.models.computer import Computer +from examples.api_for_sqlalchemy.models.movie import Movie from examples.api_for_sqlalchemy.models.parent import Parent from examples.api_for_sqlalchemy.models.parent_to_child_association import ParentToChildAssociation from examples.api_for_sqlalchemy.models.post import Post @@ -9,8 +11,10 @@ from examples.api_for_sqlalchemy.models.workplace import Workplace __all__ = ( + "AgeRating", "Child", "Computer", + "Movie", "Parent", "ParentToChildAssociation", "Post", diff --git a/examples/api_for_sqlalchemy/models/age_rating.py b/examples/api_for_sqlalchemy/models/age_rating.py new file mode 100644 index 00000000..ad775b86 --- /dev/null +++ b/examples/api_for_sqlalchemy/models/age_rating.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from examples.api_for_sqlalchemy.models.base import BaseMetadata + +if TYPE_CHECKING: + from examples.api_for_sqlalchemy.models.movie import Movie + + +class AgeRating(BaseMetadata): + __tablename__ = "age_rating" + + name: Mapped[str] = mapped_column( + String(20), + primary_key=True, + ) + description: Mapped[str] = mapped_column( + Text(), + default="", + server_default="", + ) + + movies: Mapped[list["Movie"]] = relationship( + back_populates="age_rating_obj", + ) + + def __str__(self) -> str: + return self.name diff --git a/examples/api_for_sqlalchemy/models/base.py b/examples/api_for_sqlalchemy/models/base.py index 55e8499e..5ede01c9 100644 --- a/examples/api_for_sqlalchemy/models/base.py +++ b/examples/api_for_sqlalchemy/models/base.py @@ -3,9 +3,13 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column -class Base(DeclarativeBase): +class BaseMetadata(DeclarativeBase): __table_args__: ClassVar[dict[str, Any]] = { "extend_existing": True, } + +class Base(BaseMetadata): + __abstract__ = True + id: Mapped[int] = mapped_column(primary_key=True) diff --git a/examples/api_for_sqlalchemy/models/movie.py b/examples/api_for_sqlalchemy/models/movie.py new file mode 100644 index 00000000..aace1924 --- /dev/null +++ b/examples/api_for_sqlalchemy/models/movie.py @@ -0,0 +1,54 @@ +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import ( + ForeignKey, + Identity, + Integer, + String, + Text, +) +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from examples.api_for_sqlalchemy.models.base import Base + +if TYPE_CHECKING: + from examples.api_for_sqlalchemy.models.age_rating import AgeRating + + +class Movie(Base): + __tablename__ = "movie" + + id: Mapped[int] = mapped_column( + Integer, + Identity(always=True), + primary_key=True, + autoincrement=True, + ) + title: Mapped[str] = mapped_column( + String(120), + index=True, + ) + description: Mapped[str] = mapped_column( + Text, + default="", + server_default="", + ) + age_rating: Mapped[Optional[str]] = mapped_column( + ForeignKey( + "age_rating.name", + ondelete="SET NULL", + ), + ) + age_rating_obj: Mapped["AgeRating"] = relationship( + back_populates="movies", + ) + + def __str__(self) -> str: + return self.title + + def __repr__(self) -> str: + return f"Movie(id={self.id}, title={self.title!r})" diff --git a/examples/api_for_sqlalchemy/schemas/__init__.py b/examples/api_for_sqlalchemy/schemas/__init__.py index 14c5276a..d5792095 100755 --- a/examples/api_for_sqlalchemy/schemas/__init__.py +++ b/examples/api_for_sqlalchemy/schemas/__init__.py @@ -1,3 +1,10 @@ +from .age_rating import ( + AgeRatingAttributesSchema, + AgeRatingBaseSchema, + AgeRatingCreateSchema, + AgeRatingSchema, + AgeRatingUpdateSchema, +) from .child import ( ChildAttributesSchema, ChildInSchema, @@ -10,6 +17,13 @@ ComputerPatchSchema, ComputerSchema, ) +from .movie import ( + MovieAttributesSchema, + MovieBaseSchema, + MovieCreateSchema, + MovieSchema, + MovieUpdateSchema, +) from .parent import ( ParentAttributesSchema, ParentInSchema, @@ -51,6 +65,11 @@ ) __all__ = ( + "AgeRatingAttributesSchema", + "AgeRatingBaseSchema", + "AgeRatingCreateSchema", + "AgeRatingSchema", + "AgeRatingUpdateSchema", "ChildAttributesSchema", "ChildInSchema", "ChildPatchSchema", @@ -60,6 +79,11 @@ "ComputerPatchSchema", "ComputerSchema", "CustomUserAttributesSchema", + "MovieAttributesSchema", + "MovieBaseSchema", + "MovieCreateSchema", + "MovieSchema", + "MovieUpdateSchema", "ParentAttributesSchema", "ParentInSchema", "ParentPatchSchema", diff --git a/examples/api_for_sqlalchemy/schemas/age_rating.py b/examples/api_for_sqlalchemy/schemas/age_rating.py new file mode 100644 index 00000000..858400da --- /dev/null +++ b/examples/api_for_sqlalchemy/schemas/age_rating.py @@ -0,0 +1,49 @@ +from typing import TYPE_CHECKING, Annotated, Optional + +from annotated_types import MaxLen, MinLen +from pydantic import ConfigDict + +from fastapi_jsonapi.schema_base import BaseModel +from fastapi_jsonapi.types_metadata import RelationshipInfo + +name_constrained = Annotated[ + str, + MinLen(1), + MaxLen(20), +] + +if TYPE_CHECKING: + from examples.api_for_sqlalchemy.schemas.movie import MovieSchema + + +class AgeRatingAttributesSchema(BaseModel): + model_config = ConfigDict( + from_attributes=True, + ) + name: str + description: str + + +class AgeRatingBaseSchema(AgeRatingAttributesSchema): + movies: Annotated[ + Optional[list["MovieSchema"]], + RelationshipInfo( + resource_type="movie", + many=True, + ), + ] = None + + +class AgeRatingCreateSchema(AgeRatingBaseSchema): + name: name_constrained + + +class AgeRatingUpdateSchema(AgeRatingBaseSchema): + name: Optional[name_constrained] = None + description: Optional[str] = None + + +class AgeRatingSchema(AgeRatingBaseSchema): + """ + Age Rating + """ diff --git a/examples/api_for_sqlalchemy/schemas/movie.py b/examples/api_for_sqlalchemy/schemas/movie.py new file mode 100644 index 00000000..8586ac3d --- /dev/null +++ b/examples/api_for_sqlalchemy/schemas/movie.py @@ -0,0 +1,60 @@ +from datetime import date +from typing import ( + TYPE_CHECKING, + Annotated, + Optional, +) + +from annotated_types import MaxLen, MinLen +from pydantic import ConfigDict + +from fastapi_jsonapi.schema_base import BaseModel +from fastapi_jsonapi.types_metadata import RelationshipInfo + +if TYPE_CHECKING: + from examples.api_for_sqlalchemy.schemas.age_rating import AgeRatingSchema + +title_constrained = Annotated[ + str, + MinLen(1), + MaxLen(120), +] + + +class MovieAttributesSchema(BaseModel): + model_config = ConfigDict( + from_attributes=True, + ) + title: str + description: str + age_rating: Optional[str] = None + + +class MovieBaseSchema(MovieAttributesSchema): + age_rating_obj: Annotated[ + Optional["AgeRatingSchema"], + RelationshipInfo( + resource_type="age-rating", + resource_id_example="PG-13", + id_field_name="name", + ), + ] = None + + +class MovieCreateSchema(MovieBaseSchema): + """ + Create + """ + + title: title_constrained + + +class MovieUpdateSchema(MovieBaseSchema): + title: Optional[title_constrained] = None + description: Optional[str] = None + release_date: Optional[date] = None + duration: Optional[int] = None + + +class MovieSchema(MovieBaseSchema): + id: int diff --git a/examples/api_for_sqlalchemy/urls.py b/examples/api_for_sqlalchemy/urls.py index f933283e..97335149 100644 --- a/examples/api_for_sqlalchemy/urls.py +++ b/examples/api_for_sqlalchemy/urls.py @@ -7,8 +7,10 @@ from .api.views_base import ViewBase from .models import ( + AgeRating, Child, Computer, + Movie, Parent, ParentToChildAssociation, Post, @@ -17,12 +19,18 @@ Workplace, ) from .schemas import ( + AgeRatingCreateSchema, + AgeRatingSchema, + AgeRatingUpdateSchema, ChildInSchema, ChildPatchSchema, ChildSchema, ComputerInSchema, ComputerPatchSchema, ComputerSchema, + MovieCreateSchema, + MovieSchema, + MovieUpdateSchema, ParentInSchema, ParentPatchSchema, ParentSchema, @@ -131,6 +139,27 @@ def add_routes(app: FastAPI): schema_in_patch=WorkplacePatchSchema, schema_in_post=WorkplaceInSchema, ) + builder.add_resource( + path="/age-ratings", + tags=["Age Ratings"], + resource_type="age-rating", + view=ViewBase, + model=AgeRating, + schema=AgeRatingSchema, + schema_in_post=AgeRatingCreateSchema, + schema_in_patch=AgeRatingUpdateSchema, + model_id_field_name="name", + ) + builder.add_resource( + path="/movies", + tags=["Movie"], + resource_type="movie", + view=ViewBase, + model=Movie, + schema=MovieSchema, + schema_in_post=MovieCreateSchema, + schema_in_patch=MovieUpdateSchema, + ) builder.initialize() atomic = AtomicOperations() diff --git a/fastapi_jsonapi/api/schemas.py b/fastapi_jsonapi/api/schemas.py index 41728e0e..07dd9fdb 100644 --- a/fastapi_jsonapi/api/schemas.py +++ b/fastapi_jsonapi/api/schemas.py @@ -1,13 +1,19 @@ from typing import Iterable, Optional, Type, Union -from pydantic import BaseModel +from fastapi import APIRouter +from pydantic import BaseModel, ConfigDict from fastapi_jsonapi.data_typing import TypeModel, TypeSchema from fastapi_jsonapi.views import Operation, ViewBase class ResourceData(BaseModel): + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + path: Union[str, list[str]] + router: Optional[APIRouter] tags: list[str] view: Type[ViewBase] model: Type[TypeModel] diff --git a/fastapi_jsonapi/data_layers/base.py b/fastapi_jsonapi/data_layers/base.py index b70c952b..5de71d68 100644 --- a/fastapi_jsonapi/data_layers/base.py +++ b/fastapi_jsonapi/data_layers/base.py @@ -14,6 +14,7 @@ from fastapi_jsonapi.data_typing import TypeModel, TypeSchema from fastapi_jsonapi.querystring import QueryStringManager from fastapi_jsonapi.schema import BaseJSONAPIItemInSchema +from fastapi_jsonapi.storages import models_storage from fastapi_jsonapi.views import RelationshipRequestInfo @@ -51,6 +52,7 @@ def __init__( self.disable_collection_count: bool = disable_collection_count self.default_collection_count: int = default_collection_count self.is_atomic = False + self.id_column_name = models_storage.get_model_id_field_name(resource_type) async def atomic_start(self, previous_dl: Optional["BaseDataLayer"] = None): self.is_atomic = True diff --git a/fastapi_jsonapi/data_layers/sqla/base_model.py b/fastapi_jsonapi/data_layers/sqla/base_model.py index a8eaeac2..32ae7fff 100644 --- a/fastapi_jsonapi/data_layers/sqla/base_model.py +++ b/fastapi_jsonapi/data_layers/sqla/base_model.py @@ -97,8 +97,9 @@ async def count( cls, session: AsyncSession, stmt: Select, + id_field_name: str = "id", ) -> int: - stmt = select(func.count(distinct(column("id")))).select_from(stmt.subquery()) + stmt = select(func.count(distinct(column(id_field_name)))).select_from(stmt.subquery()) return (await session.execute(stmt)).scalar_one() @classmethod diff --git a/fastapi_jsonapi/data_layers/sqla/orm.py b/fastapi_jsonapi/data_layers/sqla/orm.py index 783b9c6c..efa0b35e 100644 --- a/fastapi_jsonapi/data_layers/sqla/orm.py +++ b/fastapi_jsonapi/data_layers/sqla/orm.py @@ -428,6 +428,7 @@ async def get_collection( objects_count = await self._base_sql.count( session=self.session, stmt=query, + id_field_name=self.id_column_name, ) collection = await self.after_get_collection(collection, qs, view_kwargs) diff --git a/fastapi_jsonapi/views/view_base.py b/fastapi_jsonapi/views/view_base.py index ddf499da..42e6059e 100644 --- a/fastapi_jsonapi/views/view_base.py +++ b/fastapi_jsonapi/views/view_base.py @@ -264,10 +264,13 @@ def _prepare_item_data( attrs_schema = schemas_storage.get_attrs_schema(resource_type, operation_type="get") if include_fields is None or not (field_schemas := include_fields.get(resource_type)): - + id_val = models_storage.get_object_id( + db_object=db_item, + resource_type=resource_type, + ) data_schema = schemas_storage.get_data_schema(resource_type, operation_type="get") return data_schema( - id=f"{db_item.id}", + id=f"{id_val}", attributes=attrs_schema.model_validate(db_item), ).model_dump() diff --git a/tests/conftest.py b/tests/conftest.py index 6ec7ed4a..5ace4c10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ refresh_db, ) from tests.fixtures.entities import ( # noqa + age_rating_g, child_1, child_2, child_3, diff --git a/tests/fixtures/app.py b/tests/fixtures/app.py index 8973bee5..dada09dd 100644 --- a/tests/fixtures/app.py +++ b/tests/fixtures/app.py @@ -6,8 +6,10 @@ from pydantic import BaseModel, ConfigDict from examples.api_for_sqlalchemy.models import ( + AgeRating, Child, Computer, + Movie, Parent, ParentToChildAssociation, Post, @@ -16,12 +18,18 @@ UserBio, ) from examples.api_for_sqlalchemy.schemas import ( + AgeRatingCreateSchema, + AgeRatingSchema, + AgeRatingUpdateSchema, ChildInSchema, ChildPatchSchema, ChildSchema, ComputerInSchema, ComputerPatchSchema, ComputerSchema, + MovieCreateSchema, + MovieSchema, + MovieUpdateSchema, ParentPatchSchema, ParentSchema, ParentToChildAssociationSchema, @@ -162,6 +170,27 @@ def add_routers(app_plain: FastAPI): schema_in_patch=UserPatchSchema, schema_in_post=UserInSchema, ) + builder.add_resource( + path="/age-ratings", + tags=["Age Ratings"], + resource_type="age-rating", + view=ViewBaseGeneric, + model=AgeRating, + schema=AgeRatingSchema, + schema_in_post=AgeRatingCreateSchema, + schema_in_patch=AgeRatingUpdateSchema, + model_id_field_name="name", + ) + builder.add_resource( + path="/movies", + tags=["Movie"], + resource_type="movie", + view=ViewBaseGeneric, + model=Movie, + schema=MovieSchema, + schema_in_post=MovieCreateSchema, + schema_in_patch=MovieUpdateSchema, + ) builder.initialize() return app_plain diff --git a/tests/fixtures/entities.py b/tests/fixtures/entities.py index 9d26ed76..3aee705d 100644 --- a/tests/fixtures/entities.py +++ b/tests/fixtures/entities.py @@ -1,3 +1,4 @@ +from textwrap import dedent from typing import Awaitable, Callable, Optional import pytest @@ -5,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from examples.api_for_sqlalchemy.models import ( + AgeRating, Child, Computer, Parent, @@ -532,6 +534,13 @@ def build_workplace(**fields): return Workplace(**fields) +def build_age_rating(name: str, description: str) -> AgeRating: + return AgeRating( + name=name, + description=description, + ) + + async def create_workplace(async_session: AsyncSession, **fields): workplace = build_workplace(**fields) async_session.add(workplace) @@ -539,6 +548,13 @@ async def create_workplace(async_session: AsyncSession, **fields): return workplace +async def create_age_rating(async_session: AsyncSession, **fields): + age_rating = build_age_rating(**fields) + async_session.add(age_rating) + await async_session.commit() + return age_rating + + @async_fixture() async def workplace_1( async_session: AsyncSession, @@ -551,3 +567,18 @@ async def workplace_2( async_session: AsyncSession, ): yield await create_workplace(async_session, name="workplace_2") + + +@async_fixture() +async def age_rating_g(async_session: AsyncSession) -> AgeRating: + return await create_age_rating( + async_session=async_session, + name="G", + description=dedent( + """G – General Audiences + + All ages admitted. + Nothing that would offend parents for viewing by children. + """, + ), + ) diff --git a/tests/test_api/test_model_wo_id.py b/tests/test_api/test_model_wo_id.py new file mode 100644 index 00000000..3a2dbc99 --- /dev/null +++ b/tests/test_api/test_model_wo_id.py @@ -0,0 +1,101 @@ +""" +Tests for custom id name. + +Check cases when model is w/o `id` field +""" + +from fastapi import FastAPI +from httpx import AsyncClient +from starlette import status + +from examples.api_for_sqlalchemy.models import AgeRating +from examples.api_for_sqlalchemy.schemas import AgeRatingAttributesSchema, MovieAttributesSchema +from tests.misc.utils import fake + + +async def test_get_age_rating_list( + app: FastAPI, + client: AsyncClient, + age_rating_g: AgeRating, +): + """ + Get age rating list, no `id` field on model + """ + url = app.url_path_for("get_age-rating_list") + response = await client.get(url) + assert response.status_code == status.HTTP_200_OK, response.text + response_data = response.json() + assert "data" in response_data, response_data + expected_data = { + "data": [ + { + "id": age_rating_g.name, + "attributes": AgeRatingAttributesSchema.model_validate(age_rating_g).model_dump(), + "type": "age-rating", + }, + ], + "meta": { + # we expect that db was empty before the test. + # the only age rating obj was created in a fixture. + "count": 1, + "totalPages": 1, + }, + "jsonapi": {"version": "1.0"}, + } + assert response_data == expected_data + + +async def test_create_with_related_age_rating( + app: FastAPI, + client: AsyncClient, + age_rating_g: AgeRating, +): + """ + Create with related age rating, no `id` field on model. + + Field `name` is used for `id`. + Check relation is created, new related object is included. + """ + url = app.url_path_for("get_movie_list") + url = f"{url}?include=age_rating_obj" + + movie_attributes_obj = MovieAttributesSchema( + title=fake.name(), + description=fake.sentence(), + ) + movie_attributes = movie_attributes_obj.model_dump(exclude_unset=True) + assert "age_rating" not in movie_attributes, "don't provide this field, it should be a related obj" + relationship_data = { + "age_rating_obj": { + "data": { + "type": "age-rating", + "id": age_rating_g.name, + }, + }, + } + movie_create = { + "data": { + "attributes": movie_attributes, + "relationships": relationship_data, + }, + } + response = await client.post(url, json=movie_create) + assert response.status_code == status.HTTP_201_CREATED, response.text + response_data = response.json() + movie_data: dict = response_data["data"] + assert movie_data.pop("id") + movie_attributes_obj.age_rating = age_rating_g.name + assert movie_data == { + "type": "movie", + "attributes": movie_attributes_obj.model_dump(), + "relationships": relationship_data, + } + + included = response_data["included"] + assert included == [ + { + "id": age_rating_g.name, + "type": "age-rating", + "attributes": AgeRatingAttributesSchema.model_validate(age_rating_g).model_dump(exclude_unset=True), + }, + ] diff --git a/tests/test_atomic/test_create_objects.py b/tests/test_atomic/test_create_objects.py index b1c8a62e..64b99ab9 100644 --- a/tests/test_atomic/test_create_objects.py +++ b/tests/test_atomic/test_create_objects.py @@ -10,10 +10,12 @@ from sqlalchemy.orm import joinedload from sqlalchemy.sql.functions import count -from examples.api_for_sqlalchemy.models import Child, Parent, ParentToChildAssociation, User, UserBio +from examples.api_for_sqlalchemy.models import AgeRating, Child, Movie, Parent, ParentToChildAssociation, User, UserBio from examples.api_for_sqlalchemy.schemas import ( + AgeRatingAttributesSchema, ChildAttributesSchema, ComputerAttributesBaseSchema, + MovieAttributesSchema, ParentAttributesSchema, ParentToChildAssociationAttributesSchema, UserAttributesBaseSchema, @@ -254,6 +256,7 @@ async def test_create_user_and_user_bio_with_local_id( :param client: :param async_session: + :param user_attributes: :return: """ user_data = user_attributes @@ -930,3 +933,98 @@ async def test_update_to_many_relationship_with_local_id( :param async_session: :return: """ + + +class TestAtomicCreateObjectsMayBeWoId: + + async def test_create_user_and_user_bio_with_local_id( + self, + client: AsyncClient, + async_session: AsyncSession, + ): + """ + Prepare test data: + + - create age rating + - create movie with age rating + + :param client: + :param async_session: + :return: + """ + age_rating_data = AgeRatingAttributesSchema( + name=fake.word(), + description=fake.sentence(), + ) + movie_data = MovieAttributesSchema( + title=fake.name(), + description=fake.sentence(), + ) + + age_rating_lid = fake.word() + data_atomic_request = { + # define operations + "atomic:operations": [ + # first operation: + # create a new age rating entity + { + "op": "add", + "data": { + "type": "age-rating", + "lid": age_rating_lid, + "attributes": age_rating_data.model_dump(), + }, + }, + # second operation: + # create a new movie, + # this movie has relation + # to the age rating with id=lid + { + "op": "add", + "data": { + "type": "movie", + "attributes": movie_data.model_dump(exclude_unset=True), + "relationships": { + "age_rating_obj": { + "data": { + "lid": age_rating_lid, + "type": "age-rating", + }, + }, + }, + }, + }, + ], + } + response = await client.post("/operations", json=data_atomic_request) + assert response.status_code == status.HTTP_200_OK, response.text + response_data = response.json() + movie = await async_session.scalar( + select(Movie).options( + joinedload(Movie.age_rating_obj), + ), + ) + assert isinstance(movie, Movie) + assert isinstance(movie.age_rating_obj, AgeRating) + + movie_data.age_rating = movie.age_rating + assert response_data == { + "atomic:results": [ + { + "data": { + "id": movie.age_rating, + "type": "age-rating", + "attributes": age_rating_data.model_dump(), + }, + "meta": None, + }, + { + "data": { + "id": f"{movie.id}", + "type": "movie", + "attributes": movie_data.model_dump(), + }, + "meta": None, + }, + ], + }