Search code examples
sqlalchemyflask-sqlalchemypytestfactory-boy

How to use factory boy to test SQLalchemy association-object models?


I'm using the SQLalchemy association-object pattern (http://docs.sqlalchemy.org/en/rel_1_1/orm/basic_relationships.html#association-object) for three model classes.

Basic relationship is on the left side one User can belong to multiple Organizations. I'm storing extra User-Organization relevant data in the association object class. Then, the association-object class maps a many-to-one to the Organization.

From SQLAlchemy point, the relationship works fine. The problem is testing this with factory boy has proven difficult and always results in error RecursionError: maximum recursion depth exceeded.

Below are the three models for the association object relationship, where User is parent and the Child is Organization:

class MemberOrgsAssoc(Model):
        """The left side of the relationship maps a User as a one-to-many to
        Organizations. User-Organization relevant data is stored in 
        this association-object table. Then, there is a one-to-many from
        this association-object table to the Organization table. """

        __tablename__ = 'member_orgs'
 
        member_id = Column(db.Integer, db.ForeignKey("users.id"), primary_key=True)
        org_id = Column(db.Integer, db.ForeignKey("organizations.id"), primary_key=True)
        manager_id = Column(db.Integer, db.ForeignKey("users.id"))
        org_title = Column(db.Unicode(50))
        organization = relationship("Organization", back_populates="members")
        member = relationship("User", back_populates="organizations",
                              foreign_keys=[member_id])
        manager = relationship("User", back_populates="subordinates",
                               foreign_keys=[manager_id])

class User(SurrogatePK, Model):
    """A user of the app."""
    __tablename__ = 'users'

    username = Column(db.Unicode(80), unique=True, nullable=False)
    organizations = relationship("MemberOrgsAssoc", back_populates="member",
                                 primaryjoin = "member_orgs.c.member_id == User.id",
                                 lazy="dynamic")
    subordinates = relationship("MemberOrgsAssoc", back_populates="manager",
                                primaryjoin = "member_orgs.c.manager_id == User.id",
                                lazy="dynamic")

class Organization(SurrogatePK, Model):
    """An organization that Users may belong to."""
    __tablename__ = 'organizations'
    name = Column(db.Unicode(128), nullable=False)
    members = relationship("MemberOrgsAssoc", back_populates="organization")

So all the above SQLAlchemy model classes and relationships seem to work as intended for now.

Below are the three factory-boy classes I'm attempting to make work.

MemberOrgs association-object factory:

class MemberOrgsAssocFactory(BaseFactory):
    """Association-object table Factory"""

    class Meta:
        """Factory config"""
        model = MemberOrgsAssoc

    member_id = factory.SubFactory('tests.factories.UserFactory')
    org_id = factory.SubFactory('tests.factories.OrganizationFactory')
    manager_id = factory.SubFactory('tests.factories.UserFactory')
    org_title = Sequence(lambda n: 'CEO{0}'.format(n))
    organization = factory.SubFactory('tests.factories.OrganizationFactory')
    member = factory.SubFactory('tests.factories.UserFactory')
    manager = factory.SubFactory('tests.factories.UserFactory')

class UserFactory(BaseFactory):
    """User factory."""

    class Meta:
        """Factory configuration."""
        model = User

    username = Sequence(lambda n: 'user{0}'.format(n))
    organizations = factory.List(
        [factory.SubFactory('tests.factories.MemberOrgsAssocFactory')])
    subordinates = factory.List(
        [factory.SubFactory('tests.factories.MemberOrgsAssocFactory')])

class OrganizationFactory(BaseFactory):
    """Company factory"""

    class Meta:
        """Factory config"""
        model = Organization

    id = Sequence(lambda n: '{0}'.format(n))
    name = Sequence(lambda n: 'company{0}'.format(n))
    members = factory.List(
        [factory.SubFactory('tests.factories.MemberOrgsAssocFactory')])

Finally, need to make a user for the tests and so below is a pytest fixture to make a User. This is where the tests fail due to `RecursionError: maximum recursion depth exceeded".

@pytest.fixture(scope='function')
def user(db):
    """An user for the unit tests.
    setup reference: https://github.com/FactoryBoy/factory_boy/issues/101
    # how to handle self referential foreign key relation in factory boy
    # https://github.com/FactoryBoy/factory_boy/issues/173
    """
    user = UserFactory(
        organizations__0=None,
        subordinates__0=None,
    )

    a = MemberOrgsAssocFactory(
        is_org_admin=True,
        is_default_org=True,
        is_active=True,
    )

    a.organization=OrganizationFactory()
    user.organizations.append(a)

    db.session.commit()
    return user

Error message:

E   RecursionError: maximum recursion depth exceeded
!!! Recursion detected (same locals & position)

Solution

  • More or less resolved this, though a bit fragile overall. Must follow required pattern carefully as laid out in the sqlalchemy docs:

    """ EXAMPLE USE:
     # create User object, append an Organization object via association
     p = User()
     a = MemberOrgsAssoc(extra_data="some data")
     a.organization = Organization()
     p.organizations.append(a)
    
    # iterate through Organization objects via association, including association attributes:
     for assoc in p.organizations:
         print(assoc.extra_data)
         print(assoc.child)
    """   
    

    Below changes to the pytest fixture resolved the RecursionError issue and got it working:

    @pytest.fixture(scope='function')
    def user(db):
        """An user for the tests."""
    
        user = UserFactory(
            organizations='',
            subordinates=''
        )
    
        a = MemberOrgsAssocFactory(
            member_id=None,
            org_id=None,
            manager_id=None,
            is_org_admin=True,
            is_default_org=True,
            is_active=True,
            organization=None,
            member=None,
            manager=None
        )
        a.organization = OrganizationFactory(members=[])
        user.organizations.append(a)
        db.session.commit()
    
        # debugging
        # thisuser = User.get_by_id(user.id)
        # for assoc in thisuser.organizations:
        #    if assoc.is_default_org:
        #        print('The default organization of thisuser is -> {}'.format(assoc.organization.name))
    
        return user