Repository: FactoryBoy/factory_boy
Pull request #927 (2022-05-11 18:51:07)

Include session getter support
As our team tries to migrate our tests to use factory_boy, we found tricky to set the DB sessions during the tests runtime, mocking was tricky and nasty. I [found this issue](https://github.com/FactoryBoy/factory_boy/issues/304), which closely resemble the problem we are currently facing, and decided to give a shot.

The idea is to allow passing a callable returning the SQLAlchemy session in a Meta attribute called `sqlalchemy_session_factory`. This has several use-cases, allowing control of the session on runtime.

This is my first OSS contribution ever, super excited with it. Any feedback is appreciated :) 

Fixes https://github.com/FactoryBoy/factory_boy/issues/304


aalvrz: I just stumbled upon this. Can you provide some examples on this is to be used? Or is this simply just using `sqlalchemy_session_factory` in the Meta class?
hugopellissari: > I just stumbled upon this. Can you provide some examples on this is to be used? Or is this simply just using `sqlalchemy_session_factory` in the Meta class?

Hey @aalvrz ! Yes, the idea is simply allowing a callable returning a sqlalchemy session to be provided in the Meta attribute instead of a plain session. We were facing several problems using factory boy because of this, as we dynamically create a custom session for our tests. This is a way I found to overcome this problem. Do you have a similar problem currently?
aalvrz: @hugopellissari Are you trying to create a custom session with transactional behavior for your tests? In order to easily clean the database after each test? This is what I am trying to achieve while at the same time switching to SQLAlchemy 1.4.

I have a feeling this might be just what I need! 
hugopellissari: > @hugopellissari Are you trying to create a custom session with transactional behavior for your tests? In order to easily clean the database after each test? This is what I am trying to achieve while at the same time switching to SQLAlchemy 1.4.
> 
> I have a feeling this might be just what I need!

That's is pretty much what I'm trying to achieve with this! We are already doing this, but now that we are trying to use factory boy, it became tricky to set this custom session. Glad it might help you as well :) 
aalvrz: > > @hugopellissari Are you trying to create a custom session with transactional behavior for your tests? In order to easily clean the database after each test? This is what I am trying to achieve while at the same time switching to SQLAlchemy 1.4.
> > I have a feeling this might be just what I need!
> 
> That's is pretty much what I'm trying to achieve with this! We are already doing this, but now that we are trying to use factory boy, it became tricky to set this custom session. Glad it might help you as well :)

Awesome!

By the way I managed to make that work with Factory Boy and SQLAlchemy 1.3. I wrote a post about it here, you might find it useful: https://aalvarez.me/posts/python-transactional-tests-using-sqlalchemy-pytest-and-factory-boy/

However for SQLAlchemy 1.4 this does not seem to work well because of the new way the session should be used (as a context manager) apparently.
francoisfreitag: Thanks for the update! The code and tests look good to me.

Before to land this patch, please:
- [ ] squash the commits with a [descriptive commit message](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html)
- [ ] document the attribute in https://factoryboy.readthedocs.io/en/latest/orms.html#sqlalchemy, with an example
- [ ] add an entry to the [changelog](https://github.com/hugopellissari/factory_boy/blob/31e8a41827aea805e0475f4e6aceaa19d84cdaac/docs/changelog.rst)
hugopellissari: I just updated the docs and changelog, if it looks good, I'll squash the commits right after :) 

Patch:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index e45e4be..34f3cac 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -10,6 +10,8 @@ ChangeLog
 
     - :issue:`366`: Add :class:`factory.django.Password` to generate Django :class:`~django.contrib.auth.models.User`
       passwords.
+    - :issue:`304`: Add :attr:`~factory.alchemy.SQLAlchemyOptions.sqlalchemy_session_factory` to dynamically
+      create sessions for use by the :class:`~factory.alchemy.SQLAlchemyModelFactory`.
     - Add support for Django 3.2
     - Add support for Django 4.0
     - Add support for Python 3.10
diff --git a/docs/orms.rst b/docs/orms.rst
index e464fc4..3d255a3 100644
--- a/docs/orms.rst
+++ b/docs/orms.rst
@@ -369,6 +369,25 @@ To work, this class needs an `SQLAlchemy`_ session object affected to the :attr:
         SQLAlchemy session to use to communicate with the database when creating
         an object through this :class:`SQLAlchemyModelFactory`.
 
+    .. attribute:: sqlalchemy_session_factory
+
+       .. versionadded:: 3.3.0
+
+         :class:`~collections.abc.Callable` returning a :class:`~sqlalchemy.orm.Session` instance to use to communicate
+         with the database. You can either provide the session through this attribute, or through
+         :attr:`~factory.alchemy.SQLAlchemyOptions.sqlalchemy_session`, but not both at the same time.
+
+        .. code-block:: python
+
+            from . import common
+
+            class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
+                class Meta:
+                    model = User
+                    sqlalchemy_session_factory = lambda: common.Session()
+
+                username = 'john'
+
     .. attribute:: sqlalchemy_session_persistence
 
         Control the action taken by ``sqlalchemy_session`` at the end of a create call.
diff --git a/factory/alchemy.py b/factory/alchemy.py
index ef7a591..cf20b53 100644
--- a/factory/alchemy.py
+++ b/factory/alchemy.py
@@ -22,10 +22,18 @@ class SQLAlchemyOptions(base.FactoryOptions):
                 (meta, VALID_SESSION_PERSISTENCE_TYPES, value)
             )
 
+    @staticmethod
+    def _check_has_sqlalchemy_session_set(meta, value):
+        if value and meta.sqlalchemy_session:
+            raise RuntimeError("Provide either a sqlalchemy_session or a sqlalchemy_session_factory, not both")
+
     def _build_default_options(self):
         return super()._build_default_options() + [
             base.OptionDefault('sqlalchemy_get_or_create', (), inherit=True),
             base.OptionDefault('sqlalchemy_session', None, inherit=True),
+            base.OptionDefault(
+                'sqlalchemy_session_factory', None, inherit=True, checker=self._check_has_sqlalchemy_session_set
+            ),
             base.OptionDefault(
                 'sqlalchemy_session_persistence',
                 None,
@@ -90,6 +98,10 @@ class SQLAlchemyModelFactory(base.Factory):
     @classmethod
     def _create(cls, model_class, *args, **kwargs):
         """Create an instance of the model, and save it to the database."""
+        session_factory = cls._meta.sqlalchemy_session_factory
+        if session_factory:
+            cls._meta.sqlalchemy_session = session_factory()
+
         session = cls._meta.sqlalchemy_session
 
         if session is None:


Test patch:
diff --git a/tests/test_alchemy.py b/tests/test_alchemy.py
index 0341083..005fb0f 100644
--- a/tests/test_alchemy.py
+++ b/tests/test_alchemy.py
@@ -264,6 +264,34 @@ class SQLAlchemyNoSessionTestCase(unittest.TestCase):
         self.assertEqual(inst1.id, 1)
 
 
+class SQLAlchemySessionFactoryTestCase(unittest.TestCase):
+
+    def test_create_get_session_from_sqlalchemy_session_factory(self):
+        class SessionGetterFactory(SQLAlchemyModelFactory):
+            class Meta:
+                model = models.StandardModel
+                sqlalchemy_session = None
+                sqlalchemy_session_factory = lambda: models.session
+
+            id = factory.Sequence(lambda n: n)
+
+        SessionGetterFactory.create()
+        self.assertEqual(SessionGetterFactory._meta.sqlalchemy_session, models.session)
+        # Reuse the session obtained from sqlalchemy_session_factory.
+        SessionGetterFactory.create()
+
+    def test_create_raise_exception_sqlalchemy_session_factory_not_callable(self):
+        message = "^Provide either a sqlalchemy_session or a sqlalchemy_session_factory, not both$"
+        with self.assertRaisesRegex(RuntimeError, message):
+            class SessionAndGetterFactory(SQLAlchemyModelFactory):
+                class Meta:
+                    model = models.StandardModel
+                    sqlalchemy_session = models.session
+                    sqlalchemy_session_factory = lambda: models.session
+
+                id = factory.Sequence(lambda n: n)
+
+
 class NameConflictTests(unittest.TestCase):
     """Regression test for `TypeError: _save() got multiple values for argument 'session'`