From: Federico Caselli Date: Wed, 29 Apr 2026 21:09:57 +0000 (+0200) Subject: populate existing can be in exec option in session.get X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=43213299b596c1bd86a376a252e0df93c3759bdd;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git populate existing can be in exec option in session.get The ``populate_existing`` execution option is now honored when passed in the :paramref:`.Session.get.execution_options` dict by the method :meth:`.Session.get` and analogous in other session kinds. The current :paramref:`.Session.get.populate_existing` parameter will takes precedence if specified, overriding the value of the execution options. Fixes: #10610 Change-Id: I4ddc9a7c6dda8f31f4dd413b49a9196efb3edaa6 --- diff --git a/doc/build/changelog/unreleased_21/10610.rst b/doc/build/changelog/unreleased_21/10610.rst new file mode 100644 index 0000000000..03c1466e14 --- /dev/null +++ b/doc/build/changelog/unreleased_21/10610.rst @@ -0,0 +1,9 @@ +.. change:: + :tags: usecase, orm + :tickets: 10610 + + The ``populate_existing`` execution option is now honored when passed in the + :paramref:`.Session.get.execution_options` dict by the method + :meth:`.Session.get` and analogous in other session kinds. The current + :paramref:`.Session.get.populate_existing` parameter will takes precedence if + specified, overriding the value of the execution options. diff --git a/lib/sqlalchemy/ext/asyncio/scoping.py b/lib/sqlalchemy/ext/asyncio/scoping.py index 80d7a15a0f..2571e5ed54 100644 --- a/lib/sqlalchemy/ext/asyncio/scoping.py +++ b/lib/sqlalchemy/ext/asyncio/scoping.py @@ -1171,7 +1171,7 @@ class async_scoped_session(Generic[_AS]): ident: _PKIdentityArgument, *, options: Optional[Sequence[ORMOption]] = None, - populate_existing: bool = False, + populate_existing: bool | None = None, with_for_update: ForUpdateParameter = None, identity_token: Optional[Any] = None, execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT, @@ -1209,7 +1209,7 @@ class async_scoped_session(Generic[_AS]): ident: _PKIdentityArgument, *, options: Optional[Sequence[ORMOption]] = None, - populate_existing: bool = False, + populate_existing: bool | None = None, with_for_update: ForUpdateParameter = None, identity_token: Optional[Any] = None, execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT, diff --git a/lib/sqlalchemy/ext/asyncio/session.py b/lib/sqlalchemy/ext/asyncio/session.py index 69c98c57bb..ee7b6df960 100644 --- a/lib/sqlalchemy/ext/asyncio/session.py +++ b/lib/sqlalchemy/ext/asyncio/session.py @@ -593,7 +593,7 @@ class AsyncSession(ReversibleProxy[Session]): ident: _PKIdentityArgument, *, options: Optional[Sequence[ORMOption]] = None, - populate_existing: bool = False, + populate_existing: bool | None = None, with_for_update: ForUpdateParameter = None, identity_token: Optional[Any] = None, execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT, @@ -625,7 +625,7 @@ class AsyncSession(ReversibleProxy[Session]): ident: _PKIdentityArgument, *, options: Optional[Sequence[ORMOption]] = None, - populate_existing: bool = False, + populate_existing: bool | None = None, with_for_update: ForUpdateParameter = None, identity_token: Optional[Any] = None, execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT, diff --git a/lib/sqlalchemy/orm/scoping.py b/lib/sqlalchemy/orm/scoping.py index 0634086ea2..4d0a034872 100644 --- a/lib/sqlalchemy/orm/scoping.py +++ b/lib/sqlalchemy/orm/scoping.py @@ -969,7 +969,7 @@ class scoped_session(Generic[_S]): ident: _PKIdentityArgument, *, options: Optional[Sequence[ORMOption]] = None, - populate_existing: bool = False, + populate_existing: bool | None = None, with_for_update: ForUpdateParameter = None, identity_token: Optional[Any] = None, execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT, @@ -1048,6 +1048,8 @@ class scoped_session(Generic[_S]): :param populate_existing: causes the method to unconditionally emit a SQL query and refresh the object with the newly loaded data, regardless of whether or not the object is already present. + Setting this flag takes precedence over passing it as an + execution option. :param with_for_update: optional boolean ``True`` indicating FOR UPDATE should be used, or may be a dictionary containing flags to @@ -1098,7 +1100,7 @@ class scoped_session(Generic[_S]): ident: _PKIdentityArgument, *, options: Optional[Sequence[ORMOption]] = None, - populate_existing: bool = False, + populate_existing: bool | None = None, with_for_update: ForUpdateParameter = None, identity_token: Optional[Any] = None, execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT, diff --git a/lib/sqlalchemy/orm/session.py b/lib/sqlalchemy/orm/session.py index 0aa6458f97..a73bd2a698 100644 --- a/lib/sqlalchemy/orm/session.py +++ b/lib/sqlalchemy/orm/session.py @@ -3661,7 +3661,7 @@ class Session(_SessionClassMethods, EventTarget): ident: _PKIdentityArgument, *, options: Optional[Sequence[ORMOption]] = None, - populate_existing: bool = False, + populate_existing: bool | None = None, with_for_update: ForUpdateParameter = None, identity_token: Optional[Any] = None, execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT, @@ -3735,6 +3735,8 @@ class Session(_SessionClassMethods, EventTarget): :param populate_existing: causes the method to unconditionally emit a SQL query and refresh the object with the newly loaded data, regardless of whether or not the object is already present. + Setting this flag takes precedence over passing it as an + execution option. :param with_for_update: optional boolean ``True`` indicating FOR UPDATE should be used, or may be a dictionary containing flags to @@ -3784,7 +3786,7 @@ class Session(_SessionClassMethods, EventTarget): ident: _PKIdentityArgument, *, options: Optional[Sequence[ORMOption]] = None, - populate_existing: bool = False, + populate_existing: bool | None = None, with_for_update: ForUpdateParameter = None, identity_token: Optional[Any] = None, execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT, @@ -3835,12 +3837,24 @@ class Session(_SessionClassMethods, EventTarget): db_load_fn: Callable[..., _O], *, options: Optional[Sequence[ExecutableOption]] = None, - populate_existing: bool = False, + populate_existing: bool | None = None, with_for_update: ForUpdateParameter = None, identity_token: Optional[Any] = None, execution_options: OrmExecuteOptionsParameter = util.EMPTY_DICT, bind_arguments: Optional[_BindArguments] = None, ) -> Optional[_O]: + # set populate_existing value; direct parameter + # takes precedence over execution_options + if populate_existing is not None: + execution_options = { + **execution_options, # type: ignore[typeddict-item] + "populate_existing": populate_existing, + } + else: + populate_existing = execution_options.get( + "populate_existing", False + ) + # convert composite types to individual args if ( is_composite_class(primary_key_identity) diff --git a/test/orm/test_scoping.py b/test/orm/test_scoping.py index 509137afe3..88bc62ecf3 100644 --- a/test/orm/test_scoping.py +++ b/test/orm/test_scoping.py @@ -157,7 +157,7 @@ class ScopedSessionTest(fixtures.MappedTest): "Cls", 5, options=None, - populate_existing=False, + populate_existing=None, with_for_update=None, identity_token=None, execution_options=util.EMPTY_DICT, diff --git a/test/orm/test_session.py b/test/orm/test_session.py index fd14f5acda..065ca8145e 100644 --- a/test/orm/test_session.py +++ b/test/orm/test_session.py @@ -863,6 +863,80 @@ class SessionStateTest(_fixtures.FixtureTest): for key, value in expected_opts.items(): eq_(gather_options[key], value) + @testing.combinations( + ("default", None, {}, None), + ("arg_true", True, {}, True), + ("arg_false", False, {}, False), + ("arg_true_exe_false", True, {"populate_existing": False}, True), + ("arg_false_exe_true", False, {"populate_existing": True}, False), + ( + "exe_true", + None, + {"populate_existing": True}, + True, + ), + ( + "exe_false", + None, + {"populate_existing": False}, + False, + ), + argnames="session_parameter,execution_opt,expected_pe", + id_="iaaa", + ) + @testing.variation("object_in_session", [True, False]) + def test_get_populate_existing( + self, + session_parameter, + execution_opt, + expected_pe, + object_in_session, + ): + users, User = self.tables.users, self.classes.User + self.mapper_registry.map_imperatively(User, users) + + s = fixture_session() + + s.add(User(id=1, name="name")) + s.commit() + s.close() + if object_in_session: + # prevent GC of the object + _ = s.get(User, 1) + s.connection().execute( + update(User).where(User.id == 1).values(name="newname") + ) + + gather_options = [] + + @event.listens_for(s, "do_orm_execute") + def check(ctx: ORMExecuteState) -> None: + gather_options.append(ctx.execution_options) + + res = s.get( + User, + 1, + populate_existing=session_parameter, + execution_options=execution_opt, + ) + + if not object_in_session or expected_pe: + # object not in session (so we load) or we expected + # populate_existing to be set (so we load), ensure newer value and + # gather_options is present + eq_(res.name, "newname") + + if expected_pe is None: + assert "populate_existing" not in gather_options[0] + else: + eq_(gather_options[0]["populate_existing"], expected_pe) + + else: + # object was in the session and no populate_existing, so + # do_orm_execute never called + eq_(gather_options, []) + eq_(res.name, "name") + def test_autocommit_kw_accepted_but_must_be_false(self): Session(autocommit=False)