SQLAlchemy 2.1.4__py3-none-any.whl → 2.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. sqlalchemy/__init__.py +1 -1
  2. sqlalchemy/dialects/mysql/base.py +6 -1
  3. sqlalchemy/dialects/oracle/base.py +4 -1
  4. sqlalchemy/dialects/postgresql/base.py +12 -2
  5. sqlalchemy/engine/base.py +31 -19
  6. sqlalchemy/engine/create.py +20 -10
  7. sqlalchemy/engine/result.py +3 -1
  8. sqlalchemy/engine/url.py +39 -6
  9. sqlalchemy/ext/asyncio/result.py +23 -11
  10. sqlalchemy/orm/__init__.py +2 -0
  11. sqlalchemy/orm/_orm_constructors.py +134 -0
  12. sqlalchemy/orm/base.py +54 -0
  13. sqlalchemy/orm/bulk_persistence.py +5 -3
  14. sqlalchemy/orm/context.py +89 -3
  15. sqlalchemy/orm/decl_api.py +22 -5
  16. sqlalchemy/orm/decl_base.py +106 -22
  17. sqlalchemy/orm/mapper.py +56 -2
  18. sqlalchemy/orm/properties.py +75 -1
  19. sqlalchemy/orm/session.py +8 -1
  20. sqlalchemy/orm/util.py +9 -2
  21. sqlalchemy/pool/base.py +55 -30
  22. sqlalchemy/sql/compiler.py +117 -44
  23. sqlalchemy/sql/ddl.py +4 -4
  24. sqlalchemy/sql/dml.py +19 -17
  25. sqlalchemy/sql/schema.py +2 -0
  26. sqlalchemy/sql/selectable.py +42 -19
  27. sqlalchemy/testing/cancellation.py +77 -6
  28. sqlalchemy/util/langhelpers.py +7 -1
  29. sqlalchemy/util/queue.py +11 -0
  30. {sqlalchemy-2.1.4.dist-info → sqlalchemy-2.1.6.dist-info}/METADATA +2 -3
  31. {sqlalchemy-2.1.4.dist-info → sqlalchemy-2.1.6.dist-info}/RECORD +35 -35
  32. {sqlalchemy-2.1.4.dist-info → sqlalchemy-2.1.6.dist-info}/WHEEL +1 -1
  33. {sqlalchemy-2.1.4.dist-info → sqlalchemy-2.1.6.dist-info}/licenses/AUTHORS +0 -0
  34. {sqlalchemy-2.1.4.dist-info → sqlalchemy-2.1.6.dist-info}/licenses/LICENSE +0 -0
  35. {sqlalchemy-2.1.4.dist-info → sqlalchemy-2.1.6.dist-info}/top_level.txt +0 -0
sqlalchemy/__init__.py CHANGED
@@ -285,7 +285,7 @@ from .types import Uuid as Uuid
285
285
  from .types import VARBINARY as VARBINARY
286
286
  from .types import VARCHAR as VARCHAR
287
287
 
288
- __version__ = "2.1.4"
288
+ __version__ = "2.1.6"
289
289
 
290
290
 
291
291
  def __go(lcls: Any) -> None:
@@ -2386,6 +2386,8 @@ class MySQLDDLCompiler(
2386
2386
  class MySQLTypeCompiler(
2387
2387
  _mariadb_shim.MariaDBTypeCompilerShim, compiler.GenericTypeCompiler
2388
2388
  ):
2389
+ dialect: MySQLDialect
2390
+
2389
2391
  def _extend_numeric(self, type_: _NumericCommonType, spec: str) -> str:
2390
2392
  "Extend a numeric-type declaration with MySQL specific extensions."
2391
2393
 
@@ -2680,9 +2682,12 @@ class MySQLTypeCompiler(
2680
2682
  ) -> str:
2681
2683
  quoted_enums = []
2682
2684
  for e in enumerated_values:
2685
+ e = e.replace("'", "''")
2686
+ if self.dialect._backslash_escapes:
2687
+ e = e.replace("\\", "\\\\")
2683
2688
  if self.dialect.identifier_preparer._double_percents:
2684
2689
  e = e.replace("%", "%%")
2685
- quoted_enums.append("'%s'" % e.replace("'", "''"))
2690
+ quoted_enums.append("'%s'" % e)
2686
2691
  return self._extend_string(
2687
2692
  type_, {}, "%s(%s)" % (name, ",".join(quoted_enums))
2688
2693
  )
@@ -2279,7 +2279,10 @@ class OracleDialect(default._BackendsMultiReflection, default.DefaultDialect):
2279
2279
  return self.get_isolation_level(dbapi_conn)
2280
2280
  except NotImplementedError:
2281
2281
  raise
2282
- except:
2282
+ except Exception:
2283
+ # Exception only; a bare except here also caught
2284
+ # asyncio.CancelledError and KeyboardInterrupt, returning the
2285
+ # default in place of propagating them
2283
2286
  return "READ COMMITTED"
2284
2287
 
2285
2288
  def _execute_reflection(
@@ -5070,8 +5070,9 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect):
5070
5070
 
5071
5071
  # https://www.postgresql.org/docs/current/static/sql-createtable.html
5072
5072
  return re.compile(
5073
- r"FOREIGN KEY \((.*?)\) "
5074
- rf"REFERENCES (?:({qtoken})\.)?({qtoken})\(((?:{qtoken}(?: *, *)?)+)\)" # noqa: E501
5073
+ rf"FOREIGN KEY \((.*?)(?:, PERIOD ({qtoken}))?\) "
5074
+ rf"REFERENCES (?:({qtoken})\.)?({qtoken})"
5075
+ rf"\(((?:{qtoken}(?: *, *)?)+)(?:, PERIOD ({qtoken}))?\)"
5075
5076
  r"[\s]?(MATCH (FULL|PARTIAL|SIMPLE)+)?"
5076
5077
  r"[\s]?(?:ON (UPDATE|DELETE) "
5077
5078
  r"(CASCADE|RESTRICT|NO ACTION|"
@@ -5089,9 +5090,11 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect):
5089
5090
 
5090
5091
  (
5091
5092
  constrained_columns,
5093
+ constrained_period_column,
5092
5094
  referred_schema,
5093
5095
  referred_table,
5094
5096
  referred_columns,
5097
+ referred_period_column,
5095
5098
  _,
5096
5099
  match,
5097
5100
  upddelkey1,
@@ -5103,6 +5106,13 @@ class PGDialect(default._BackendsMultiReflection, default.DefaultDialect):
5103
5106
  initially,
5104
5107
  ) = m
5105
5108
 
5109
+ # TODO: the PERIOD designation of a temporal foreign key
5110
+ # (PostgreSQL 18+) is not yet reflected
5111
+ if constrained_period_column:
5112
+ constrained_columns += f", {constrained_period_column}"
5113
+ if referred_period_column:
5114
+ referred_columns += f", {referred_period_column}"
5115
+
5106
5116
  onupdate = (
5107
5117
  upddelval1
5108
5118
  if upddelkey1 == "UPDATE"
sqlalchemy/engine/base.py CHANGED
@@ -758,12 +758,19 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
758
758
  if self.closed:
759
759
  raise exc.ResourceClosedError("This Connection is closed")
760
760
 
761
- if self._still_open_and_dbapi_connection_is_valid:
762
- pool_proxied_connection = self._dbapi_connection
763
- assert pool_proxied_connection is not None
764
- pool_proxied_connection.invalidate(exception)
765
-
766
- self._dbapi_connection = None
761
+ try:
762
+ if self._still_open_and_dbapi_connection_is_valid:
763
+ pool_proxied_connection = self._dbapi_connection
764
+ assert pool_proxied_connection is not None
765
+ pool_proxied_connection.invalidate(exception)
766
+ finally:
767
+ # this is always a hard invalidation, which closes the DBAPI
768
+ # connection; for an asyncio driver that close is an awaited
769
+ # terminate() which a cancellation can interrupt. this
770
+ # Connection is invalidated regardless, so that nothing
771
+ # further, such as a rollback, is emitted on the DBAPI
772
+ # connection
773
+ self._dbapi_connection = None
767
774
 
768
775
  def detach(self) -> None:
769
776
  """Detach the underlying DB-API connection from its connection pool.
@@ -1266,24 +1273,29 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
1266
1273
  self._transaction.close()
1267
1274
  skip_reset = True
1268
1275
  finally:
1269
- if self._dbapi_connection is not None:
1270
- conn = self._dbapi_connection
1271
-
1272
- # as we just closed the transaction, close the connection
1273
- # pool connection without doing an additional reset
1274
- if skip_reset:
1275
- cast("_ConnectionFairy", conn)._close_special(
1276
- transaction_reset=True
1277
- )
1278
- else:
1279
- conn.close()
1280
-
1276
+ try:
1277
+ if self._dbapi_connection is not None:
1278
+ conn = self._dbapi_connection
1279
+
1280
+ # as we just closed the transaction, close the connection
1281
+ # pool connection without doing an additional reset
1282
+ if skip_reset:
1283
+ cast("_ConnectionFairy", conn)._close_special(
1284
+ transaction_reset=True
1285
+ )
1286
+ else:
1287
+ conn.close()
1288
+ finally:
1281
1289
  # There is a slight chance that conn.close() may have
1282
1290
  # triggered an invalidation here in which case
1283
1291
  # _dbapi_connection would already be None, however usually
1284
1292
  # it will be non-None here and in a "closed" state.
1293
+ #
1294
+ # there is also a chance that the conn.close() here could
1295
+ # itself be interrupted, such as by a cancellation; hence a
1296
+ # second try/finally is needed as well (see #13699)
1285
1297
  self._dbapi_connection = None
1286
- self.__can_reconnect = False
1298
+ self.__can_reconnect = False
1287
1299
 
1288
1300
  # special case to handle mypy issue:
1289
1301
  # https://github.com/python/mypy/issues/20651
@@ -747,16 +747,26 @@ def create_engine(url: Union[str, _url.URL], **kwargs: Any) -> Engine:
747
747
  )
748
748
  c._execution_options = util.EMPTY_DICT
749
749
 
750
- try:
751
- dialect.initialize(c)
752
- finally:
753
- # note that "invalidated" and "closed" are mutually
754
- # exclusive in 1.4 Connection.
755
- if not c.invalidated and not c.closed:
756
- # transaction is rolled back otherwise, tested by
757
- # test/dialect/postgresql/test_dialect.py
758
- # ::MiscBackendTest::test_initial_transaction_state
759
- dialect.do_rollback(c.connection)
750
+ dialect.initialize(c)
751
+
752
+ # roll back only once initialize() has succeeded, rather than
753
+ # in a finally: block. when initialize() raises, the DBAPI
754
+ # connection may be partway through an operation that c
755
+ # doesn't know about, such as psycopg's TypeInfo.fetch()
756
+ # interrupted by a cancellation; the rollback then fails
757
+ # and its error replaces the original one. the pool closes
758
+ # the connection in that case, so there's nothing to roll
759
+ # back.
760
+ #
761
+ # note that "invalidated" and "closed" are mutually
762
+ # exclusive in 1.4 Connection. c may be invalidated here if
763
+ # initialize() caught an error on a statement that was a
764
+ # disconnect.
765
+ if not c.invalidated and not c.closed:
766
+ # transaction is rolled back otherwise, tested by
767
+ # test/dialect/postgresql/test_dialect.py
768
+ # ::MiscBackendTest::test_initial_transaction_state
769
+ dialect.do_rollback(c.connection)
760
770
 
761
771
  # previously, the "first_connect" event was used here, which was then
762
772
  # scaled back if the "on_connect" handler were present. now,
@@ -1774,10 +1774,12 @@ class FrozenResult(Generic[Unpack[_Ts]]):
1774
1774
  self._source_supports_scalars = result._source_supports_scalars
1775
1775
  self._attributes = result._attributes
1776
1776
 
1777
+ # use _allrows() rather than fetchall() so that AsyncResult, whose
1778
+ # fetchall() is a coroutine, may be frozen within greenlet_spawn()
1777
1779
  if self._source_supports_scalars:
1778
1780
  self.data = list(result._raw_row_iterator())
1779
1781
  else:
1780
- self.data = result.fetchall()
1782
+ self.data = result._allrows()
1781
1783
 
1782
1784
  def _rewrite_rows(self) -> Sequence[Sequence[Any]]:
1783
1785
  # used only by the orm fn merge_frozen_result
sqlalchemy/engine/url.py CHANGED
@@ -364,7 +364,11 @@ class URL(NamedTuple):
364
364
  return self._replace(**kw)
365
365
 
366
366
  def update_query_string(
367
- self, query_string: str, append: bool = False
367
+ self,
368
+ query_string: str,
369
+ append: bool = False,
370
+ *,
371
+ keep_blank_values: bool = False,
368
372
  ) -> URL:
369
373
  """Return a new :class:`_engine.URL` object with the :attr:`_engine.URL.query`
370
374
  parameter dictionary updated by the given query string.
@@ -387,6 +391,13 @@ class URL(NamedTuple):
387
391
  If left at its default of False, keys present in the given query
388
392
  parameters will replace those of the existing query string.
389
393
 
394
+ :param keep_blank_values: if True, parameters with blank values
395
+ such as ``key=`` are retained in the query dictionary as empty
396
+ strings. If left at its default of False, parameters with blank
397
+ values are omitted.
398
+
399
+ .. versionadded:: 2.1.6
400
+
390
401
  .. versionadded:: 1.4
391
402
 
392
403
  .. seealso::
@@ -396,7 +407,10 @@ class URL(NamedTuple):
396
407
  :meth:`_engine.URL.update_query_dict`
397
408
 
398
409
  """ # noqa: E501
399
- return self.update_query_pairs(parse_qsl(query_string), append=append)
410
+ return self.update_query_pairs(
411
+ parse_qsl(query_string, keep_blank_values=keep_blank_values),
412
+ append=append,
413
+ )
400
414
 
401
415
  def update_query_pairs(
402
416
  self,
@@ -836,7 +850,9 @@ class URL(NamedTuple):
836
850
  return translated
837
851
 
838
852
 
839
- def make_url(name_or_url: Union[str, URL]) -> URL:
853
+ def make_url(
854
+ name_or_url: Union[str, URL], *, keep_blank_values: bool = False
855
+ ) -> URL:
840
856
  """Given a string, produce a new URL instance.
841
857
 
842
858
  The format of the URL generally follows `RFC-1738
@@ -846,6 +862,21 @@ def make_url(name_or_url: Union[str, URL]) -> URL:
846
862
 
847
863
  If a :class:`.URL` object is passed, it is returned as is.
848
864
 
865
+ :param name_or_url: a string URL or :class:`.URL` object.
866
+
867
+ :param keep_blank_values: if True, query string parameters with blank
868
+ values such as ``key=`` are retained in the :attr:`.URL.query`
869
+ dictionary as empty strings. If left at its default of False,
870
+ parameters with blank values are omitted. Has no effect when a
871
+ :class:`.URL` object is passed.
872
+
873
+ As :func:`_sa.create_engine` parses string URLs using the default
874
+ setting, a string URL with blank values to be retained should be
875
+ parsed with :func:`.make_url` first, and the resulting :class:`.URL`
876
+ passed to :func:`_sa.create_engine`.
877
+
878
+ .. versionadded:: 2.1.6
879
+
849
880
  .. seealso::
850
881
 
851
882
  :ref:`database_urls`
@@ -853,7 +884,7 @@ def make_url(name_or_url: Union[str, URL]) -> URL:
853
884
  """
854
885
 
855
886
  if isinstance(name_or_url, str):
856
- return _parse_url(name_or_url)
887
+ return _parse_url(name_or_url, keep_blank_values=keep_blank_values)
857
888
  elif not isinstance(name_or_url, URL) and not hasattr(
858
889
  name_or_url, "_sqla_is_testing_if_this_is_a_mock_object"
859
890
  ):
@@ -864,7 +895,7 @@ def make_url(name_or_url: Union[str, URL]) -> URL:
864
895
  return name_or_url
865
896
 
866
897
 
867
- def _parse_url(name: str) -> URL:
898
+ def _parse_url(name: str, keep_blank_values: bool = False) -> URL:
868
899
  pattern = re.compile(
869
900
  r"""
870
901
  (?P<name>[\w\+]+)://
@@ -892,7 +923,9 @@ def _parse_url(name: str) -> URL:
892
923
  if components["query"] is not None:
893
924
  query = {}
894
925
 
895
- for key, value in parse_qsl(components["query"]):
926
+ for key, value in parse_qsl(
927
+ components["query"], keep_blank_values=keep_blank_values
928
+ ):
896
929
  if key in query:
897
930
  query[key] = util.to_list(query[key])
898
931
  cast("List[str]", query[key]).append(value)
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import operator
10
10
  from typing import Any
11
11
  from typing import AsyncIterator
12
+ from typing import Iterator
12
13
  from typing import Literal
13
14
  from typing import Optional
14
15
  from typing import overload
@@ -20,6 +21,7 @@ from typing import TypeVar
20
21
  from . import exc as async_exc
21
22
  from ... import util
22
23
  from ...engine import Result
24
+ from ...engine._result_cy import _RowData
23
25
  from ...engine.result import _NO_ROW
24
26
  from ...engine.result import _R
25
27
  from ...engine.result import _WithKeys
@@ -467,6 +469,11 @@ class AsyncResult(_WithKeys, AsyncCommon[Row[Unpack[_Ts]]]):
467
469
 
468
470
  return await greenlet_spawn(FrozenResult, self)
469
471
 
472
+ def _raw_row_iterator(self) -> Iterator[_RowData]:
473
+ # used by FrozenResult when the source result supports scalars;
474
+ # must be invoked within greenlet_spawn()
475
+ return self._real_result._raw_row_iterator()
476
+
470
477
  @overload
471
478
  def scalars(
472
479
  self: AsyncResult[_T, Unpack[TupleAny]], index: Literal[0]
@@ -494,7 +501,7 @@ class AsyncResult(_WithKeys, AsyncCommon[Row[Unpack[_Ts]]]):
494
501
  referring to this :class:`_asyncio.AsyncResult` object.
495
502
 
496
503
  """
497
- return AsyncScalarResult(self._real_result, index)
504
+ return AsyncScalarResult(self, index)
498
505
 
499
506
  def mappings(self) -> AsyncMappingResult:
500
507
  """Apply a mappings filter to returned rows, returning an instance of
@@ -505,11 +512,11 @@ class AsyncResult(_WithKeys, AsyncCommon[Row[Unpack[_Ts]]]):
505
512
  objects.
506
513
 
507
514
  :return: a new :class:`_asyncio.AsyncMappingResult` filtering object
508
- referring to the underlying :class:`_result.Result` object.
515
+ referring to this :class:`_asyncio.AsyncResult` object.
509
516
 
510
517
  """
511
518
 
512
- return AsyncMappingResult(self._real_result)
519
+ return AsyncMappingResult(self)
513
520
 
514
521
 
515
522
  class AsyncScalarResult(AsyncCommon[_R]):
@@ -532,19 +539,21 @@ class AsyncScalarResult(AsyncCommon[_R]):
532
539
 
533
540
  def __init__(
534
541
  self,
535
- real_result: Result[Unpack[TupleAny]],
542
+ result: AsyncResult[Unpack[TupleAny]],
536
543
  index: _KeyIndexType,
537
544
  ):
538
- self._real_result = real_result
545
+ # rows are fetched from the sync Result; metadata and unique state
546
+ # come from the AsyncResult, where unique() and columns() apply them
547
+ self._real_result = result._real_result
539
548
 
540
- if real_result._source_supports_scalars:
541
- self._metadata = real_result._metadata
549
+ if result._source_supports_scalars:
550
+ self._metadata = result._metadata
542
551
  self._post_creational_filter = None
543
552
  else:
544
- self._metadata = real_result._metadata._reduce([index])
553
+ self._metadata = result._metadata._reduce([index])
545
554
  self._post_creational_filter = operator.itemgetter(0)
546
555
 
547
- self._unique_filter_state = real_result._unique_filter_state
556
+ self._unique_filter_state = result._unique_filter_state
548
557
 
549
558
  def unique(
550
559
  self,
@@ -665,8 +674,11 @@ class AsyncMappingResult(_WithKeys, AsyncCommon[RowMapping]):
665
674
 
666
675
  _post_creational_filter = operator.attrgetter("_mapping")
667
676
 
668
- def __init__(self, result: Result[Unpack[TupleAny]]):
669
- self._real_result = result
677
+ def __init__(self, result: AsyncResult[Unpack[TupleAny]]):
678
+ # rows are fetched from the sync Result; metadata and unique state
679
+ # come from the AsyncResult, where unique() and columns() apply them
680
+ self._real_result = result._real_result
681
+
670
682
  self._unique_filter_state = result._unique_filter_state
671
683
  self._metadata = result._metadata
672
684
  if result._source_supports_scalars:
@@ -37,6 +37,7 @@ from ._orm_constructors import outerjoin as outerjoin
37
37
  from ._orm_constructors import query_expression as query_expression
38
38
  from ._orm_constructors import relationship as relationship
39
39
  from ._orm_constructors import synonym as synonym
40
+ from ._orm_constructors import unmapped_column as unmapped_column
40
41
  from ._orm_constructors import with_loader_criteria as with_loader_criteria
41
42
  from ._orm_constructors import with_polymorphic as with_polymorphic
42
43
  from .attributes import AttributeEventToken as AttributeEventToken
@@ -51,6 +52,7 @@ from .base import NotExtension as NotExtension
51
52
  from .base import ORMDescriptor as ORMDescriptor
52
53
  from .base import PassiveFlag as PassiveFlag
53
54
  from .base import SQLORMExpression as SQLORMExpression
55
+ from .base import Unmapped as Unmapped
54
56
  from .base import WriteOnlyMapped as WriteOnlyMapped
55
57
  from .context import FromStatement as FromStatement
56
58
  from .context import QueryContext as QueryContext
@@ -29,6 +29,7 @@ from .descriptor_props import Synonym
29
29
  from .interfaces import _AttributeOptions
30
30
  from .properties import MappedColumn
31
31
  from .properties import MappedSQLExpression
32
+ from .properties import UnmappedColumn
32
33
  from .query import AliasOption
33
34
  from .relationships import _RelationshipArgumentType
34
35
  from .relationships import _RelationshipBackPopulatesArgument
@@ -395,6 +396,139 @@ def mapped_column(
395
396
  )
396
397
 
397
398
 
399
+ def unmapped_column(
400
+ __name_pos: Optional[
401
+ Union[str, _TypeEngineArgument[Any], SchemaEventTarget]
402
+ ] = None,
403
+ __type_pos: Optional[
404
+ Union[_TypeEngineArgument[Any], SchemaEventTarget]
405
+ ] = None,
406
+ /,
407
+ *args: SchemaEventTarget,
408
+ init: Literal[False] = False,
409
+ nullable: Optional[
410
+ Union[bool, Literal[SchemaConst.NULL_UNSPECIFIED]]
411
+ ] = SchemaConst.NULL_UNSPECIFIED,
412
+ primary_key: Optional[bool] = False,
413
+ autoincrement: _AutoIncrementType = "auto",
414
+ use_existing_column: bool = False,
415
+ name: Optional[str] = None,
416
+ type_: Optional[_TypeEngineArgument[Any]] = None,
417
+ index: Optional[bool] = None,
418
+ unique: Optional[bool] = None,
419
+ info: Optional[_InfoType] = None,
420
+ default: Optional[Any] = _NoArg.NO_ARG,
421
+ insert_default: Optional[Any] = _NoArg.NO_ARG,
422
+ onupdate: Optional[Any] = None,
423
+ server_default: Optional[_ServerDefaultArgument] = None,
424
+ server_onupdate: Optional[_ServerOnUpdateArgument] = None,
425
+ quote: Optional[bool] = None,
426
+ system: bool = False,
427
+ comment: Optional[str] = None,
428
+ sort_order: Union[_NoArg, int] = _NoArg.NO_ARG,
429
+ **kw: Any,
430
+ ) -> UnmappedColumn[Any]:
431
+ r"""declare a :class:`_schema.Column` that is part of the
432
+ :class:`_schema.Table` for a :ref:`Declarative Table
433
+ <orm_declarative_table>` mapping, but which is not mapped by the ORM.
434
+
435
+ :func:`_orm.unmapped_column` is used with the :class:`_orm.Unmapped`
436
+ annotation::
437
+
438
+ class User(Base):
439
+ __tablename__ = "user"
440
+
441
+ id: Mapped[int] = mapped_column(primary_key=True)
442
+ name: Mapped[str]
443
+
444
+ legacy_name: Unmapped[str | None] = unmapped_column()
445
+
446
+ The resulting :class:`_schema.Column` is added to the
447
+ :class:`_schema.Table` in the same way as one produced by
448
+ :func:`_orm.mapped_column`, deriving its type and nullability from the
449
+ annotation, so that the column is present in DDL as well as for schema
450
+ comparison tools such as Alembic autogenerate. However, the column is
451
+ excluded from the mapping, the attribute is removed from the class, and
452
+ when using :ref:`orm_declarative_native_dataclasses` it is not part of
453
+ the dataclass.
454
+
455
+ A bare ``Unmapped[]`` annotation with no right-hand side is equivalent
456
+ to ``unmapped_column()`` with no arguments; however, see the note at
457
+ :class:`_orm.Unmapped` regarding typing tools and dataclasses.
458
+
459
+ As the attribute is not part of the Python model, ORM and dataclass
460
+ oriented arguments of :func:`_orm.mapped_column`, such as
461
+ ``default_factory`` and ``deferred``, are not part of
462
+ :func:`_orm.unmapped_column`.
463
+
464
+ .. versionadded:: 2.1.5
465
+
466
+ .. seealso::
467
+
468
+ :ref:`orm_declarative_unmapped_column`
469
+
470
+ :param __name: String name to give to the :class:`_schema.Column`;
471
+ defaults to the attribute name.
472
+ :param __type: :class:`_types.TypeEngine` type or instance for the
473
+ :class:`_schema.Column`; if omitted, it is derived from the
474
+ annotation, or from a :class:`_schema.ForeignKey` if present.
475
+ :param \*args: Additional positional arguments such as
476
+ :class:`_schema.ForeignKey` and :class:`_schema.CheckConstraint`,
477
+ passed through to the :class:`_schema.Column`.
478
+ :param init: Always ``False``; this parameter is present so that
479
+ :pep:`681` typing tools omit the attribute from the ``__init__()``
480
+ method of :ref:`ORM mapped dataclasses
481
+ <orm_declarative_native_dataclasses>`.
482
+ :param nullable: whether the column is "NULL" or "NOT NULL"; if omitted,
483
+ derived from the annotation as with :func:`_orm.mapped_column`.
484
+ :param primary_key: indicates the :class:`_schema.Column` is part of the
485
+ table's primary key. As the mapper requires that its primary key
486
+ columns be mapped, the columns that the mapper should use for the
487
+ identity of the class must then be indicated using the
488
+ :paramref:`_orm.Mapper.primary_key` parameter; see
489
+ :ref:`orm_declarative_unmapped_column` for an example.
490
+ :param autoincrement: Passed directly to the
491
+ :paramref:`_schema.Column.autoincrement` parameter.
492
+ :param use_existing_column: see
493
+ :paramref:`_orm.mapped_column.use_existing_column`.
494
+ :param default: Passed directly to the :paramref:`_schema.Column.default`
495
+ parameter. As the attribute is not part of a dataclass, this is
496
+ strictly a Core column default.
497
+ :param insert_default: synonym for
498
+ :paramref:`_orm.unmapped_column.default`.
499
+ :param sort_order: see :paramref:`_orm.mapped_column.sort_order`.
500
+ :param \**kw: All remaining keyword arguments are passed through to the
501
+ constructor for the :class:`_schema.Column`, including dialect-specific
502
+ arguments.
503
+
504
+ """
505
+
506
+ return UnmappedColumn(
507
+ __name_pos,
508
+ __type_pos,
509
+ *args,
510
+ name=name,
511
+ type_=type_,
512
+ default=default,
513
+ insert_default=insert_default,
514
+ index=index,
515
+ unique=unique,
516
+ info=info,
517
+ nullable=nullable,
518
+ primary_key=primary_key,
519
+ autoincrement=autoincrement,
520
+ onupdate=onupdate,
521
+ server_default=server_default,
522
+ server_onupdate=server_onupdate,
523
+ use_existing_column=use_existing_column,
524
+ quote=quote,
525
+ comment=comment,
526
+ system=system,
527
+ sort_order=sort_order,
528
+ **kw,
529
+ )
530
+
531
+
398
532
  def orm_insert_sentinel(
399
533
  name: Optional[str] = None,
400
534
  type_: Optional[_TypeEngineArgument[Any]] = None,
sqlalchemy/orm/base.py CHANGED
@@ -881,6 +881,60 @@ class _DeclarativeMapped(Mapped[_T_co], _MappedAttribute[_T_co]):
881
881
  return NotImplemented
882
882
 
883
883
 
884
+ class Unmapped(_MappedAnnotationBase[_T_co]):
885
+ """Represent a database column that is part of the mapped
886
+ :class:`_schema.Table`, but is not mapped by the ORM.
887
+
888
+ The :class:`_orm.Unmapped` type annotation is used in an
889
+ :ref:`Annotated Declarative Table <orm_declarative_mapped_column>`
890
+ mapping, in conjunction with the :func:`_orm.unmapped_column`
891
+ construct, to indicate a column that should be present in the
892
+ :class:`_schema.Table` generated for the class, while not being part
893
+ of the Python model at all. The type and nullability of the
894
+ :class:`_schema.Column` are derived from the annotation in the same
895
+ way as for :class:`_orm.Mapped`.
896
+
897
+ E.g.::
898
+
899
+ class User(Base):
900
+ __tablename__ = "user"
901
+
902
+ id: Mapped[int] = mapped_column(primary_key=True)
903
+ name: Mapped[str]
904
+
905
+ legacy_name: Unmapped[str | None] = unmapped_column()
906
+
907
+ The ``legacy_name`` column is part of ``User.__table__``, but there is
908
+ no ``User.legacy_name`` attribute, and when using
909
+ :ref:`orm_declarative_native_dataclasses`, it is not part of the
910
+ dataclass.
911
+
912
+ A bare ``Unmapped[]`` annotation with no right-hand side is equivalent to
913
+ ``unmapped_column()`` with no arguments. When using
914
+ :ref:`orm_declarative_native_dataclasses`, the attribute is omitted from
915
+ the dataclass and its ``__init__()`` method in both cases; however,
916
+ :pep:`681` typing tools are only able to determine this when
917
+ :func:`_orm.unmapped_column` is present, and otherwise see the attribute
918
+ as a required ``__init__()`` argument, so it should always be included
919
+ in that case.
920
+
921
+ .. versionadded:: 2.1.5
922
+
923
+ .. seealso::
924
+
925
+ :ref:`orm_declarative_unmapped_column`
926
+
927
+ :func:`_orm.unmapped_column`
928
+
929
+ """
930
+
931
+ __slots__ = ()
932
+
933
+
934
+ def _is_unmapped_container(mapped_container: Optional[type]) -> bool:
935
+ return mapped_container is Unmapped
936
+
937
+
884
938
  class DynamicMapped(_MappedAnnotationBase[_T_co]):
885
939
  """Represent the ORM mapped attribute type for a "dynamic" relationship.
886
940
 
@@ -1933,9 +1933,11 @@ class _BulkORMUpdate(_BulkUDCompileState, UpdateDMLState):
1933
1933
  # attributes that were formerly modified instead get expired.
1934
1934
  # this only gets hit if the session had pending changes
1935
1935
  # and autoflush were set to False.
1936
- to_expire = attrib.intersection(dict_).difference(to_evaluate)
1937
- if to_expire:
1938
- state._expire_attributes(dict_, to_expire)
1936
+ formerly_modified = attrib.intersection(dict_).difference(
1937
+ to_evaluate
1938
+ )
1939
+ if formerly_modified:
1940
+ state._expire_attributes(dict_, formerly_modified)
1939
1941
 
1940
1942
  states.add(state)
1941
1943
  session._register_altered(states)