From 22300dbd24f2152636491d2287bee08935e14282 Mon Sep 17 00:00:00 2001 From: Daniel Stone Date: Mon, 30 Aug 2021 11:15:25 -0400 Subject: [PATCH] Added loader options to session.merge, asyncsession.merge Added loader options to :meth:`_orm.Session.merge` and :meth:`_asyncio.AsyncSession.merge`, which will apply the given loader options to the ``get()`` used internally by merge, allowing eager loading of relationships etc. to be applied when the merge process loads a new object. Pull request courtesy Daniel Stone. Fixes: #6955 Closes: #6957 Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/6957 Pull-request-sha: ab4d96cd5da9a5dd01112b8dcd6514db64aa8d9f Change-Id: I5b94dfda1088a8bc6396e9fd9a072827df1f8680 --- doc/build/changelog/unreleased_14/6955.rst | 9 +++++ lib/sqlalchemy/ext/asyncio/session.py | 42 +++++++++++++++++++--- lib/sqlalchemy/orm/session.py | 16 +++++++-- test/ext/asyncio/test_session_py3k.py | 33 +++++++++++++++++ test/orm/test_merge.py | 31 ++++++++++++++++ 5 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 doc/build/changelog/unreleased_14/6955.rst diff --git a/doc/build/changelog/unreleased_14/6955.rst b/doc/build/changelog/unreleased_14/6955.rst new file mode 100644 index 0000000000..5896f3b4a4 --- /dev/null +++ b/doc/build/changelog/unreleased_14/6955.rst @@ -0,0 +1,9 @@ +.. change:: + :tags: usecase, orm + :tickets: 6955 + + Added loader options to :meth:`_orm.Session.merge` and + :meth:`_asyncio.AsyncSession.merge`, which will apply the given loader + options to the ``get()`` used internally by merge, allowing eager loading + of relationships etc. to be applied when the merge process loads a new + object. Pull request courtesy Daniel Stone. diff --git a/lib/sqlalchemy/ext/asyncio/session.py b/lib/sqlalchemy/ext/asyncio/session.py index 6b18e3d7c7..e4372f4484 100644 --- a/lib/sqlalchemy/ext/asyncio/session.py +++ b/lib/sqlalchemy/ext/asyncio/session.py @@ -133,6 +133,10 @@ class AsyncSession(ReversibleProxy): This is the async version of the :meth:`_orm.Session.refresh` method. See that method for a complete description of all options. + .. seealso:: + + :meth:`_orm.Session.refresh` - main documentation for refresh + """ return await greenlet_spawn( @@ -179,6 +183,11 @@ class AsyncSession(ReversibleProxy): ): """Execute a statement and return a buffered :class:`_engine.Result` object. + + .. seealso:: + + :meth:`_orm.Session.execute` - main documentation for execute + """ if execution_options: @@ -205,7 +214,13 @@ class AsyncSession(ReversibleProxy): bind_arguments=None, **kw ): - """Execute a statement and return a scalar result.""" + """Execute a statement and return a scalar result. + + .. seealso:: + + :meth:`_orm.Session.scalar` - main documentation for scalar + + """ result = await self.execute( statement, @@ -228,6 +243,10 @@ class AsyncSession(ReversibleProxy): """Return an instance based on the given primary key identifier, or ``None`` if not found. + .. seealso:: + + :meth:`_orm.Session.get` - main documentation for get + """ return await greenlet_spawn( @@ -276,17 +295,24 @@ class AsyncSession(ReversibleProxy): As this operation may need to cascade along unloaded relationships, it is awaitable to allow for those queries to take place. + .. seealso:: + + :meth:`_orm.Session.delete` - main documentation for delete """ return await greenlet_spawn(self.sync_session.delete, instance) - async def merge(self, instance, load=True): + async def merge(self, instance, load=True, options=None): """Copy the state of a given instance into a corresponding instance within this :class:`_asyncio.AsyncSession`. + .. seealso:: + + :meth:`_orm.Session.merge` - main documentation for merge + """ return await greenlet_spawn( - self.sync_session.merge, instance, load=load + self.sync_session.merge, instance, load=load, options=options ) async def flush(self, objects=None): @@ -294,7 +320,7 @@ class AsyncSession(ReversibleProxy): .. seealso:: - :meth:`_orm.Session.flush` + :meth:`_orm.Session.flush` - main documentation for flush """ await greenlet_spawn(self.sync_session.flush, objects=objects) @@ -334,9 +360,17 @@ class AsyncSession(ReversibleProxy): r"""Return a :class:`_asyncio.AsyncConnection` object corresponding to this :class:`.Session` object's transactional state. + This method may also be used to establish execution options for the + database connection used by the current transaction. + .. versionadded:: 1.4.24 Added **kw arguments which are passed through to the underlying :meth:`_orm.Session.connection` method. + .. seealso:: + + :meth:`_orm.Session.connection` - main documentation for + "connection" + """ sync_connection = await greenlet_spawn( diff --git a/lib/sqlalchemy/orm/session.py b/lib/sqlalchemy/orm/session.py index 0bdd5cc959..a93684126b 100644 --- a/lib/sqlalchemy/orm/session.py +++ b/lib/sqlalchemy/orm/session.py @@ -2843,7 +2843,7 @@ class Session(_SessionClassMethods): load_options=load_options, ) - def merge(self, instance, load=True): + def merge(self, instance, load=True, options=None): """Copy the state of a given instance into a corresponding instance within this :class:`.Session`. @@ -2889,6 +2889,11 @@ class Session(_SessionClassMethods): produced as "clean", so it is only appropriate that the given objects should be "clean" as well, else this suggests a mis-use of the method. + :param options: optional sequence of loader options which will be + applied to the :meth:`_orm.Session.get` method when the merge + operation loads the existing version of the object from the database. + + .. versionadded:: 1.4.24 .. seealso:: @@ -2916,6 +2921,7 @@ class Session(_SessionClassMethods): attributes.instance_state(instance), attributes.instance_dict(instance), load=load, + options=options, _recursive=_recursive, _resolve_conflict_map=_resolve_conflict_map, ) @@ -2927,6 +2933,7 @@ class Session(_SessionClassMethods): state, state_dict, load=True, + options=None, _recursive=None, _resolve_conflict_map=None, ): @@ -2990,7 +2997,12 @@ class Session(_SessionClassMethods): new_instance = True elif key_is_persistent: - merged = self.get(mapper.class_, key[1], identity_token=key[2]) + merged = self.get( + mapper.class_, + key[1], + identity_token=key[2], + options=options, + ) if merged is None: merged = mapper.class_manager.new_instance() diff --git a/test/ext/asyncio/test_session_py3k.py b/test/ext/asyncio/test_session_py3k.py index 4165991d41..cd90547406 100644 --- a/test/ext/asyncio/test_session_py3k.py +++ b/test/ext/asyncio/test_session_py3k.py @@ -106,6 +106,17 @@ class AsyncSessionQueryTest(AsyncFixture): u3 = await async_session.get(User, 12) is_(u3, None) + @async_test + async def test_get_loader_options(self, async_session): + User = self.classes.User + + u = await async_session.get( + User, 7, options=[selectinload(User.addresses)] + ) + + eq_(u.name, "jack") + eq_(len(u.__dict__["addresses"]), 1) + @async_test @testing.requires.independent_cursors @testing.combinations( @@ -335,6 +346,28 @@ class AsyncSessionTransactionTest(AsyncFixture): is_(new_u_merged, u1) eq_(u1.name, "new u1") + @async_test + async def test_merge_loader_options(self, async_session): + User = self.classes.User + Address = self.classes.Address + + async with async_session.begin(): + u1 = User(id=1, name="u1", addresses=[Address(email_address="e1")]) + + async_session.add(u1) + + await async_session.close() + + async with async_session.begin(): + new_u1 = User(id=1, name="new u1") + + new_u_merged = await async_session.merge( + new_u1, options=[selectinload(User.addresses)] + ) + + eq_(new_u_merged.name, "new u1") + eq_(len(new_u_merged.__dict__["addresses"]), 1) + @async_test async def test_join_to_external_transaction(self, async_engine): User = self.classes.User diff --git a/test/orm/test_merge.py b/test/orm/test_merge.py index a0cb5426f3..cf4e81c98e 100644 --- a/test/orm/test_merge.py +++ b/test/orm/test_merge.py @@ -18,6 +18,7 @@ from sqlalchemy.orm import deferred from sqlalchemy.orm import foreign from sqlalchemy.orm import mapper from sqlalchemy.orm import relationship +from sqlalchemy.orm import selectinload from sqlalchemy.orm import Session from sqlalchemy.orm import synonym from sqlalchemy.orm.collections import attribute_mapped_collection @@ -52,6 +53,36 @@ class MergeTest(_fixtures.FixtureTest): return canary + def test_loader_options(self): + User, Address, addresses, users = ( + self.classes.User, + self.classes.Address, + self.tables.addresses, + self.tables.users, + ) + + mapper( + User, + users, + properties={"addresses": relationship(Address, backref="user")}, + ) + mapper(Address, addresses) + + s = fixture_session() + u = User( + id=7, + name="fred", + addresses=[Address(id=1, email_address="jack@bean.com")], + ) + s.add(u) + s.commit() + s.close() + + u = User(id=7, name="fred") + u2 = s.merge(u, options=[selectinload(User.addresses)]) + + eq_(len(u2.__dict__["addresses"]), 1) + def test_transient_to_pending(self): User, users = self.classes.User, self.tables.users -- 2.47.2