From: Mike Bayer Date: Wed, 19 Oct 2022 13:41:44 +0000 (-0400) Subject: more many-to-one typing X-Git-Tag: rel_2_0_0b2~6 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=05d5f90d184494782d3ed6a24f6dd6b48bb31946;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git more many-to-one typing 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 --- diff --git a/doc/build/orm/basic_relationships.rst b/doc/build/orm/basic_relationships.rst index a243723999..28c1c65bfd 100644 --- a/doc/build/orm/basic_relationships.rst +++ b/doc/build/orm/basic_relationships.rst @@ -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 diff --git a/lib/sqlalchemy/orm/_orm_constructors.py b/lib/sqlalchemy/orm/_orm_constructors.py index e27a29729b..f001325933 100644 --- a/lib/sqlalchemy/orm/_orm_constructors.py +++ b/lib/sqlalchemy/orm/_orm_constructors.py @@ -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 diff --git a/lib/sqlalchemy/orm/relationships.py b/lib/sqlalchemy/orm/relationships.py index 81c26d3722..276199da27 100644 --- a/lib/sqlalchemy/orm/relationships.py +++ b/lib/sqlalchemy/orm/relationships.py @@ -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: diff --git a/lib/sqlalchemy/util/typing.py b/lib/sqlalchemy/util/typing.py index 85ef4bb455..1d93444476 100644 --- a/lib/sqlalchemy/util/typing.py +++ b/lib/sqlalchemy/util/typing.py @@ -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. diff --git a/test/orm/declarative/test_tm_future_annotations.py b/test/orm/declarative/test_tm_future_annotations.py index b1e80f5d93..45fb88f5c0 100644 --- a/test/orm/declarative/test_tm_future_annotations.py +++ b/test/orm/declarative/test_tm_future_annotations.py @@ -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): diff --git a/test/orm/declarative/test_typed_mapping.py b/test/orm/declarative/test_typed_mapping.py index ae8e9d746b..1928b3812c 100644 --- a/test/orm/declarative/test_typed_mapping.py +++ b/test/orm/declarative/test_typed_mapping.py @@ -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