strawberry-sqlalchemy icon indicating copy to clipboard operation
strawberry-sqlalchemy copied to clipboard

Mapper cannot access relationship direction attribute

Open david-1792 opened this issue 8 months ago • 2 comments

I am running the mapper using async SQLAlchemy with a simple one-to-many relationship like the following.

# app.api.teams.models
class Team(Base):
    __tablename__ = 'team'

    id: Mapped[uuid.UUID] = mapped_column(types.UUID, primary_key=True, default=uuid.uuid4)

    name: Mapped[str]
    headquarters: Mapped[str]

# app.api.teams.graphql.types
@graphql_mapper.type(m.Team)
class Team:
    pass

# app.api.heroes.models
class Hero(Base):
    __tablename__ = 'hero'

    id: Mapped[uuid.UUID] = mapped_column(types.UUID, primary_key=True, default=uuid.uuid4)

    name: Mapped[str]
    secret_name: Mapped[str]
    age: Mapped[int]

    team_id: Mapped[uuid.UUID] = mapped_column(types.UUID, ForeignKey('team.id'))
    team: Mapped["Team"] = relationship()

# app.api.heroes.graphql.types
@graphql_mapper.type(m.Hero)
class Hero:
    __exclude__ = ['secret_name']

This example works fine and I can access the Team type through the Hero type. The problem arises when I create the heroes attribute on the Team class (to make the relationship bi-lateral)

# app.api.teams.models
def resolve_hero():
    from app.api.heroes.models import Hero
    return Hero

class Team(Base):
    __tablename__ = 'team'

    id: Mapped[uuid.UUID] = mapped_column(types.UUID, primary_key=True, default=uuid.uuid4)

    name: Mapped[str]
    headquarters: Mapped[str]

    heroes: Mapped[List["Hero"]] = relationship(resolve_hero, back_populates='team')

# app.api.heroes.models
class Hero(Base):
    __tablename__ = 'hero'

    id: Mapped[uuid.UUID] = mapped_column(types.UUID, primary_key=True, default=uuid.uuid4)

    name: Mapped[str]
    secret_name: Mapped[str]
    age: Mapped[int]

    team_id: Mapped[uuid.UUID] = mapped_column(types.UUID, ForeignKey('team.id'))
    team: Mapped["Team"] = relationship("Team", back_populates='heroes')

At this moment, the app does not deploy and I get the following error message.

  File "/app/app/api/heroes/graphql/types.py", line 13, in <module>
    @graphql_mapper.type(m.Hero)
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/strawberry_sqlalchemy_mapper/mapper.py", line 699, in convert
    strawberry_type = self._convert_relationship_to_strawberry_type(
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/strawberry_sqlalchemy_mapper/mapper.py", line 397, in _convert_relationship_to_strawberry_type
    if self._get_relationship_is_optional(relationship):
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/strawberry_sqlalchemy_mapper/mapper.py", line 406, in _get_relationship_is_optional
    if relationship.direction in [ONETOMANY, MANYTOMANY]:
       ^^^^^^^^^^^^^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/sqlalchemy/util/langhelpers.py", line 1332, in __getattr__
    return self._fallback_getattr(key)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/sqlalchemy/util/langhelpers.py", line 1301, in _fallback_getattr
    raise AttributeError(key)
AttributeError: direction

I am using strawberry v0.266.0 and strawberry-sqlalchemy-mapper v.0.6.0

david-1792 avatar Apr 30 '25 02:04 david-1792

Hi, @david-1792 , thanks so much for your description it really helps the investigation! Please fell free to work on a PR if you find the solution, I will check it on the weekend 😉

Ckk3 avatar May 06 '25 19:05 Ckk3

Hello, @david-1792 , I couldn't got success replicating this issue, I think that maybe you forgot to add the async_bind_factorry on the context_value , please take a look at the test I've made: I tested on both 0.6.0 and 0.6.1 version.

import uuid
from sqlalchemy import types
from sqlalchemy import orm
from sqlalchemy.orm import (
    relationship,
    Mapped,
    mapped_column,
)
from strawberry_sqlalchemy_mapper import StrawberrySQLAlchemyLoader


@pytest.fixture
def base():
    return orm.declarative_base()


@pytest.fixture
def mapper():
    return StrawberrySQLAlchemyMapper()


@pytest.fixture
def direction_relationship_table(base):
    class Hero(base):
        __tablename__ = "hero"

        id: Mapped[uuid.UUID] = mapped_column(
            types.UUID, primary_key=True, default=uuid.uuid4
        )

        name: Mapped[str]
        secret_name: Mapped[str]
        age: Mapped[int]

        team_id: Mapped[uuid.UUID] = mapped_column(types.UUID, ForeignKey("team.id"))
        team: Mapped["Team"] = relationship("Team", back_populates="heroes")

    def resolve_hero():
        return Hero

    class Team(base):
        __tablename__ = "team"

        id: Mapped[uuid.UUID] = mapped_column(
            types.UUID, primary_key=True, default=uuid.uuid4
        )

        name: Mapped[str]
        headquarters: Mapped[str]

        heroes: Mapped[List["Hero"]] = relationship(resolve_hero, back_populates="team")

    return Team, Hero


async def test_direction_relationship(
    direction_relationship_table, mapper, async_engine, base, async_sessionmaker
):
    # async_engine and async_sessionmaker are fixture from tests/conftest.py 
    async with async_engine.begin() as conn:
        await conn.run_sync(base.metadata.create_all)

    TeamModel, HeroModel = direction_relationship_table

    @mapper.type(TeamModel)
    class Team:
        pass

    @mapper.type(HeroModel)
    class Hero:
        __exclude__ = ["secret_name"]

    @strawberry.type
    class Query:
        @strawberry.field
        async def heroes(self) -> Hero:
            session = async_sessionmaker()
            return await session.get(
                HeroModel, uuid.UUID("1832dd83-79b0-499b-a6d4-42797f60e72a")
            )

    mapper.finalize()
    schema = strawberry.Schema(query=Query)

Its good to you know that I've tested a query with this code and it run as expected:

query = """\
    query GetHero {
        heroes {
            id
            name
            age
            team {
                id
                name
                headquarters
                heroes {
                    pageInfo {
                        hasNextPage
                        hasPreviousPage
                        startCursor
                        endCursor
                    }
                    edges {
                        cursor
                        node {
                            id
                            name
                            age
                        }
                    }
                }
            }
        }
    }
    """

    async with async_sessionmaker(expire_on_commit=False) as session:
        team = TeamModel(name="Avengers", headquarters="New York")
        hero = HeroModel(
            id=uuid.UUID("1832dd83-79b0-499b-a6d4-42797f60e72a"),
            name="Iron Man",
            secret_name="Tony Stark",
            age=45,
            team=team,  # Associate Hero with the Team
        )

        session.add_all([team, hero])
        await session.commit()
        # breakpoint()
        result = await schema.execute(
            query,
            context_value={
                "sqlalchemy_loader": StrawberrySQLAlchemyLoader(
                    async_bind_factory=async_sessionmaker
                )
            },
        )
        assert result.errors is None
        # result.data = {'heroes': {'id': '1832dd83-79b0-499b-a6d4-42797f60e72a', 'name': 'Iron Man', 'age': 45, 'team': {'id': 'b6a57fbd-bd1d-4029-80be-df33fed989db', 'name': 'Avengers', 'headquarters': 'New York', 'heroes': {'pageInfo': {'hasNextPage': False, 'hasPreviousPage': False, 'startCursor': 'YXJyYXljb25uZWN0aW9uOjA=', 'endCursor': 'YXJyYXljb25uZWN0aW9uOjA='}, 'edges': [{'cursor': 'YXJyYXljb25uZWN0aW9uOjA=', 'node': {'id': '1832dd83-79b0-499b-a6d4-42797f60e72a', 'name': 'Iron Man', 'age': 45}}]}}}}

Ckk3 avatar May 23 '25 17:05 Ckk3