OK so this is a little awkward if you truly want
"group.profiles[0].roles" to be local to the group. It means you don't
really want Group.profiles to be a list of Profile objects, which is
an object that can be associated with multiple groups. You want a
"view" object that is a GroupProfile, a Profile that's associated with
a specific group, which then has one or more Roles set up for that
Group/Profile association. So implied here is that the join across
the ProfileGroupRole association is between GroupProfile and Role - as
far as how to link Group and Profile together we will need to produce
distinct pairs by joining against a DISTINCT of ProfileGroupRole.
This is a more complicated technique.
I'm going off your original email that indicates you can't change the
table structure at all here. So to work all these requirements
together we can create a GroupProfile "view" object as:
class GroupProfile(Base):
__table__ = (
select([Group.group_id, Profile]).
select_from(join(Group, ProfileGroupRole).join(Profile)).
distinct().alias()
)
group = relationship(
Group,
backref=backref("profiles", viewonly=True,
order_by=__table__.c.profile_id),
primaryjoin=foreign(__table__.c.group_id) == Group.group_id,
viewonly=True)
roles = relationship(
Role,
secondary=ProfileGroupRole.__table__,
primaryjoin=and_(
__table__.c.group_id == ProfileGroupRole.group_id,
__table__.c.profile_id == ProfileGroupRole.profile_id),
viewonly=True,
collection_class=set,
lazy="joined", innerjoin=True)
Just so you know, that mapping is using several advanced techniques to
work the three-way association table into a structure that behaves
more like individual two-way associations (however note this is
strictly read-only stuff, it won't persist data as designed). The map
to a SELECT sets up that we can get unique Group/Profile pairs
regardless of how many ProfileGroupRole rows link them together. The
GroupProfile.role relationship brings ProfileGroupRole in as an
association table a second time to produce individual rows per Role,
and it also uses joined eager loading with innerjoin=True to emit
fewer queries; we can use innerjoin because there are definitely Role
rows present.
Below is full demo for SQLite that returns what you expect as well as
what I think you expect for some more complex cases:
from sqlalchemy import *
from sqlalchemy.orm import *
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Role(Base):
__tablename__ = 'role'
role_id = Column(Integer, primary_key=True)
role_name = Column(String, nullable=False)
role_description = Column(String)
def __repr__(self):
return "%s%r" % (
self.__class__.__name__,
{k: v for k, v in self.__dict__.items()
if not k.startswith('_')})
class Group(Base):
__tablename__ = 'group'
group_id = Column(Integer, primary_key=True)
group_name = Column(String, nullable=False)
group_description = Column(String)
class Profile(Base):
__tablename__ = 'profile'
profile_id = Column(Integer, primary_key=True)
profile_username = Column(String, nullable=False, unique=True)
class ProfileGroupRole(Base):
__tablename__ = 'profile_group_role_assoc'
profile_id = Column(
Integer, ForeignKey('profile.profile_id'), primary_key=True)
group_id = Column(
Integer, ForeignKey('group.group_id'), primary_key=True)
role_id = Column(
Integer, ForeignKey('role.role_id'), primary_key=True)
group = relationship(
Group, backref=backref('_profile_group_roles',
cascade='all, delete-orphan'))
role = relationship(Role)
profile = relationship(
Profile, backref=backref('_profile_group_roles',
cascade='all, delete-orphan'))
# using generic DISTINCT here, so create a subquery to minimize the columns
# against which DISTINCT is being applied. On Postgresql, we can
# use DISTINCT ON which would allow us to drop the extra subquery.
distinct_profile_groups = select(
[ProfileGroupRole.group_id, ProfileGroupRole.profile_id]).\
distinct().alias()
class GroupProfile(Base):
# the GroupProfile itself is then based on Group/Profile rows
# joined to our distinct group_id/profile_id pairs
__table__ = (
select([Group.group_id, Profile]).
select_from(join(Group, distinct_profile_groups).join(Profile)).
alias()
)
group = relationship(
Group,
backref=backref("profiles", viewonly=True,
order_by=__table__.c.profile_id),
# "foreign" - means our local "group_id" column can refer
# to the same "Group.group_id" across multiple rows, e.g. as though
# it's a foreign key to Group.group_id
primaryjoin=foreign(__table__.c.group_id) == Group.group_id,
viewonly=True)
roles = relationship(
Role,
secondary=ProfileGroupRole.__table__,
primaryjoin=and_(
__table__.c.group_id == ProfileGroupRole.group_id,
__table__.c.profile_id == ProfileGroupRole.profile_id),
viewonly=True,
collection_class=set,
lazy="joined", innerjoin=True)
e = create_engine("sqlite://", echo=True)
Base.metadata.create_all(e)
s = Session(e)
profile1 = Profile(profile_username='test_user')
group1 = Group(group_name='group1', group_description='')
role1 = Role(role_name='role1')
prg1 = ProfileGroupRole(profile=profile1, group=group1, role=role1)
group2 = Group(group_name='group2', group_description='')
role2 = Role(role_name='role2')
prg2 = ProfileGroupRole(profile=profile1, group=group2, role=role1)
prg3 = ProfileGroupRole(profile=profile1, group=group2, role=role2)
group3 = Group(group_name='group3', group_description='')
profile2 = Profile(profile_username='user2')
role3 = Role(role_name='role3')
prg4 = ProfileGroupRole(profile=profile1, group=group3, role=role1)
prg5 = ProfileGroupRole(profile=profile2, group=group3, role=role2)
prg6 = ProfileGroupRole(profile=profile2, group=group3, role=role3)
s.add_all([prg1, prg2, prg3, prg4, prg5])
s.commit()
# test one. A group with one GroupProfile and one Role.
group1 = s.query(Group).filter(Group.group_name == 'group1').one()
assert group1.profiles[0].roles == {role1}
print group1.profiles[0].roles
# test two. A group with one GroupProfile and two Roles.
group2 = s.query(Group).filter(Group.group_name == 'group2').one()
assert group2.profiles[0].roles == {role1, role2}
print group2.profiles[0].roles
assert len(group2.profiles) == 1
# test three. A group with *two* GroupProfile objects (e.g. two Profiles),
# each with roles.
group3 = s.query(Group).filter(Group.group_name == 'group3').one()
assert group3.profiles[0].roles == {role1}
assert group3.profiles[1].roles == {role2, role3}
print group3.profiles[0].roles
print group3.profiles[1].roles
assert len(group3.profiles) == 2