Mapper cannot access relationship direction attribute
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
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 😉
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}}]}}}}