Search code examples
pythonpostgresqlsqlalchemydata-modelingrelationships

SQLAlchemy models assign relation unpredictably


I'm creating a database User model which can belong to several Traits. The below script reproduces a strange behavior that I don't understand -- after committing the new rows, the relationship attaches the Traits to the Users in an unpredictable way.

I want to be able to attach 1+ traits to a user, and I want users to be able to share traits.

But in this example, when a trait is shared between users, the Trait sometimes is attached to user 1, and othertimes user 2. How can I make it so that the users can share traits?

import enum
from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Enum, UniqueConstraint, ForeignKey
from sqlalchemy.orm import backref, relationship, validates
Base = declarative_base()

class TraitName(enum.Enum):
    happy = 0
    mad = 1
    full = 2

class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    username = Column(String(80), unique=True, nullable=False)
    traits = relationship("Trait")
    def __repr__(self):
        return self.username + str(traits)

class Trait(Base):
    __tablename__ = 'traits'

    id = Column(Integer, primary_key=True)
    name = Column(Enum(TraitName), nullable=False)
    user_id = Column(Integer, ForeignKey('users.id'))
    __table_args__ = (UniqueConstraint('name', 'user_id', name='_user_trait'),)
    def __repr__(self):
        return str(self.name)

When I run this script several times,

from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

engine = create_engine('postgresql://postgres@localhost:5420/test_db')
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
session = Session(engine)

user1 = User(username="bob")
user2 = User(username="ann")
happy_trait = Trait(name=TraitName.happy)
full_trait = Trait(name=TraitName.full)
user1.traits.extend([full_trait, happy_trait])
user2.traits.append(happy_trait)
session.add_all([user1, user2])
session.commit()
print([t.traits for t in session.query(User).all()])
print(session.query(Trait.user_id).all())
print(session.query(Trait).filter(Trait.user_id.in_((1,))).all())
session.close()

different results:

either:

[[TraitName.full, TraitName.happy], []] # Users' traits
[(1,), (1,)]                            # Traits' user_ids
[TraitName.full, TraitName.happy]       # Traits with uers_id 1

or

[[TraitName.full], [TraitName.happy]] # Users' traits
[(1,), (2,)]                          # Traits' user_ids
[TraitName.full]                      # Traits with uers_id 1

So I would like to understand why the Trait will be assigned unpredictably. And how I can model my User and Traits to avoid this -- maybe this isn't the appropraite pattern for attaching traits to users?

Also, if it's possible (I suppose everything is possible), I'd like to have two fields on User: inactive_traits and active_traits fields. But in anycase I first have to figure out how to get one list of traits working.


Solution

  • You named your classes badly, which caused you to use them wrong.

    Your TraitName is actually your Trait, which should be shared and your Trait is actually a UserTrait, which can't be shared between users, but you try to share the instance between them.

    I'd do the following instead:

    import enum
    from sqlalchemy import Column, Integer, String
    from sqlalchemy.ext.declarative import declarative_base
    from sqlalchemy import orm, Enum, ForeignKey, create_engine
    
    Base = declarative_base()
    
    class Trait(enum.Enum):
        happy = 0
        mad = 1
        full = 2
    
    class User(Base):
        __tablename__ = 'users'
    
        user_id = Column(Integer, primary_key=True)
        username = Column(String(80), unique=True, nullable=False)
        traits = orm.relationship("UserTrait")
        def __repr__(self):
            return self.username + str(self.traits)
    
    class UserTrait(Base):
        __tablename__ = 'usertraits'
    
        user_id = Column(Integer, ForeignKey('users.user_id'), primary_key=True)
        trait = Column(Enum(Trait), primary_key=True)
    
        def __repr__(self):
            return str([self.user_id, self.trait])
    
    def __main__():
    
        engine = create_engine('postgresql:///?host=/var/run/postgresql/', echo=False)
        Base.metadata.drop_all(engine)
        Base.metadata.create_all(engine)
        session = orm.Session(engine)
    
        user1 = User(username="bob")
        user2 = User(username="ann")
        user1.traits.extend([UserTrait(trait=Trait.full), UserTrait(trait=Trait.happy)])
        user2.traits.append(UserTrait(trait=Trait.happy))
        session.add_all([user1, user2])
        session.commit()
        print([t.traits for t in session.query(User).all()])
        print(session.query(UserTrait.user_id).all())
        print(session.query(UserTrait).filter(UserTrait.user_id.in_((1,))).all())
        session.close()
    
    if __name__ == "__main__":
        __main__()