]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
more many-to-one typing
authorMike Bayer <mike_mp@zzzcomputing.com>
Wed, 19 Oct 2022 13:41:44 +0000 (09:41 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 19 Oct 2022 19:00:39 +0000 (15:00 -0400)
since we are typing centric, note this configuration
as we have just supported in #8668.

Note also I am just taking "backref" out of the basic
version of the docs here totally, this doc is already
a lot to read / take in without making it even more
confusing; backref still has an entirely dedicated
docs page which can have all the additional behaviors
of backref() described.

Additionally, get other "optional" forms to work including
``cls | None`` and ``Union[cls, None]``.

Fixes: #8668
Change-Id: I2b026f496a1710ddebfb4aa6cf8459b4892cbc54

doc/build/orm/basic_relationships.rst
lib/sqlalchemy/orm/_orm_constructors.py
lib/sqlalchemy/orm/relationships.py
lib/sqlalchemy/util/typing.py
test/orm/declarative/test_tm_future_annotations.py
test/orm/declarative/test_typed_mapping.py

index a2437239999b5325d8c8be753a0fef90ec27f62c..28c1c65bfd9dc681e85191aee512e3be8b8daa76 100644 (file)
@@ -125,7 +125,10 @@ a collection of items represented by the child::
 
 To establish a bidirectional relationship in one-to-many, where the "reverse"
 side is a many to one, specify an additional :func:`_orm.relationship` and connect
-the two using the :paramref:`_orm.relationship.back_populates` parameter::
+the two using the :paramref:`_orm.relationship.back_populates` parameter,
+using the attribute name of each :func:`_orm.relationship`
+as the value for :paramref:`_orm.relationship.back_populates` on the other::
+
 
     class Parent(Base):
         __tablename__ = "parent_table"
@@ -143,34 +146,6 @@ the two using the :paramref:`_orm.relationship.back_populates` parameter::
 
 ``Child`` will get a ``parent`` attribute with many-to-one semantics.
 
-Alternatively, the :paramref:`_orm.relationship.backref` option may be used
-on a single :func:`_orm.relationship` instead of using
-:paramref:`_orm.relationship.back_populates`; in this form, the ``Child.parent``
-relationship is generated implicitly::
-
-    class Parent(Base):
-        __tablename__ = "parent_table"
-
-        id: Mapped[int] = mapped_column(primary_key=True)
-        children: Mapped[list["Child"]] = relationship(backref="parent")
-
-
-    class Child(Base):
-        __tablename__ = "child_table"
-
-        id: Mapped[int] = mapped_column(primary_key=True)
-        parent_id: Mapped[int] = mapped_column(ForeignKey("parent_table.id"))
-
-.. note::
-
-  Using :paramref:`_orm.relationship.backref` will not provide
-  adequate information to :pep:`484` typing tools such that they will be
-  correctly aware of the ``Child.parent`` attribute, as it is not
-  explicitly present.  For modern Python styles,
-  :paramref:`_orm.relationship.back_populates` with explicit use of
-  :func:`_orm.relationship` on both classes in a bi-directional relationship
-  should be preferred.
-
 .. _relationship_patterns_o2m_collection:
 
 Using Sets, Lists, or other Collection Types for One To Many
@@ -240,9 +215,14 @@ attribute will be created::
 
         id: Mapped[int] = mapped_column(primary_key=True)
 
+The above example shows a many-to-one relationship that assumes non-nullable
+behavior; the next section, :ref:`relationship_patterns_nullable_m2o`,
+illustrates a nullable version.
+
 Bidirectional behavior is achieved by adding a second :func:`_orm.relationship`
 and applying the :paramref:`_orm.relationship.back_populates` parameter
-in both directions::
+in both directions, using the attribute name of each :func:`_orm.relationship`
+as the value for :paramref:`_orm.relationship.back_populates` on the other::
 
     class Parent(Base):
         __tablename__ = "parent_table"
@@ -258,11 +238,66 @@ in both directions::
         id: Mapped[int] = mapped_column(primary_key=True)
         parents: Mapped[list["Parent"]] = relationship(back_populates="child")
 
-As is the case with :ref:`relationship_patterns_o2m`, the
-:paramref:`_orm.relationship.backref` parameter may be used in place of
-:paramref:`_orm.relationship.back_populates`, however :paramref:`_orm.relationship.back_populates`
-is preferred for its explicitness.
+.. _relationship_patterns_nullable_m2o:
+
+Nullable Many-to-One
+^^^^^^^^^^^^^^^^^^^^
+
+In the preceding example, the ``Parent.child`` relationship is not typed as
+allowing ``None``; this follows from the ``Parent.child_id`` column itself
+not being nullable, as it is typed with ``Mapped[int]``.    If we wanted
+``Parent.child`` to be a **nullable** many-to-one, we can set both
+``Parent.child_id`` and ``Parent.child`` to be ``Optional[]``, in which
+case the configuration would look like::
+
+    from typing import Optional
+
+
+    class Parent(Base):
+        __tablename__ = "parent_table"
+
+        id: Mapped[int] = mapped_column(primary_key=True)
+        child_id: Mapped[Optional[int]] = mapped_column(ForeignKey("child_table.id"))
+        child: Mapped[Optional["Child"]] = relationship(back_populates="parents")
+
+
+    class Child(Base):
+        __tablename__ = "child_table"
+
+        id: Mapped[int] = mapped_column(primary_key=True)
+        parents: Mapped[list["Parent"]] = relationship(back_populates="child")
+
+Above, the column for ``Parent.child_id`` will be created in DDL to allow
+``NULL`` values. When using :func:`_orm.mapped_column` with explicit typing
+declarations, the specification of ``child_id: Mapped[Optional[int]]`` is
+equivalent to setting :paramref:`_schema.Column.nullable` to ``True`` on the
+:class:`_schema.Column`, whereas ``child_id: Mapped[int]`` is equivalent to
+setting it to ``False``. See :ref:`orm_declarative_mapped_column_nullability`
+for background on this behavior.
+
+.. tip::
+
+  If using Python 3.10 or greater, :pep:`604` syntax is more convenient
+  to indicate optional types using ``| None``, which when combined with
+  :pep:`563` postponed annotation evaluation so that string-quoted types aren't
+  required, would look like::
+
+      from __future__ import annotations
+
+
+      class Parent(Base):
+          __tablename__ = "parent_table"
 
+          id: Mapped[int] = mapped_column(primary_key=True)
+          child_id: Mapped[int | None] = mapped_column(ForeignKey("child_table.id"))
+          child: Mapped[Child | None] = relationship(back_populates="parents")
+
+
+      class Child(Base):
+          __tablename__ = "child_table"
+
+          id: Mapped[int] = mapped_column(primary_key=True)
+          parents: Mapped[list[Parent]] = relationship(back_populates="child")
 
 .. _relationships_one_to_one:
 
@@ -463,48 +498,6 @@ for each :func:`_orm.relationship` specify the common association table::
             secondary=association_table, back_populates="children"
         )
 
-When using the :paramref:`_orm.relationship.backref` parameter instead of
-:paramref:`_orm.relationship.back_populates`, the backref will automatically
-use the same :paramref:`_orm.relationship.secondary` argument for the
-reverse relationship::
-
-    from __future__ import annotations
-
-    from sqlalchemy import Column
-    from sqlalchemy import Table
-    from sqlalchemy import ForeignKey
-    from sqlalchemy import Integer
-    from sqlalchemy.orm import Mapped
-    from sqlalchemy.orm import mapped_column
-    from sqlalchemy.orm import DeclarativeBase
-    from sqlalchemy.orm import relationship
-
-
-    class Base(DeclarativeBase):
-        pass
-
-
-    association_table = Table(
-        "association_table",
-        Base.metadata,
-        Column("left_id", ForeignKey("left_table.id"), primary_key=True),
-        Column("right_id", ForeignKey("right_table.id"), primary_key=True),
-    )
-
-
-    class Parent(Base):
-        __tablename__ = "left_table"
-
-        id: Mapped[int] = mapped_column(primary_key=True)
-        children: Mapped[list[Child]] = relationship(
-            secondary=association_table, backref="parents"
-        )
-
-
-    class Child(Base):
-        __tablename__ = "right_table"
-        id: Mapped[int] = mapped_column(primary_key=True)
-
 Using a late-evaluated form for the "secondary" argument
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
@@ -703,12 +696,6 @@ constructs, linked to the existing ones using :paramref:`_orm.relationship.back_
         id: Mapped[int] = mapped_column(primary_key=True)
         parents: Mapped[list["Association"]] = relationship(back_populates="child")
 
-Schemes that use :paramref:`_orm.relationship.backref` are possible as well,
-where there would be two explicit :func:`_orm.relationship` constructs, each
-of which would then include :paramref:`_orm.relationship.backref`
-parameters that imply the production of two more
-:func:`_orm.relationship` constructs.
-
 Working with the association pattern in its direct form requires that child
 objects are associated with an association instance before being appended to
 the parent; similarly, access from parent to child goes through the
index e27a29729b271e50d37b6ed5a6ce8500e69a64ac..f0013259339bc529e1336cfd0894d06009d63d44 100644 (file)
@@ -941,6 +941,9 @@ def relationship(
         :ref:`relationship_patterns` - includes many examples of
         :paramref:`_orm.relationship.back_populates`.
 
+        :paramref:`_orm.relationship.backref` - legacy form which allows
+        more succinct configuration, but does not support explicit typing
+
     :param overlaps:
        A string name or comma-delimited set of names of other relationships
        on either this mapper, a descendant mapper, or a target mapper with
index 81c26d37224ce1399a38892182266ca6f5ac88d5..276199da272661f76dd8793685707cb4f62c75ee 100644 (file)
@@ -1784,7 +1784,7 @@ class RelationshipProperty(
             ):
                 self.uselist = False
 
-        self.argument = argument
+        self.argument = cast("_RelationshipArgumentType[_T]", argument)
 
     @util.preload_module("sqlalchemy.orm.mapper")
     def _setup_entity(self, __argument: Any = None) -> None:
index 85ef4bb455222911f53cbdab106610f3e125cac8..1d93444476f0eaf9f9257d9cdcf71f89d9fa2b87 100644 (file)
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import re
 import sys
 import typing
 from typing import Any
@@ -57,13 +58,15 @@ if compat.py310:
 else:
     NoneType = type(None)  # type: ignore
 
+NoneFwd = ForwardRef("None")
+
 typing_get_args = get_args
 typing_get_origin = get_origin
 
 # copied from TypeShed, required in order to implement
 # MutableMapping.update()
 
-_AnnotationScanType = Union[Type[Any], str]
+_AnnotationScanType = Union[Type[Any], str, ForwardRef]
 
 
 class SupportsKeysAndGetItem(Protocol[_KT, _VT_co]):
@@ -151,6 +154,13 @@ def de_optionalize_union_types(type_: Type[Any]) -> Type[Any]:
     ...
 
 
+@overload
+def de_optionalize_union_types(
+    type_: _AnnotationScanType,
+) -> _AnnotationScanType:
+    ...
+
+
 def de_optionalize_union_types(
     type_: _AnnotationScanType,
 ) -> _AnnotationScanType:
@@ -158,10 +168,14 @@ def de_optionalize_union_types(
     to not include the ``NoneType``.
 
     """
-    if is_optional(type_):
+    if is_fwd_ref(type_):
+        return de_optionalize_fwd_ref_union_types(cast(ForwardRef, type_))
+
+    elif is_optional(type_):
         typ = set(type_.__args__)  # type: ignore
 
         typ.discard(NoneType)
+        typ.discard(NoneFwd)
 
         return make_union_type(*typ)
 
@@ -169,6 +183,37 @@ def de_optionalize_union_types(
         return type_
 
 
+def de_optionalize_fwd_ref_union_types(
+    type_: ForwardRef,
+) -> _AnnotationScanType:
+    """return the non-optional type for Optional[], Union[None, ...], x|None,
+    etc. without de-stringifying forward refs.
+
+    unfortunately this seems to require lots of hardcoded heuristics
+
+    """
+
+    annotation = type_.__forward_arg__
+
+    mm = re.match(r"^(.+?)\[(.+)\]$", annotation)
+    if mm:
+        if mm.group(1) == "Optional":
+            return ForwardRef(mm.group(2))
+        elif mm.group(1) == "Union":
+            elements = re.split(r",\s*", mm.group(2))
+            return make_union_type(
+                *[ForwardRef(elem) for elem in elements if elem != "None"]
+            )
+        else:
+            return type_
+
+    pipe_tokens = re.split(r"\s*\|\s*", annotation)
+    if "None" in pipe_tokens:
+        return ForwardRef("|".join(p for p in pipe_tokens if p != "None"))
+
+    return type_
+
+
 def make_union_type(*types: _AnnotationScanType) -> Type[Any]:
     """Make a Union type.
 
index b1e80f5d93c0a5a4581af1a870c978f475178b74..45fb88f5c05e2dfedc16021154648207a3e09af5 100644 (file)
@@ -12,6 +12,7 @@ from sqlalchemy import ForeignKey
 from sqlalchemy import Integer
 from sqlalchemy import Numeric
 from sqlalchemy import Table
+from sqlalchemy import testing
 from sqlalchemy.orm import attribute_keyed_dict
 from sqlalchemy.orm import DeclarativeBase
 from sqlalchemy.orm import DynamicMapped
@@ -292,6 +293,55 @@ class RelationshipLHSTest(_RelationshipLHSTest):
 
         is_(a1.bs["foo"], b1)
 
+    @testing.combinations(
+        ("not_optional",),
+        ("optional",),
+        ("optional_fwd_ref",),
+        ("union_none",),
+        ("pep604", testing.requires.python310),
+        argnames="optional_on_m2o",
+    )
+    def test_basic_bidirectional(self, decl_base, optional_on_m2o):
+        class A(decl_base):
+            __tablename__ = "a"
+
+            id: Mapped[int] = mapped_column(primary_key=True)
+            data: Mapped[str] = mapped_column()
+            bs: Mapped[List["B"]] = relationship(  # noqa: F821
+                back_populates="a"
+            )
+
+        class B(decl_base):
+            __tablename__ = "b"
+            id: Mapped[int] = mapped_column(Integer, primary_key=True)
+            a_id: Mapped[int] = mapped_column(ForeignKey("a.id"))
+
+            if optional_on_m2o == "optional":
+                a: Mapped[Optional["A"]] = relationship(
+                    back_populates="bs", primaryjoin=a_id == A.id
+                )
+            elif optional_on_m2o == "optional_fwd_ref":
+                a: Mapped["Optional[A]"] = relationship(
+                    back_populates="bs", primaryjoin=a_id == A.id
+                )
+            elif optional_on_m2o == "union_none":
+                a: Mapped[Union[A, None]] = relationship(
+                    back_populates="bs", primaryjoin=a_id == A.id
+                )
+            elif optional_on_m2o == "pep604":
+                a: Mapped[A | None] = relationship(
+                    back_populates="bs", primaryjoin=a_id == A.id
+                )
+            else:
+                a: Mapped["A"] = relationship(
+                    back_populates="bs", primaryjoin=a_id == A.id
+                )
+
+        a1 = A(data="data")
+        b1 = B()
+        a1.bs.append(b1)
+        is_(a1, b1.a)
+
 
 class WriteOnlyRelationshipTest(_WriteOnlyRelationshipTest):
     def test_dynamic(self, decl_base):
index ae8e9d746b9a13a2354acb973d46c3fe56b14ec3..1928b3812c99f4809fa47b9ff39a471dac9d79df 100644 (file)
@@ -1236,7 +1236,14 @@ class RelationshipLHSTest(fixtures.TestBase, testing.AssertsCompiledSQL):
             select(A).join(A.bs), "SELECT a.id FROM a JOIN b ON a.id = b.a_id"
         )
 
-    @testing.combinations(True, False, argnames="optional_on_m2o")
+    @testing.combinations(
+        ("not_optional",),
+        ("optional",),
+        ("optional_fwd_ref",),
+        ("union_none",),
+        ("pep604", testing.requires.python310),
+        argnames="optional_on_m2o",
+    )
     def test_basic_bidirectional(self, decl_base, optional_on_m2o):
         class A(decl_base):
             __tablename__ = "a"
@@ -1252,10 +1259,22 @@ class RelationshipLHSTest(fixtures.TestBase, testing.AssertsCompiledSQL):
             id: Mapped[int] = mapped_column(Integer, primary_key=True)
             a_id: Mapped[int] = mapped_column(ForeignKey("a.id"))
 
-            if optional_on_m2o:
+            if optional_on_m2o == "optional":
                 a: Mapped[Optional["A"]] = relationship(
                     back_populates="bs", primaryjoin=a_id == A.id
                 )
+            elif optional_on_m2o == "optional_fwd_ref":
+                a: Mapped["Optional[A]"] = relationship(
+                    back_populates="bs", primaryjoin=a_id == A.id
+                )
+            elif optional_on_m2o == "union_none":
+                a: Mapped["Union[A, None]"] = relationship(
+                    back_populates="bs", primaryjoin=a_id == A.id
+                )
+            elif optional_on_m2o == "pep604":
+                a: Mapped[A | None] = relationship(
+                    back_populates="bs", primaryjoin=a_id == A.id
+                )
             else:
                 a: Mapped["A"] = relationship(
                     back_populates="bs", primaryjoin=a_id == A.id