diff options
author | mike bayer <mike_mp@zzzcomputing.com> | 2022-02-13 20:37:12 +0000 |
---|---|---|
committer | Gerrit Code Review <gerrit@ci3.zzzcomputing.com> | 2022-02-13 20:37:12 +0000 |
commit | d6b3c82b0c329730bcaff42b4bb39dba83acb536 (patch) | |
tree | d6b7f744a35c8d89615eeb0504ee7a4193f95642 /lib/sqlalchemy/orm | |
parent | 260ade78a70d51378de9e7b9456bfe6218859b6c (diff) | |
parent | e545298e35ea9f126054b337e4b5ba01988b29f7 (diff) | |
download | sqlalchemy-d6b3c82b0c329730bcaff42b4bb39dba83acb536.tar.gz |
Merge "establish mypy / typing approach for v2.0" into main
Diffstat (limited to 'lib/sqlalchemy/orm')
-rw-r--r-- | lib/sqlalchemy/orm/__init__.py | 18 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/_orm_constructors.py | 861 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/attributes.py | 6 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/base.py | 29 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/clsregistry.py | 46 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/collections.py | 259 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/context.py | 12 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/decl_api.py | 160 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/decl_base.py | 288 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/descriptor_props.py | 176 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/dynamic.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/interfaces.py | 89 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/mapped_collection.py | 232 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/mapper.py | 23 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/properties.py | 197 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/query.py | 51 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/relationships.py | 214 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/session.py | 171 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/strategies.py | 36 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/strategy_options.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/util.py | 466 |
21 files changed, 2268 insertions, 1070 deletions
diff --git a/lib/sqlalchemy/orm/__init__.py b/lib/sqlalchemy/orm/__init__.py index 55f2f3100..bbed93310 100644 --- a/lib/sqlalchemy/orm/__init__.py +++ b/lib/sqlalchemy/orm/__init__.py @@ -17,19 +17,27 @@ from . import exc as exc from . import mapper as mapperlib from . import strategy_options as strategy_options from ._orm_constructors import _mapper_fn as mapper +from ._orm_constructors import aliased as aliased from ._orm_constructors import backref as backref from ._orm_constructors import clear_mappers as clear_mappers from ._orm_constructors import column_property as column_property from ._orm_constructors import composite as composite +from ._orm_constructors import CompositeProperty as CompositeProperty from ._orm_constructors import contains_alias as contains_alias from ._orm_constructors import create_session as create_session from ._orm_constructors import deferred as deferred from ._orm_constructors import dynamic_loader as dynamic_loader +from ._orm_constructors import join as join from ._orm_constructors import mapped_column as mapped_column +from ._orm_constructors import MappedColumn as MappedColumn +from ._orm_constructors import outerjoin as outerjoin from ._orm_constructors import query_expression as query_expression from ._orm_constructors import relationship as relationship +from ._orm_constructors import RelationshipProperty as RelationshipProperty from ._orm_constructors import synonym as synonym +from ._orm_constructors import SynonymProperty as SynonymProperty from ._orm_constructors import with_loader_criteria as with_loader_criteria +from ._orm_constructors import with_polymorphic as with_polymorphic from .attributes import AttributeEvent as AttributeEvent from .attributes import InstrumentedAttribute as InstrumentedAttribute from .attributes import QueryableAttribute as QueryableAttribute @@ -46,8 +54,8 @@ from .decl_api import declared_attr as declared_attr from .decl_api import has_inherited_table as has_inherited_table from .decl_api import registry as registry from .decl_api import synonym_for as synonym_for -from .descriptor_props import CompositeProperty as CompositeProperty -from .descriptor_props import SynonymProperty as SynonymProperty +from .descriptor_props import Composite as Composite +from .descriptor_props import Synonym as Synonym from .dynamic import AppenderQuery as AppenderQuery from .events import AttributeEvents as AttributeEvents from .events import InstanceEvents as InstanceEvents @@ -81,7 +89,7 @@ from .query import AliasOption as AliasOption from .query import FromStatement as FromStatement from .query import Query as Query from .relationships import foreign as foreign -from .relationships import RelationshipProperty as RelationshipProperty +from .relationships import Relationship as Relationship from .relationships import remote as remote from .scoping import scoped_session as scoped_session from .session import close_all_sessions as close_all_sessions @@ -111,17 +119,13 @@ from .strategy_options import undefer as undefer from .strategy_options import undefer_group as undefer_group from .strategy_options import with_expression as with_expression from .unitofwork import UOWTransaction as UOWTransaction -from .util import aliased as aliased from .util import Bundle as Bundle from .util import CascadeOptions as CascadeOptions -from .util import join as join from .util import LoaderCriteriaOption as LoaderCriteriaOption from .util import object_mapper as object_mapper -from .util import outerjoin as outerjoin from .util import polymorphic_union as polymorphic_union from .util import was_deleted as was_deleted from .util import with_parent as with_parent -from .util import with_polymorphic as with_polymorphic from .. import util as _sa_util diff --git a/lib/sqlalchemy/orm/_orm_constructors.py b/lib/sqlalchemy/orm/_orm_constructors.py index 80607670e..a1f1faa05 100644 --- a/lib/sqlalchemy/orm/_orm_constructors.py +++ b/lib/sqlalchemy/orm/_orm_constructors.py @@ -7,35 +7,52 @@ import typing from typing import Any -from typing import Callable from typing import Collection +from typing import List +from typing import Mapping from typing import Optional from typing import overload +from typing import Set from typing import Type from typing import Union from . import mapper as mapperlib from .base import Mapped -from .descriptor_props import CompositeProperty -from .descriptor_props import SynonymProperty +from .descriptor_props import Composite +from .descriptor_props import Synonym +from .mapper import Mapper from .properties import ColumnProperty +from .properties import MappedColumn from .query import AliasOption -from .relationships import RelationshipProperty +from .relationships import _RelationshipArgumentType +from .relationships import Relationship from .session import Session +from .util import _ORMJoin +from .util import AliasedClass +from .util import AliasedInsp from .util import LoaderCriteriaOption from .. import sql from .. import util from ..exc import InvalidRequestError -from ..sql.schema import Column -from ..sql.schema import SchemaEventTarget +from ..sql.base import SchemaEventTarget +from ..sql.selectable import Alias +from ..sql.selectable import FromClause from ..sql.type_api import TypeEngine from ..util.typing import Literal - -_RC = typing.TypeVar("_RC") _T = typing.TypeVar("_T") +CompositeProperty = Composite +"""Alias for :class:`_orm.Composite`.""" + +RelationshipProperty = Relationship +"""Alias for :class:`_orm.Relationship`.""" + +SynonymProperty = Synonym +"""Alias for :class:`_orm.Synonym`.""" + + @util.deprecated( "1.4", "The :class:`.AliasOption` object is not necessary " @@ -51,35 +68,45 @@ def contains_alias(alias) -> "AliasOption": return AliasOption(alias) +# see test/ext/mypy/plain_files/mapped_column.py for mapped column +# typing tests + + @overload def mapped_column( + __type: Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"], *args: SchemaEventTarget, - nullable: bool = ..., - primary_key: bool = ..., + nullable: Literal[None] = ..., + primary_key: Literal[None] = ..., + deferred: bool = ..., **kw: Any, -) -> "Mapped": +) -> "MappedColumn[Any]": ... @overload def mapped_column( + __name: str, __type: Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"], *args: SchemaEventTarget, - nullable: Union[Literal[None], Literal[True]] = ..., - primary_key: Union[Literal[None], Literal[False]] = ..., + nullable: Literal[None] = ..., + primary_key: Literal[None] = ..., + deferred: bool = ..., **kw: Any, -) -> "Mapped[Optional[_T]]": +) -> "MappedColumn[Any]": ... @overload def mapped_column( + __name: str, __type: Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"], *args: SchemaEventTarget, - nullable: Union[Literal[None], Literal[True]] = ..., - primary_key: Union[Literal[None], Literal[False]] = ..., + nullable: Literal[True] = ..., + primary_key: Literal[None] = ..., + deferred: bool = ..., **kw: Any, -) -> "Mapped[Optional[_T]]": +) -> "MappedColumn[Optional[_T]]": ... @@ -87,45 +114,48 @@ def mapped_column( def mapped_column( __type: Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"], *args: SchemaEventTarget, - nullable: Union[Literal[None], Literal[False]] = ..., - primary_key: Literal[True] = True, + nullable: Literal[True] = ..., + primary_key: Literal[None] = ..., + deferred: bool = ..., **kw: Any, -) -> "Mapped[_T]": +) -> "MappedColumn[Optional[_T]]": ... @overload def mapped_column( + __name: str, __type: Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"], *args: SchemaEventTarget, nullable: Literal[False] = ..., - primary_key: bool = ..., + primary_key: Literal[None] = ..., + deferred: bool = ..., **kw: Any, -) -> "Mapped[_T]": +) -> "MappedColumn[_T]": ... @overload def mapped_column( - __name: str, __type: Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"], *args: SchemaEventTarget, - nullable: Union[Literal[None], Literal[True]] = ..., - primary_key: Union[Literal[None], Literal[False]] = ..., + nullable: Literal[False] = ..., + primary_key: Literal[None] = ..., + deferred: bool = ..., **kw: Any, -) -> "Mapped[Optional[_T]]": +) -> "MappedColumn[_T]": ... @overload def mapped_column( - __name: str, __type: Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"], *args: SchemaEventTarget, - nullable: Union[Literal[None], Literal[True]] = ..., - primary_key: Union[Literal[None], Literal[False]] = ..., + nullable: bool = ..., + primary_key: Literal[True] = ..., + deferred: bool = ..., **kw: Any, -) -> "Mapped[Optional[_T]]": +) -> "MappedColumn[_T]": ... @@ -134,55 +164,209 @@ def mapped_column( __name: str, __type: Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"], *args: SchemaEventTarget, - nullable: Union[Literal[None], Literal[False]] = ..., - primary_key: Literal[True] = True, + nullable: bool = ..., + primary_key: Literal[True] = ..., + deferred: bool = ..., **kw: Any, -) -> "Mapped[_T]": +) -> "MappedColumn[_T]": ... @overload def mapped_column( __name: str, - __type: Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"], *args: SchemaEventTarget, - nullable: Literal[False] = ..., + nullable: bool = ..., primary_key: bool = ..., + deferred: bool = ..., **kw: Any, -) -> "Mapped[_T]": +) -> "MappedColumn[Any]": ... -def mapped_column(*args, **kw) -> "Mapped": - """construct a new ORM-mapped :class:`_schema.Column` construct. +@overload +def mapped_column( + *args: SchemaEventTarget, + nullable: bool = ..., + primary_key: bool = ..., + deferred: bool = ..., + **kw: Any, +) -> "MappedColumn[Any]": + ... + + +def mapped_column(*args: Any, **kw: Any) -> "MappedColumn[Any]": + r"""construct a new ORM-mapped :class:`_schema.Column` construct. + + The :func:`_orm.mapped_column` function provides an ORM-aware and + Python-typing-compatible construct which is used with + :ref:`declarative <orm_declarative_mapping>` mappings to indicate an + attribute that's mapped to a Core :class:`_schema.Column` object. It + provides the equivalent feature as mapping an attribute to a + :class:`_schema.Column` object directly when using declarative. + + .. versionadded:: 2.0 - The :func:`_orm.mapped_column` function is shorthand for the construction - of a Core :class:`_schema.Column` object delivered within a - :func:`_orm.column_property` construct, which provides for consistent - typing information to be delivered to the class so that it works under - static type checkers such as mypy and delivers useful information in - IDE related type checkers such as pylance. The function can be used - in declarative mappings anywhere that :class:`_schema.Column` is normally - used:: + :func:`_orm.mapped_column` is normally used with explicit typing along with + the :class:`_orm.Mapped` mapped attribute type, where it can derive the SQL + type and nullability for the column automatically, such as:: + from typing import Optional + + from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column class User(Base): __tablename__ = 'user' - id = mapped_column(Integer) - name = mapped_column(String) + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column() + options: Mapped[Optional[str]] = mapped_column() + + In the above example, the ``int`` and ``str`` types are inferred by the + Declarative mapping system to indicate use of the :class:`_types.Integer` + and :class:`_types.String` datatypes, and the presence of ``Optional`` or + not indicates whether or not each non-primary-key column is to be + ``nullable=True`` or ``nullable=False``. + + The above example, when interpreted within a Declarative class, will result + in a table named ``"user"`` which is equivalent to the following:: + + from sqlalchemy import Integer + from sqlalchemy import String + from sqlalchemy import Table + + Table( + 'user', + Base.metadata, + Column("id", Integer, primary_key=True), + Column("name", String, nullable=False), + Column("options", String, nullable=True), + ) + The :func:`_orm.mapped_column` construct accepts the same arguments as + that of :class:`_schema.Column` directly, including optional "name" + and "type" fields, so the above mapping can be stated more explicitly + as:: - .. versionadded:: 2.0 + from typing import Optional + + from sqlalchemy import Integer + from sqlalchemy import String + from sqlalchemy.orm import Mapped + from sqlalchemy.orm import mapped_column + + class User(Base): + __tablename__ = 'user' + + id: Mapped[int] = mapped_column("id", Integer, primary_key=True) + name: Mapped[str] = mapped_column("name", String, nullable=False) + options: Mapped[Optional[str]] = mapped_column( + "name", String, nullable=True + ) + + Arguments passed to :func:`_orm.mapped_column` always supersede those which + would be derived from the type annotation and/or attribute name. To state + the above mapping with more specific datatypes for ``id`` and ``options``, + and a different column name for ``name``, looks like:: + + from sqlalchemy import BigInteger + + class User(Base): + __tablename__ = 'user' + + id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True) + name: Mapped[str] = mapped_column("user_name") + options: Mapped[Optional[str]] = mapped_column(String(50)) + + Where again, datatypes and nullable parameters that can be automatically + derived may be omitted. + + The datatypes passed to :class:`_orm.Mapped` are mapped to SQL + :class:`_types.TypeEngine` types with the following default mapping:: + + _type_map = { + int: Integer(), + float: Float(), + bool: Boolean(), + decimal.Decimal: Numeric(), + dt.date: Date(), + dt.datetime: DateTime(), + dt.time: Time(), + dt.timedelta: Interval(), + util.NoneType: NULLTYPE, + bytes: LargeBinary(), + str: String(), + } + + The above mapping may be expanded to include any combination of Python + datatypes to SQL types by using the + :paramref:`_orm.registry.type_annotation_map` parameter to + :class:`_orm.registry`, or as the attribute ``type_annotation_map`` upon + the :class:`_orm.DeclarativeBase` base class. + + Finally, :func:`_orm.mapped_column` is implicitly used by the Declarative + mapping system for any :class:`_orm.Mapped` annotation that has no + attribute value set up. This is much in the way that Python dataclasses + allow the ``field()`` construct to be optional, only needed when additional + parameters should be associated with the field. Using this functionality, + our original mapping can be stated even more succinctly as:: + + from typing import Optional + + from sqlalchemy.orm import Mapped + from sqlalchemy.orm import mapped_column + + class User(Base): + __tablename__ = 'user' + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + options: Mapped[Optional[str]] + + Above, the ``name`` and ``options`` columns will be evaluated as + ``Column("name", String, nullable=False)`` and + ``Column("options", String, nullable=True)``, respectively. + + :param __name: String name to give to the :class:`_schema.Column`. This + is an optional, positional only argument that if present must be the + first positional argument passed. If omitted, the attribute name to + which the :func:`_orm.mapped_column` is mapped will be used as the SQL + column name. + :param __type: :class:`_types.TypeEngine` type or instance which will + indicate the datatype to be associated with the :class:`_schema.Column`. + This is an optional, positional-only argument that if present must + immediately follow the ``__name`` parameter if present also, or otherwise + be the first positional parameter. If omitted, the ultimate type for + the column may be derived either from the annotated type, or if a + :class:`_schema.ForeignKey` is present, from the datatype of the + referenced column. + :param \*args: Additional positional arguments include constructs such + as :class:`_schema.ForeignKey`, :class:`_schema.CheckConstraint`, + and :class:`_schema.Identity`, which are passed through to the constructed + :class:`_schema.Column`. + :param nullable: Optional bool, whether the column should be "NULL" or + "NOT NULL". If omitted, the nullability is derived from the type + annotation based on whether or not ``typing.Optional`` is present. + ``nullable`` defaults to ``True`` otherwise for non-primary key columns, + and ``False`` or primary key columns. + :param primary_key: optional bool, indicates the :class:`_schema.Column` + would be part of the table's primary key or not. + :param deferred: Optional bool - this keyword argument is consumed by the + ORM declarative process, and is not part of the :class:`_schema.Column` + itself; instead, it indicates that this column should be "deferred" for + loading as though mapped by :func:`_orm.deferred`. + :param \**kw: All remaining keyword argments are passed through to the + constructor for the :class:`_schema.Column`. """ - return column_property(Column(*args, **kw)) + + return MappedColumn(*args, **kw) def column_property( column: sql.ColumnElement[_T], *additional_columns, **kwargs -) -> "Mapped[_T]": +) -> "ColumnProperty[_T]": r"""Provide a column-level property for use with a mapping. Column-based properties can normally be applied to the mapper's @@ -269,22 +453,49 @@ def column_property( return ColumnProperty(column, *additional_columns, **kwargs) -def composite(class_: Type[_T], *attrs, **kwargs) -> "Mapped[_T]": +@overload +def composite( + class_: Type[_T], + *attrs: Union[sql.ColumnElement[Any], MappedColumn, str, Mapped[Any]], + **kwargs: Any, +) -> "Composite[_T]": + ... + + +@overload +def composite( + *attrs: Union[sql.ColumnElement[Any], MappedColumn, str, Mapped[Any]], + **kwargs: Any, +) -> "Composite[Any]": + ... + + +def composite( + class_: Any = None, + *attrs: Union[sql.ColumnElement[Any], MappedColumn, str, Mapped[Any]], + **kwargs: Any, +) -> "Composite[Any]": r"""Return a composite column-based property for use with a Mapper. See the mapping documentation section :ref:`mapper_composite` for a full usage example. The :class:`.MapperProperty` returned by :func:`.composite` - is the :class:`.CompositeProperty`. + is the :class:`.Composite`. :param class\_: The "composite type" class, or any classmethod or callable which will produce a new instance of the composite object given the column values in order. - :param \*cols: - List of Column objects to be mapped. + :param \*attrs: + List of elements to be mapped, which may include: + + * :class:`_schema.Column` objects + * :func:`_orm.mapped_column` constructs + * string names of other attributes on the mapped class, which may be + any other SQL or object-mapped attribute. This can for + example allow a composite that refers to a many-to-one relationship :param active_history=False: When ``True``, indicates that the "previous" value for a @@ -301,7 +512,7 @@ def composite(class_: Type[_T], *attrs, **kwargs) -> "Mapped[_T]": :func:`~sqlalchemy.orm.deferred`. :param comparator_factory: a class which extends - :class:`.CompositeProperty.Comparator` which provides custom SQL + :class:`.Composite.Comparator` which provides custom SQL clause generation for comparison operations. :param doc: @@ -312,7 +523,7 @@ def composite(class_: Type[_T], *attrs, **kwargs) -> "Mapped[_T]": :attr:`.MapperProperty.info` attribute of this object. """ - return CompositeProperty(class_, *attrs, **kwargs) + return Composite(class_, *attrs, **kwargs) def with_loader_criteria( @@ -500,143 +711,140 @@ def with_loader_criteria( @overload def relationship( - argument: Union[str, Type[_RC], Callable[[], Type[_RC]]], + argument: Optional[_RelationshipArgumentType[_T]], + secondary=None, + *, + uselist: Literal[False] = None, + collection_class: Literal[None] = None, + primaryjoin=None, + secondaryjoin=None, + back_populates=None, + **kw: Any, +) -> Relationship[_T]: + ... + + +@overload +def relationship( + argument: Optional[_RelationshipArgumentType[_T]], secondary=None, *, uselist: Literal[True] = None, + collection_class: Literal[None] = None, primaryjoin=None, secondaryjoin=None, - foreign_keys=None, - order_by=False, - backref=None, back_populates=None, - overlaps=None, - post_update=False, - cascade=False, - viewonly=False, - lazy="select", - collection_class=None, - passive_deletes=RelationshipProperty._persistence_only["passive_deletes"], - passive_updates=RelationshipProperty._persistence_only["passive_updates"], - remote_side=None, - enable_typechecks=RelationshipProperty._persistence_only[ - "enable_typechecks" - ], - join_depth=None, - comparator_factory=None, - single_parent=False, - innerjoin=False, - distinct_target_key=None, - doc=None, - active_history=RelationshipProperty._persistence_only["active_history"], - cascade_backrefs=RelationshipProperty._persistence_only[ - "cascade_backrefs" - ], - load_on_pending=False, - bake_queries=True, - _local_remote_pairs=None, - query_class=None, - info=None, - omit_join=None, - sync_backref=None, - _legacy_inactive_history_style=False, -) -> Mapped[Collection[_RC]]: + **kw: Any, +) -> Relationship[List[_T]]: ... @overload def relationship( - argument: Union[str, Type[_RC], Callable[[], Type[_RC]]], + argument: Optional[_RelationshipArgumentType[_T]], secondary=None, *, - uselist: Optional[bool] = None, + uselist: Union[Literal[None], Literal[True]] = None, + collection_class: Type[List] = None, primaryjoin=None, secondaryjoin=None, - foreign_keys=None, - order_by=False, - backref=None, back_populates=None, - overlaps=None, - post_update=False, - cascade=False, - viewonly=False, - lazy="select", - collection_class=None, - passive_deletes=RelationshipProperty._persistence_only["passive_deletes"], - passive_updates=RelationshipProperty._persistence_only["passive_updates"], - remote_side=None, - enable_typechecks=RelationshipProperty._persistence_only[ - "enable_typechecks" - ], - join_depth=None, - comparator_factory=None, - single_parent=False, - innerjoin=False, - distinct_target_key=None, - doc=None, - active_history=RelationshipProperty._persistence_only["active_history"], - cascade_backrefs=RelationshipProperty._persistence_only[ - "cascade_backrefs" - ], - load_on_pending=False, - bake_queries=True, - _local_remote_pairs=None, - query_class=None, - info=None, - omit_join=None, - sync_backref=None, - _legacy_inactive_history_style=False, -) -> Mapped[_RC]: + **kw: Any, +) -> Relationship[List[_T]]: ... +@overload def relationship( - argument: Union[str, Type[_RC], Callable[[], Type[_RC]]], + argument: Optional[_RelationshipArgumentType[_T]], secondary=None, *, + uselist: Union[Literal[None], Literal[True]] = None, + collection_class: Type[Set] = None, primaryjoin=None, secondaryjoin=None, - foreign_keys=None, + back_populates=None, + **kw: Any, +) -> Relationship[Set[_T]]: + ... + + +@overload +def relationship( + argument: Optional[_RelationshipArgumentType[_T]], + secondary=None, + *, + uselist: Union[Literal[None], Literal[True]] = None, + collection_class: Type[Mapping[Any, Any]] = None, + primaryjoin=None, + secondaryjoin=None, + back_populates=None, + **kw: Any, +) -> Relationship[Mapping[Any, _T]]: + ... + + +@overload +def relationship( + argument: _RelationshipArgumentType[_T], + secondary=None, + *, + uselist: Literal[None] = None, + collection_class: Literal[None] = None, + primaryjoin=None, + secondaryjoin=None, + back_populates=None, + **kw: Any, +) -> Relationship[Any]: + ... + + +@overload +def relationship( + argument: Optional[_RelationshipArgumentType[_T]] = None, + secondary=None, + *, + uselist: Literal[True] = None, + collection_class: Any = None, + primaryjoin=None, + secondaryjoin=None, + back_populates=None, + **kw: Any, +) -> Relationship[Any]: + ... + + +@overload +def relationship( + argument: Literal[None] = None, + secondary=None, + *, uselist: Optional[bool] = None, - order_by=False, - backref=None, + collection_class: Any = None, + primaryjoin=None, + secondaryjoin=None, back_populates=None, - overlaps=None, - post_update=False, - cascade=False, - viewonly=False, - lazy="select", - collection_class=None, - passive_deletes=RelationshipProperty._persistence_only["passive_deletes"], - passive_updates=RelationshipProperty._persistence_only["passive_updates"], - remote_side=None, - enable_typechecks=RelationshipProperty._persistence_only[ - "enable_typechecks" - ], - join_depth=None, - comparator_factory=None, - single_parent=False, - innerjoin=False, - distinct_target_key=None, - doc=None, - active_history=RelationshipProperty._persistence_only["active_history"], - cascade_backrefs=RelationshipProperty._persistence_only[ - "cascade_backrefs" - ], - load_on_pending=False, - bake_queries=True, - _local_remote_pairs=None, - query_class=None, - info=None, - omit_join=None, - sync_backref=None, - _legacy_inactive_history_style=False, -) -> Mapped[_RC]: + **kw: Any, +) -> Relationship[Any]: + ... + + +def relationship( + argument: Optional[_RelationshipArgumentType[_T]] = None, + secondary=None, + *, + uselist: Optional[bool] = None, + collection_class: Optional[Type[Collection]] = None, + primaryjoin=None, + secondaryjoin=None, + back_populates=None, + **kw: Any, +) -> Relationship[Any]: """Provide a relationship between two mapped classes. This corresponds to a parent-child or associative table relationship. The constructed class is an instance of - :class:`.RelationshipProperty`. + :class:`.Relationship`. A typical :func:`_orm.relationship`, used in a classical mapping:: @@ -897,7 +1105,7 @@ def relationship( examples. :param comparator_factory: - A class which extends :class:`.RelationshipProperty.Comparator` + A class which extends :class:`.Relationship.Comparator` which provides custom SQL clause generation for comparison operations. @@ -1447,42 +1655,15 @@ def relationship( """ - return RelationshipProperty( + return Relationship( argument, - secondary, - primaryjoin, - secondaryjoin, - foreign_keys, - uselist, - order_by, - backref, - back_populates, - overlaps, - post_update, - cascade, - viewonly, - lazy, - collection_class, - passive_deletes, - passive_updates, - remote_side, - enable_typechecks, - join_depth, - comparator_factory, - single_parent, - innerjoin, - distinct_target_key, - doc, - active_history, - cascade_backrefs, - load_on_pending, - bake_queries, - _local_remote_pairs, - query_class, - info, - omit_join, - sync_backref, - _legacy_inactive_history_style, + secondary=secondary, + uselist=uselist, + collection_class=collection_class, + primaryjoin=primaryjoin, + secondaryjoin=secondaryjoin, + back_populates=back_populates, + **kw, ) @@ -1493,7 +1674,7 @@ def synonym( comparator_factory=None, doc=None, info=None, -) -> "Mapped": +) -> "Synonym[Any]": """Denote an attribute name as a synonym to a mapped property, in that the attribute will mirror the value and expression behavior of another attribute. @@ -1597,9 +1778,7 @@ def synonym( than can be achieved with synonyms. """ - return SynonymProperty( - name, map_column, descriptor, comparator_factory, doc, info - ) + return Synonym(name, map_column, descriptor, comparator_factory, doc, info) def create_session(bind=None, **kwargs): @@ -1733,7 +1912,9 @@ def deferred(*columns, **kw): return ColumnProperty(deferred=True, *columns, **kw) -def query_expression(default_expr=sql.null()): +def query_expression( + default_expr: sql.ColumnElement[_T] = sql.null(), +) -> "Mapped[_T]": """Indicate an attribute that populates from a query-time SQL expression. :param default_expr: Optional SQL expression object that will be used in @@ -1787,3 +1968,273 @@ def clear_mappers(): """ mapperlib._dispose_registries(mapperlib._all_registries(), False) + + +@overload +def aliased( + element: Union[Type[_T], "Mapper[_T]", "AliasedClass[_T]"], + alias=None, + name=None, + flat=False, + adapt_on_names=False, +) -> "AliasedClass[_T]": + ... + + +@overload +def aliased( + element: "FromClause", + alias=None, + name=None, + flat=False, + adapt_on_names=False, +) -> "Alias": + ... + + +def aliased( + element: Union[Type[_T], "Mapper[_T]", "FromClause", "AliasedClass[_T]"], + alias=None, + name=None, + flat=False, + adapt_on_names=False, +) -> Union["AliasedClass[_T]", "Alias"]: + """Produce an alias of the given element, usually an :class:`.AliasedClass` + instance. + + E.g.:: + + my_alias = aliased(MyClass) + + session.query(MyClass, my_alias).filter(MyClass.id > my_alias.id) + + The :func:`.aliased` function is used to create an ad-hoc mapping of a + mapped class to a new selectable. By default, a selectable is generated + from the normally mapped selectable (typically a :class:`_schema.Table` + ) using the + :meth:`_expression.FromClause.alias` method. However, :func:`.aliased` + can also be + used to link the class to a new :func:`_expression.select` statement. + Also, the :func:`.with_polymorphic` function is a variant of + :func:`.aliased` that is intended to specify a so-called "polymorphic + selectable", that corresponds to the union of several joined-inheritance + subclasses at once. + + For convenience, the :func:`.aliased` function also accepts plain + :class:`_expression.FromClause` constructs, such as a + :class:`_schema.Table` or + :func:`_expression.select` construct. In those cases, the + :meth:`_expression.FromClause.alias` + method is called on the object and the new + :class:`_expression.Alias` object returned. The returned + :class:`_expression.Alias` is not + ORM-mapped in this case. + + .. seealso:: + + :ref:`tutorial_orm_entity_aliases` - in the :ref:`unified_tutorial` + + :ref:`orm_queryguide_orm_aliases` - in the :ref:`queryguide_toplevel` + + :ref:`ormtutorial_aliases` - in the legacy :ref:`ormtutorial_toplevel` + + :param element: element to be aliased. Is normally a mapped class, + but for convenience can also be a :class:`_expression.FromClause` + element. + + :param alias: Optional selectable unit to map the element to. This is + usually used to link the object to a subquery, and should be an aliased + select construct as one would produce from the + :meth:`_query.Query.subquery` method or + the :meth:`_expression.Select.subquery` or + :meth:`_expression.Select.alias` methods of the :func:`_expression.select` + construct. + + :param name: optional string name to use for the alias, if not specified + by the ``alias`` parameter. The name, among other things, forms the + attribute name that will be accessible via tuples returned by a + :class:`_query.Query` object. Not supported when creating aliases + of :class:`_sql.Join` objects. + + :param flat: Boolean, will be passed through to the + :meth:`_expression.FromClause.alias` call so that aliases of + :class:`_expression.Join` objects will alias the individual tables + inside the join, rather than creating a subquery. This is generally + supported by all modern databases with regards to right-nested joins + and generally produces more efficient queries. + + :param adapt_on_names: if True, more liberal "matching" will be used when + mapping the mapped columns of the ORM entity to those of the + given selectable - a name-based match will be performed if the + given selectable doesn't otherwise have a column that corresponds + to one on the entity. The use case for this is when associating + an entity with some derived selectable such as one that uses + aggregate functions:: + + class UnitPrice(Base): + __tablename__ = 'unit_price' + ... + unit_id = Column(Integer) + price = Column(Numeric) + + aggregated_unit_price = Session.query( + func.sum(UnitPrice.price).label('price') + ).group_by(UnitPrice.unit_id).subquery() + + aggregated_unit_price = aliased(UnitPrice, + alias=aggregated_unit_price, adapt_on_names=True) + + Above, functions on ``aggregated_unit_price`` which refer to + ``.price`` will return the + ``func.sum(UnitPrice.price).label('price')`` column, as it is + matched on the name "price". Ordinarily, the "price" function + wouldn't have any "column correspondence" to the actual + ``UnitPrice.price`` column as it is not a proxy of the original. + + """ + return AliasedInsp._alias_factory( + element, + alias=alias, + name=name, + flat=flat, + adapt_on_names=adapt_on_names, + ) + + +def with_polymorphic( + base, + classes, + selectable=False, + flat=False, + polymorphic_on=None, + aliased=False, + innerjoin=False, + _use_mapper_path=False, +): + """Produce an :class:`.AliasedClass` construct which specifies + columns for descendant mappers of the given base. + + Using this method will ensure that each descendant mapper's + tables are included in the FROM clause, and will allow filter() + criterion to be used against those tables. The resulting + instances will also have those columns already loaded so that + no "post fetch" of those columns will be required. + + .. seealso:: + + :ref:`with_polymorphic` - full discussion of + :func:`_orm.with_polymorphic`. + + :param base: Base class to be aliased. + + :param classes: a single class or mapper, or list of + class/mappers, which inherit from the base class. + Alternatively, it may also be the string ``'*'``, in which case + all descending mapped classes will be added to the FROM clause. + + :param aliased: when True, the selectable will be aliased. For a + JOIN, this means the JOIN will be SELECTed from inside of a subquery + unless the :paramref:`_orm.with_polymorphic.flat` flag is set to + True, which is recommended for simpler use cases. + + :param flat: Boolean, will be passed through to the + :meth:`_expression.FromClause.alias` call so that aliases of + :class:`_expression.Join` objects will alias the individual tables + inside the join, rather than creating a subquery. This is generally + supported by all modern databases with regards to right-nested joins + and generally produces more efficient queries. Setting this flag is + recommended as long as the resulting SQL is functional. + + :param selectable: a table or subquery that will + be used in place of the generated FROM clause. This argument is + required if any of the desired classes use concrete table + inheritance, since SQLAlchemy currently cannot generate UNIONs + among tables automatically. If used, the ``selectable`` argument + must represent the full set of tables and columns mapped by every + mapped class. Otherwise, the unaccounted mapped columns will + result in their table being appended directly to the FROM clause + which will usually lead to incorrect results. + + When left at its default value of ``False``, the polymorphic + selectable assigned to the base mapper is used for selecting rows. + However, it may also be passed as ``None``, which will bypass the + configured polymorphic selectable and instead construct an ad-hoc + selectable for the target classes given; for joined table inheritance + this will be a join that includes all target mappers and their + subclasses. + + :param polymorphic_on: a column to be used as the "discriminator" + column for the given selectable. If not given, the polymorphic_on + attribute of the base classes' mapper will be used, if any. This + is useful for mappings that don't have polymorphic loading + behavior by default. + + :param innerjoin: if True, an INNER JOIN will be used. This should + only be specified if querying for one specific subtype only + """ + return AliasedInsp._with_polymorphic_factory( + base, + classes, + selectable=selectable, + flat=flat, + polymorphic_on=polymorphic_on, + aliased=aliased, + innerjoin=innerjoin, + _use_mapper_path=_use_mapper_path, + ) + + +def join( + left, right, onclause=None, isouter=False, full=False, join_to_left=None +): + r"""Produce an inner join between left and right clauses. + + :func:`_orm.join` is an extension to the core join interface + provided by :func:`_expression.join()`, where the + left and right selectables may be not only core selectable + objects such as :class:`_schema.Table`, but also mapped classes or + :class:`.AliasedClass` instances. The "on" clause can + be a SQL expression, or an attribute or string name + referencing a configured :func:`_orm.relationship`. + + :func:`_orm.join` is not commonly needed in modern usage, + as its functionality is encapsulated within that of the + :meth:`_query.Query.join` method, which features a + significant amount of automation beyond :func:`_orm.join` + by itself. Explicit usage of :func:`_orm.join` + with :class:`_query.Query` involves usage of the + :meth:`_query.Query.select_from` method, as in:: + + from sqlalchemy.orm import join + session.query(User).\ + select_from(join(User, Address, User.addresses)).\ + filter(Address.email_address=='foo@bar.com') + + In modern SQLAlchemy the above join can be written more + succinctly as:: + + session.query(User).\ + join(User.addresses).\ + filter(Address.email_address=='foo@bar.com') + + See :meth:`_query.Query.join` for information on modern usage + of ORM level joins. + + .. deprecated:: 0.8 + + the ``join_to_left`` parameter is deprecated, and will be removed + in a future release. The parameter has no effect. + + """ + return _ORMJoin(left, right, onclause, isouter, full) + + +def outerjoin(left, right, onclause=None, full=False, join_to_left=None): + """Produce a left outer join between left and right clauses. + + This is the "outer join" version of the :func:`_orm.join` function, + featuring the same behavior except that an OUTER JOIN is generated. + See that function's documentation for other usage details. + + """ + return _ORMJoin(left, right, onclause, True, full) diff --git a/lib/sqlalchemy/orm/attributes.py b/lib/sqlalchemy/orm/attributes.py index 5a605b7c6..fbfb2b2ee 100644 --- a/lib/sqlalchemy/orm/attributes.py +++ b/lib/sqlalchemy/orm/attributes.py @@ -35,6 +35,7 @@ from .base import instance_state from .base import instance_str from .base import LOAD_AGAINST_COMMITTED from .base import manager_of_class +from .base import Mapped as Mapped # noqa from .base import NEVER_SET # noqa from .base import NO_AUTOFLUSH from .base import NO_CHANGE # noqa @@ -79,6 +80,7 @@ class QueryableAttribute( traversals.HasCopyInternals, roles.JoinTargetRole, roles.OnClauseRole, + roles.ColumnsClauseRole, sql_base.Immutable, sql_base.MemoizedHasCacheKey, ): @@ -190,7 +192,7 @@ class QueryableAttribute( construct has defined one). * If the attribute refers to any other kind of - :class:`.MapperProperty`, including :class:`.RelationshipProperty`, + :class:`.MapperProperty`, including :class:`.Relationship`, the attribute will refer to the :attr:`.MapperProperty.info` dictionary associated with that :class:`.MapperProperty`. @@ -352,7 +354,7 @@ class QueryableAttribute( Return values here will commonly be instances of - :class:`.ColumnProperty` or :class:`.RelationshipProperty`. + :class:`.ColumnProperty` or :class:`.Relationship`. """ diff --git a/lib/sqlalchemy/orm/base.py b/lib/sqlalchemy/orm/base.py index 7ab4b7737..e6d4a6729 100644 --- a/lib/sqlalchemy/orm/base.py +++ b/lib/sqlalchemy/orm/base.py @@ -12,8 +12,11 @@ import operator import typing from typing import Any +from typing import Callable from typing import Generic +from typing import Optional from typing import overload +from typing import Tuple from typing import TypeVar from typing import Union @@ -22,8 +25,9 @@ from .. import exc as sa_exc from .. import inspection from .. import util from ..sql.elements import SQLCoreOperations -from ..util import typing as compat_typing from ..util.langhelpers import TypingOnly +from ..util.typing import Concatenate +from ..util.typing import ParamSpec if typing.TYPE_CHECKING: @@ -32,6 +36,9 @@ if typing.TYPE_CHECKING: _T = TypeVar("_T", bound=Any) +_IdentityKeyType = Tuple[type, Tuple[Any, ...], Optional[str]] + + PASSIVE_NO_RESULT = util.symbol( "PASSIVE_NO_RESULT", """Symbol returned by a loader callable or other attribute/history @@ -236,16 +243,16 @@ _DEFER_FOR_STATE = util.symbol("DEFER_FOR_STATE") _RAISE_FOR_STATE = util.symbol("RAISE_FOR_STATE") -_Fn = typing.TypeVar("_Fn", bound=typing.Callable) -_Args = compat_typing.ParamSpec("_Args") -_Self = typing.TypeVar("_Self") +_Fn = TypeVar("_Fn", bound=Callable) +_Args = ParamSpec("_Args") +_Self = TypeVar("_Self") def _assertions( - *assertions, -) -> typing.Callable[ - [typing.Callable[compat_typing.Concatenate[_Fn, _Args], _Self]], - typing.Callable[compat_typing.Concatenate[_Fn, _Args], _Self], + *assertions: Any, +) -> Callable[ + [Callable[Concatenate[_Self, _Fn, _Args], _Self]], + Callable[Concatenate[_Self, _Fn, _Args], _Self], ]: @util.decorator def generate( @@ -605,8 +612,8 @@ class SQLORMOperations(SQLCoreOperations[_T], TypingOnly): ... -class Mapped(Generic[_T], util.TypingOnly): - """Represent an ORM mapped attribute for typing purposes. +class Mapped(Generic[_T], TypingOnly): + """Represent an ORM mapped attribute on a mapped class. This class represents the complete descriptor interface for any class attribute that will have been :term:`instrumented` by the ORM @@ -650,7 +657,7 @@ class Mapped(Generic[_T], util.TypingOnly): ... @classmethod - def _empty_constructor(cls, arg1: Any) -> "SQLORMOperations[_T]": + def _empty_constructor(cls, arg1: Any) -> "Mapped[_T]": ... @overload diff --git a/lib/sqlalchemy/orm/clsregistry.py b/lib/sqlalchemy/orm/clsregistry.py index ac6b0fd4c..037b70257 100644 --- a/lib/sqlalchemy/orm/clsregistry.py +++ b/lib/sqlalchemy/orm/clsregistry.py @@ -10,11 +10,14 @@ This system allows specification of classes and expressions used in :func:`_orm.relationship` using strings. """ +import re +from typing import MutableMapping +from typing import Union import weakref from . import attributes from . import interfaces -from .descriptor_props import SynonymProperty +from .descriptor_props import Synonym from .properties import ColumnProperty from .util import class_mapper from .. import exc @@ -22,6 +25,8 @@ from .. import inspection from .. import util from ..sql.schema import _get_table_key +_ClsRegistryType = MutableMapping[str, Union[type, "ClsRegistryToken"]] + # strong references to registries which we place in # the _decl_class_registry, which is usually weak referencing. # the internal registries here link to classes with weakrefs and remove @@ -118,7 +123,13 @@ def _key_is_empty(key, decl_class_registry, test): return not test(thing) -class _MultipleClassMarker: +class ClsRegistryToken: + """an object that can be in the registry._class_registry as a value.""" + + __slots__ = () + + +class _MultipleClassMarker(ClsRegistryToken): """refers to multiple classes of the same name within _decl_class_registry. @@ -182,7 +193,7 @@ class _MultipleClassMarker: self.contents.add(weakref.ref(item, self._remove_item)) -class _ModuleMarker: +class _ModuleMarker(ClsRegistryToken): """Refers to a module name within _decl_class_registry. @@ -281,7 +292,7 @@ class _GetColumns: desc = mp.all_orm_descriptors[key] if desc.extension_type is interfaces.NOT_EXTENSION: prop = desc.property - if isinstance(prop, SynonymProperty): + if isinstance(prop, Synonym): key = prop.name elif not isinstance(prop, ColumnProperty): raise exc.InvalidRequestError( @@ -372,13 +383,26 @@ class _class_resolver: return self.fallback[key] def _raise_for_name(self, name, err): - raise exc.InvalidRequestError( - "When initializing mapper %s, expression %r failed to " - "locate a name (%r). If this is a class name, consider " - "adding this relationship() to the %r class after " - "both dependent classes have been defined." - % (self.prop.parent, self.arg, name, self.cls) - ) from err + generic_match = re.match(r"(.+)\[(.+)\]", name) + + if generic_match: + raise exc.InvalidRequestError( + f"When initializing mapper {self.prop.parent}, " + f'expression "relationship({self.arg!r})" seems to be ' + "using a generic class as the argument to relationship(); " + "please state the generic argument " + "using an annotation, e.g. " + f'"{self.prop.key}: Mapped[{generic_match.group(1)}' + f'[{generic_match.group(2)}]] = relationship()"' + ) from err + else: + raise exc.InvalidRequestError( + "When initializing mapper %s, expression %r failed to " + "locate a name (%r). If this is a class name, consider " + "adding this relationship() to the %r class after " + "both dependent classes have been defined." + % (self.prop.parent, self.arg, name, self.cls) + ) from err def _resolve_name(self): name = self.arg diff --git a/lib/sqlalchemy/orm/collections.py b/lib/sqlalchemy/orm/collections.py index 75ce8216f..ba4225563 100644 --- a/lib/sqlalchemy/orm/collections.py +++ b/lib/sqlalchemy/orm/collections.py @@ -102,18 +102,20 @@ The owning object and :class:`.CollectionAttributeImpl` are also reachable through the adapter, allowing for some very sophisticated behavior. """ - import operator import threading +import typing import weakref -from sqlalchemy.util.compat import inspect_getfullargspec -from . import base from .. import exc as sa_exc from .. import util -from ..sql import coercions -from ..sql import expression -from ..sql import roles +from ..util.compat import inspect_getfullargspec + +if typing.TYPE_CHECKING: + from .mapped_collection import attribute_mapped_collection + from .mapped_collection import column_mapped_collection + from .mapped_collection import mapped_collection + from .mapped_collection import MappedCollection # noqa: F401 __all__ = [ "collection", @@ -126,180 +128,6 @@ __all__ = [ __instrumentation_mutex = threading.Lock() -class _PlainColumnGetter: - """Plain column getter, stores collection of Column objects - directly. - - Serializes to a :class:`._SerializableColumnGetterV2` - which has more expensive __call__() performance - and some rare caveats. - - """ - - def __init__(self, cols): - self.cols = cols - self.composite = len(cols) > 1 - - def __reduce__(self): - return _SerializableColumnGetterV2._reduce_from_cols(self.cols) - - def _cols(self, mapper): - return self.cols - - def __call__(self, value): - state = base.instance_state(value) - m = base._state_mapper(state) - - key = [ - m._get_state_attr_by_column(state, state.dict, col) - for col in self._cols(m) - ] - - if self.composite: - return tuple(key) - else: - return key[0] - - -class _SerializableColumnGetter: - """Column-based getter used in version 0.7.6 only. - - Remains here for pickle compatibility with 0.7.6. - - """ - - def __init__(self, colkeys): - self.colkeys = colkeys - self.composite = len(colkeys) > 1 - - def __reduce__(self): - return _SerializableColumnGetter, (self.colkeys,) - - def __call__(self, value): - state = base.instance_state(value) - m = base._state_mapper(state) - key = [ - m._get_state_attr_by_column( - state, state.dict, m.mapped_table.columns[k] - ) - for k in self.colkeys - ] - if self.composite: - return tuple(key) - else: - return key[0] - - -class _SerializableColumnGetterV2(_PlainColumnGetter): - """Updated serializable getter which deals with - multi-table mapped classes. - - Two extremely unusual cases are not supported. - Mappings which have tables across multiple metadata - objects, or which are mapped to non-Table selectables - linked across inheriting mappers may fail to function - here. - - """ - - def __init__(self, colkeys): - self.colkeys = colkeys - self.composite = len(colkeys) > 1 - - def __reduce__(self): - return self.__class__, (self.colkeys,) - - @classmethod - def _reduce_from_cols(cls, cols): - def _table_key(c): - if not isinstance(c.table, expression.TableClause): - return None - else: - return c.table.key - - colkeys = [(c.key, _table_key(c)) for c in cols] - return _SerializableColumnGetterV2, (colkeys,) - - def _cols(self, mapper): - cols = [] - metadata = getattr(mapper.local_table, "metadata", None) - for (ckey, tkey) in self.colkeys: - if tkey is None or metadata is None or tkey not in metadata: - cols.append(mapper.local_table.c[ckey]) - else: - cols.append(metadata.tables[tkey].c[ckey]) - return cols - - -def column_mapped_collection(mapping_spec): - """A dictionary-based collection type with column-based keying. - - Returns a :class:`.MappedCollection` factory with a keying function - generated from mapping_spec, which may be a Column or a sequence - of Columns. - - The key value must be immutable for the lifetime of the object. You - can not, for example, map on foreign key values if those key values will - change during the session, i.e. from None to a database-assigned integer - after a session flush. - - """ - cols = [ - coercions.expect(roles.ColumnArgumentRole, q, argname="mapping_spec") - for q in util.to_list(mapping_spec) - ] - keyfunc = _PlainColumnGetter(cols) - return lambda: MappedCollection(keyfunc) - - -class _SerializableAttrGetter: - def __init__(self, name): - self.name = name - self.getter = operator.attrgetter(name) - - def __call__(self, target): - return self.getter(target) - - def __reduce__(self): - return _SerializableAttrGetter, (self.name,) - - -def attribute_mapped_collection(attr_name): - """A dictionary-based collection type with attribute-based keying. - - Returns a :class:`.MappedCollection` factory with a keying based on the - 'attr_name' attribute of entities in the collection, where ``attr_name`` - is the string name of the attribute. - - .. warning:: the key value must be assigned to its final value - **before** it is accessed by the attribute mapped collection. - Additionally, changes to the key attribute are **not tracked** - automatically, which means the key in the dictionary is not - automatically synchronized with the key value on the target object - itself. See the section :ref:`key_collections_mutations` - for an example. - - """ - getter = _SerializableAttrGetter(attr_name) - return lambda: MappedCollection(getter) - - -def mapped_collection(keyfunc): - """A dictionary-based collection type with arbitrary keying. - - Returns a :class:`.MappedCollection` factory with a keying function - generated from keyfunc, a callable that takes an entity and returns a - key value. - - The key value must be immutable for the lifetime of the object. You - can not, for example, map on foreign key values if those key values will - change during the session, i.e. from None to a database-assigned integer - after a session flush. - - """ - return lambda: MappedCollection(keyfunc) - - class collection: """Decorators for entity collection classes. @@ -1620,63 +1448,24 @@ __interfaces = { } -class MappedCollection(dict): - """A basic dictionary-based collection class. - - Extends dict with the minimal bag semantics that collection - classes require. ``set`` and ``remove`` are implemented in terms - of a keying function: any callable that takes an object and - returns an object for use as a dictionary key. - - """ - - def __init__(self, keyfunc): - """Create a new collection with keying provided by keyfunc. +def __go(lcls): - keyfunc may be any callable that takes an object and returns an object - for use as a dictionary key. + global mapped_collection, column_mapped_collection + global attribute_mapped_collection, MappedCollection - The keyfunc will be called every time the ORM needs to add a member by - value-only (such as when loading instances from the database) or - remove a member. The usual cautions about dictionary keying apply- - ``keyfunc(object)`` should return the same output for the life of the - collection. Keying based on mutable properties can result in - unreachable instances "lost" in the collection. + from .mapped_collection import mapped_collection + from .mapped_collection import column_mapped_collection + from .mapped_collection import attribute_mapped_collection + from .mapped_collection import MappedCollection - """ - self.keyfunc = keyfunc - - @collection.appender - @collection.internally_instrumented - def set(self, value, _sa_initiator=None): - """Add an item by value, consulting the keyfunc for the key.""" - - key = self.keyfunc(value) - self.__setitem__(key, value, _sa_initiator) - - @collection.remover - @collection.internally_instrumented - def remove(self, value, _sa_initiator=None): - """Remove an item by value, consulting the keyfunc for the key.""" - - key = self.keyfunc(value) - # Let self[key] raise if key is not in this collection - # testlib.pragma exempt:__ne__ - if self[key] != value: - raise sa_exc.InvalidRequestError( - "Can not remove '%s': collection holds '%s' for key '%s'. " - "Possible cause: is the MappedCollection key function " - "based on mutable properties or properties that only obtain " - "values after flush?" % (value, self[key], key) - ) - self.__delitem__(key, _sa_initiator) + # ensure instrumentation is associated with + # these built-in classes; if a user-defined class + # subclasses these and uses @internally_instrumented, + # the superclass is otherwise not instrumented. + # see [ticket:2406]. + _instrument_class(InstrumentedList) + _instrument_class(InstrumentedSet) + _instrument_class(MappedCollection) -# ensure instrumentation is associated with -# these built-in classes; if a user-defined class -# subclasses these and uses @internally_instrumented, -# the superclass is otherwise not instrumented. -# see [ticket:2406]. -_instrument_class(MappedCollection) -_instrument_class(InstrumentedList) -_instrument_class(InstrumentedSet) +__go(locals()) diff --git a/lib/sqlalchemy/orm/context.py b/lib/sqlalchemy/orm/context.py index 8e9cf66e2..34f291864 100644 --- a/lib/sqlalchemy/orm/context.py +++ b/lib/sqlalchemy/orm/context.py @@ -5,16 +5,18 @@ # This module is part of SQLAlchemy and is released under # the MIT License: https://www.opensource.org/licenses/mit-license.php import itertools +from typing import List from . import attributes from . import interfaces from . import loading from .base import _is_aliased_class +from .interfaces import ORMColumnDescription from .interfaces import ORMColumnsClauseRole from .path_registry import PathRegistry from .util import _entity_corresponds_to from .util import _ORMJoin -from .util import aliased +from .util import AliasedClass from .util import Bundle from .util import ORMAdapter from .. import exc as sa_exc @@ -1570,7 +1572,7 @@ class ORMSelectCompileState(ORMCompileState, SelectState): # when we are here, it means join() was called with an indicator # as to an exact left side, which means a path to a - # RelationshipProperty was given, e.g.: + # Relationship was given, e.g.: # # join(RightEntity, LeftEntity.right) # @@ -1725,7 +1727,7 @@ class ORMSelectCompileState(ORMCompileState, SelectState): need_adapter = True # make the right hand side target into an ORM entity - right = aliased(right_mapper, right_selectable) + right = AliasedClass(right_mapper, right_selectable) util.warn_deprecated( "An alias is being generated automatically against " @@ -1750,7 +1752,7 @@ class ORMSelectCompileState(ORMCompileState, SelectState): # test/orm/inheritance/test_relationships.py. There are also # general overlap cases with many-to-many tables where automatic # aliasing is desirable. - right = aliased(right, flat=True) + right = AliasedClass(right, flat=True) need_adapter = True util.warn( @@ -1910,7 +1912,7 @@ class ORMSelectCompileState(ORMCompileState, SelectState): def _column_descriptions( query_or_select_stmt, compile_state=None, legacy=False -): +) -> List[ORMColumnDescription]: if compile_state is None: compile_state = ORMSelectCompileState._create_entities_collection( query_or_select_stmt, legacy=legacy diff --git a/lib/sqlalchemy/orm/decl_api.py b/lib/sqlalchemy/orm/decl_api.py index 59fabb9b6..5ac9966dd 100644 --- a/lib/sqlalchemy/orm/decl_api.py +++ b/lib/sqlalchemy/orm/decl_api.py @@ -11,7 +11,9 @@ import typing from typing import Any from typing import Callable from typing import ClassVar +from typing import Mapping from typing import Optional +from typing import Type from typing import TypeVar from typing import Union import weakref @@ -31,7 +33,7 @@ from .decl_base import _declarative_constructor from .decl_base import _DeferredMapperConfig from .decl_base import _del_attribute from .decl_base import _mapper -from .descriptor_props import SynonymProperty as _orm_synonym +from .descriptor_props import Synonym as _orm_synonym from .mapper import Mapper from .. import exc from .. import inspection @@ -39,14 +41,18 @@ from .. import util from ..sql.elements import SQLCoreOperations from ..sql.schema import MetaData from ..sql.selectable import FromClause +from ..sql.type_api import TypeEngine from ..util import hybridmethod from ..util import hybridproperty +from ..util import typing as compat_typing if typing.TYPE_CHECKING: from .state import InstanceState # noqa _T = TypeVar("_T", bound=Any) +_TypeAnnotationMapType = Mapping[Type, Union[Type[TypeEngine], TypeEngine]] + def has_inherited_table(cls): """Given a class, return True if any of the classes it inherits from has a @@ -67,8 +73,22 @@ def has_inherited_table(cls): return False +class _DynamicAttributesType(type): + def __setattr__(cls, key, value): + if "__mapper__" in cls.__dict__: + _add_attribute(cls, key, value) + else: + type.__setattr__(cls, key, value) + + def __delattr__(cls, key): + if "__mapper__" in cls.__dict__: + _del_attribute(cls, key) + else: + type.__delattr__(cls, key) + + class DeclarativeAttributeIntercept( - type, inspection.Inspectable["Mapper[Any]"] + _DynamicAttributesType, inspection.Inspectable["Mapper[Any]"] ): """Metaclass that may be used in conjunction with the :class:`_orm.DeclarativeBase` class to support addition of class @@ -76,15 +96,16 @@ class DeclarativeAttributeIntercept( """ - def __setattr__(cls, key, value): - _add_attribute(cls, key, value) - - def __delattr__(cls, key): - _del_attribute(cls, key) +class DeclarativeMeta( + _DynamicAttributesType, inspection.Inspectable["Mapper[Any]"] +): + metadata: MetaData + registry: "RegistryType" -class DeclarativeMeta(type, inspection.Inspectable["Mapper[Any]"]): - def __init__(cls, classname, bases, dict_, **kw): + def __init__( + cls, classname: Any, bases: Any, dict_: Any, **kw: Any + ) -> None: # early-consume registry from the initial declarative base, # assign privately to not conflict with subclass attributes named # "registry" @@ -103,12 +124,6 @@ class DeclarativeMeta(type, inspection.Inspectable["Mapper[Any]"]): _as_declarative(reg, cls, dict_) type.__init__(cls, classname, bases, dict_) - def __setattr__(cls, key, value): - _add_attribute(cls, key, value) - - def __delattr__(cls, key): - _del_attribute(cls, key) - def synonym_for(name, map_column=False): """Decorator that produces an :func:`_orm.synonym` @@ -250,6 +265,9 @@ class declared_attr(interfaces._MappedAttribute[_T]): self._cascading = cascading self.__doc__ = fn.__doc__ + def _collect_return_annotation(self) -> Optional[Type[Any]]: + return util.get_annotations(self.fget).get("return") + def __get__(self, instance, owner) -> InstrumentedAttribute[_T]: # the declared_attr needs to make use of a cache that exists # for the span of the declarative scan_attributes() phase. @@ -409,6 +427,11 @@ def _setup_declarative_base(cls): else: metadata = None + if "type_annotation_map" in cls.__dict__: + type_annotation_map = cls.__dict__["type_annotation_map"] + else: + type_annotation_map = None + reg = cls.__dict__.get("registry", None) if reg is not None: if not isinstance(reg, registry): @@ -416,8 +439,18 @@ def _setup_declarative_base(cls): "Declarative base class has a 'registry' attribute that is " "not an instance of sqlalchemy.orm.registry()" ) + elif type_annotation_map is not None: + raise exc.InvalidRequestError( + "Declarative base class has both a 'registry' attribute and a " + "type_annotation_map entry. Per-base type_annotation_maps " + "are not supported. Please apply the type_annotation_map " + "to this registry directly." + ) + else: - reg = registry(metadata=metadata) + reg = registry( + metadata=metadata, type_annotation_map=type_annotation_map + ) cls.registry = reg cls._sa_registry = reg @@ -476,6 +509,44 @@ class DeclarativeBase( mappings. The superclass makes use of the ``__init_subclass__()`` method to set up new classes and metaclasses aren't used. + When first used, the :class:`_orm.DeclarativeBase` class instantiates a new + :class:`_orm.registry` to be used with the base, assuming one was not + provided explicitly. The :class:`_orm.DeclarativeBase` class supports + class-level attributes which act as parameters for the construction of this + registry; such as to indicate a specific :class:`_schema.MetaData` + collection as well as a specific value for + :paramref:`_orm.registry.type_annotation_map`:: + + from typing import Annotation + + from sqlalchemy import BigInteger + from sqlalchemy import MetaData + from sqlalchemy import String + from sqlalchemy.orm import DeclarativeBase + + bigint = Annotation(int, "bigint") + my_metadata = MetaData() + + class Base(DeclarativeBase): + metadata = my_metadata + type_annotation_map = { + str: String().with_variant(String(255), "mysql", "mariadb"), + bigint: BigInteger() + } + + Class-level attributes which may be specified include: + + :param metadata: optional :class:`_schema.MetaData` collection. + If a :class:`_orm.registry` is constructed automatically, this + :class:`_schema.MetaData` collection will be used to construct it. + Otherwise, the local :class:`_schema.MetaData` collection will supercede + that used by an existing :class:`_orm.registry` passed using the + :paramref:`_orm.DeclarativeBase.registry` parameter. + :param type_annotation_map: optional type annotation map that will be + passed to the :class:`_orm.registry` as + :paramref:`_orm.registry.type_annotation_map`. + :param registry: supply a pre-existing :class:`_orm.registry` directly. + .. versionadded:: 2.0 """ @@ -516,12 +587,13 @@ def add_mapped_attribute(target, key, attr): def declarative_base( - metadata=None, + metadata: Optional[MetaData] = None, mapper=None, cls=object, name="Base", - constructor=_declarative_constructor, - class_registry=None, + class_registry: Optional[clsregistry._ClsRegistryType] = None, + type_annotation_map: Optional[_TypeAnnotationMapType] = None, + constructor: Callable[..., None] = _declarative_constructor, metaclass=DeclarativeMeta, ) -> Any: r"""Construct a base class for declarative class definitions. @@ -593,6 +665,14 @@ def declarative_base( to share the same registry of class names for simplified inter-base relationships. + :param type_annotation_map: optional dictionary of Python types to + SQLAlchemy :class:`_types.TypeEngine` classes or instances. This + is used exclusively by the :class:`_orm.MappedColumn` construct + to produce column types based on annotations within the + :class:`_orm.Mapped` type. + + .. versionadded:: 2.0 + :param metaclass: Defaults to :class:`.DeclarativeMeta`. A metaclass or __metaclass__ compatible callable to use as the meta type of the generated @@ -608,6 +688,7 @@ def declarative_base( metadata=metadata, class_registry=class_registry, constructor=constructor, + type_annotation_map=type_annotation_map, ).generate_base( mapper=mapper, cls=cls, @@ -651,9 +732,10 @@ class registry: def __init__( self, - metadata=None, - class_registry=None, - constructor=_declarative_constructor, + metadata: Optional[MetaData] = None, + class_registry: Optional[clsregistry._ClsRegistryType] = None, + type_annotation_map: Optional[_TypeAnnotationMapType] = None, + constructor: Callable[..., None] = _declarative_constructor, ): r"""Construct a new :class:`_orm.registry` @@ -679,6 +761,14 @@ class registry: to share the same registry of class names for simplified inter-base relationships. + :param type_annotation_map: optional dictionary of Python types to + SQLAlchemy :class:`_types.TypeEngine` classes or instances. This + is used exclusively by the :class:`_orm.MappedColumn` construct + to produce column types based on annotations within the + :class:`_orm.Mapped` type. + + .. versionadded:: 2.0 + """ lcl_metadata = metadata or MetaData() @@ -690,7 +780,9 @@ class registry: self._non_primary_mappers = weakref.WeakKeyDictionary() self.metadata = lcl_metadata self.constructor = constructor - + self.type_annotation_map = {} + if type_annotation_map is not None: + self.update_type_annotation_map(type_annotation_map) self._dependents = set() self._dependencies = set() @@ -699,6 +791,25 @@ class registry: with mapperlib._CONFIGURE_MUTEX: mapperlib._mapper_registries[self] = True + def update_type_annotation_map( + self, + type_annotation_map: Mapping[ + Type, Union[Type[TypeEngine], TypeEngine] + ], + ) -> None: + """update the :paramref:`_orm.registry.type_annotation_map` with new + values.""" + + self.type_annotation_map.update( + { + sub_type: sqltype + for typ, sqltype in type_annotation_map.items() + for sub_type in compat_typing.expand_unions( + typ, include_union=True, discard_none=True + ) + } + ) + @property def mappers(self): """read only collection of all :class:`_orm.Mapper` objects.""" @@ -1131,6 +1242,9 @@ class registry: return _mapper(self, class_, local_table, kw) +RegistryType = registry + + def as_declarative(**kw): """ Class decorator which will adapt a given class into a diff --git a/lib/sqlalchemy/orm/decl_base.py b/lib/sqlalchemy/orm/decl_base.py index fb736806c..342aa772b 100644 --- a/lib/sqlalchemy/orm/decl_base.py +++ b/lib/sqlalchemy/orm/decl_base.py @@ -5,23 +5,34 @@ # This module is part of SQLAlchemy and is released under # the MIT License: https://www.opensource.org/licenses/mit-license.php """Internal implementation for declarative.""" + +from __future__ import annotations + import collections +from typing import Any +from typing import Dict +from typing import Tuple import weakref -from sqlalchemy.orm import attributes -from sqlalchemy.orm import instrumentation +from . import attributes from . import clsregistry from . import exc as orm_exc +from . import instrumentation from . import mapperlib from .attributes import InstrumentedAttribute from .attributes import QueryableAttribute from .base import _is_mapped_class from .base import InspectionAttr -from .descriptor_props import CompositeProperty -from .descriptor_props import SynonymProperty +from .descriptor_props import Composite +from .descriptor_props import Synonym +from .interfaces import _IntrospectsAnnotations +from .interfaces import _MappedAttribute +from .interfaces import _MapsColumns from .interfaces import MapperProperty from .mapper import Mapper as mapper from .properties import ColumnProperty +from .properties import MappedColumn +from .util import _is_mapped_annotation from .util import class_mapper from .. import event from .. import exc @@ -130,7 +141,7 @@ def _mapper(registry, cls, table, mapper_kw): @util.preload_module("sqlalchemy.orm.decl_api") -def _is_declarative_props(obj): +def _is_declarative_props(obj: Any) -> bool: declared_attr = util.preloaded.orm_decl_api.declared_attr return isinstance(obj, (declared_attr, util.classproperty)) @@ -208,7 +219,7 @@ class _MapperConfig: class _ImperativeMapperConfig(_MapperConfig): - __slots__ = ("dict_", "local_table", "inherits") + __slots__ = ("local_table", "inherits") def __init__( self, @@ -221,7 +232,6 @@ class _ImperativeMapperConfig(_MapperConfig): registry, cls_, mapper_kw ) - self.dict_ = {} self.local_table = self.set_cls_attribute("__table__", table) with mapperlib._CONFIGURE_MUTEX: @@ -277,7 +287,10 @@ class _ImperativeMapperConfig(_MapperConfig): class _ClassScanMapperConfig(_MapperConfig): __slots__ = ( - "dict_", + "registry", + "clsdict_view", + "collected_attributes", + "collected_annotations", "local_table", "persist_selectable", "declared_columns", @@ -299,11 +312,17 @@ class _ClassScanMapperConfig(_MapperConfig): ): super(_ClassScanMapperConfig, self).__init__(registry, cls_, mapper_kw) - - self.dict_ = dict(dict_) if dict_ else {} + self.registry = registry self.persist_selectable = None - self.declared_columns = set() + + self.clsdict_view = ( + util.immutabledict(dict_) if dict_ else util.EMPTY_DICT + ) + self.collected_attributes = {} + self.collected_annotations: Dict[str, Tuple[Any, bool]] = {} + self.declared_columns = util.OrderedSet() self.column_copies = {} + self._setup_declared_events() self._scan_attributes() @@ -407,6 +426,19 @@ class _ClassScanMapperConfig(_MapperConfig): return attribute_is_overridden + _skip_attrs = frozenset( + [ + "__module__", + "__annotations__", + "__doc__", + "__dict__", + "__weakref__", + "_sa_class_manager", + "__dict__", + "__weakref__", + ] + ) + def _cls_attr_resolver(self, cls): """produce a function to iterate the "attributes" of a class, adjusting for SQLAlchemy fields embedded in dataclass fields. @@ -416,31 +448,52 @@ class _ClassScanMapperConfig(_MapperConfig): cls, "__sa_dataclass_metadata_key__", None ) + cls_annotations = util.get_annotations(cls) + + cls_vars = vars(cls) + + skip = self._skip_attrs + + names = util.merge_lists_w_ordering( + [n for n in cls_vars if n not in skip], list(cls_annotations) + ) if sa_dataclass_metadata_key is None: def local_attributes_for_class(): - for name, obj in vars(cls).items(): - yield name, obj, False + return ( + ( + name, + cls_vars.get(name), + cls_annotations.get(name), + False, + ) + for name in names + ) else: - field_names = set() + dataclass_fields = { + field.name: field for field in util.local_dataclass_fields(cls) + } def local_attributes_for_class(): - for field in util.local_dataclass_fields(cls): - if sa_dataclass_metadata_key in field.metadata: - field_names.add(field.name) + for name in names: + field = dataclass_fields.get(name, None) + if field and sa_dataclass_metadata_key in field.metadata: yield field.name, _as_dc_declaredattr( field.metadata, sa_dataclass_metadata_key - ), True - for name, obj in vars(cls).items(): - if name not in field_names: - yield name, obj, False + ), cls_annotations.get(field.name), True + else: + yield name, cls_vars.get(name), cls_annotations.get( + name + ), False return local_attributes_for_class def _scan_attributes(self): cls = self.cls - dict_ = self.dict_ + + clsdict_view = self.clsdict_view + collected_attributes = self.collected_attributes column_copies = self.column_copies mapper_args_fn = None table_args = inherited_table_args = None @@ -462,10 +515,16 @@ class _ClassScanMapperConfig(_MapperConfig): if not class_mapped and base is not cls: self._produce_column_copies( - local_attributes_for_class, attribute_is_overridden + local_attributes_for_class, + attribute_is_overridden, ) - for name, obj, is_dataclass in local_attributes_for_class(): + for ( + name, + obj, + annotation, + is_dataclass, + ) in local_attributes_for_class(): if name == "__mapper_args__": check_decl = _check_declared_props_nocascade( obj, name, cls @@ -514,7 +573,12 @@ class _ClassScanMapperConfig(_MapperConfig): elif base is not cls: # we're a mixin, abstract base, or something that is # acting like that for now. - if isinstance(obj, Column): + + if isinstance(obj, (Column, MappedColumn)): + self.collected_annotations[name] = ( + annotation, + False, + ) # already copied columns to the mapped class. continue elif isinstance(obj, MapperProperty): @@ -526,8 +590,12 @@ class _ClassScanMapperConfig(_MapperConfig): "field() objects, use a lambda:" ) elif _is_declarative_props(obj): + # tried to get overloads to tell this to + # pylance, no luck + assert obj is not None + if obj._cascading: - if name in dict_: + if name in clsdict_view: # unfortunately, while we can use the user- # defined attribute here to allow a clean # override, if there's another @@ -541,7 +609,7 @@ class _ClassScanMapperConfig(_MapperConfig): "@declared_attr.cascading; " "skipping" % (name, cls) ) - dict_[name] = column_copies[ + collected_attributes[name] = column_copies[ obj ] = ret = obj.__get__(obj, cls) setattr(cls, name, ret) @@ -579,19 +647,36 @@ class _ClassScanMapperConfig(_MapperConfig): ): ret = ret.descriptor - dict_[name] = column_copies[obj] = ret + collected_attributes[name] = column_copies[ + obj + ] = ret if ( isinstance(ret, (Column, MapperProperty)) and ret.doc is None ): ret.doc = obj.__doc__ - # here, the attribute is some other kind of property that - # we assume is not part of the declarative mapping. - # however, check for some more common mistakes + + self.collected_annotations[name] = ( + obj._collect_return_annotation(), + False, + ) + elif _is_mapped_annotation(annotation, cls): + self.collected_annotations[name] = ( + annotation, + is_dataclass, + ) + if obj is None: + collected_attributes[name] = MappedColumn() + else: + collected_attributes[name] = obj else: + # here, the attribute is some other kind of + # property that we assume is not part of the + # declarative mapping. however, check for some + # more common mistakes self._warn_for_decl_attributes(base, name, obj) elif is_dataclass and ( - name not in dict_ or dict_[name] is not obj + name not in clsdict_view or clsdict_view[name] is not obj ): # here, we are definitely looking at the target class # and not a superclass. this is currently a @@ -606,7 +691,20 @@ class _ClassScanMapperConfig(_MapperConfig): if _is_declarative_props(obj): obj = obj.fget() - dict_[name] = obj + collected_attributes[name] = obj + self.collected_annotations[name] = ( + annotation, + True, + ) + else: + self.collected_annotations[name] = ( + annotation, + False, + ) + if obj is None and _is_mapped_annotation(annotation, cls): + collected_attributes[name] = MappedColumn() + elif name in clsdict_view: + collected_attributes[name] = obj if inherited_table_args and not tablename: table_args = None @@ -618,46 +716,55 @@ class _ClassScanMapperConfig(_MapperConfig): def _warn_for_decl_attributes(self, cls, key, c): if isinstance(c, expression.ColumnClause): util.warn( - "Attribute '%s' on class %s appears to be a non-schema " - "'sqlalchemy.sql.column()' " + f"Attribute '{key}' on class {cls} appears to " + "be a non-schema 'sqlalchemy.sql.column()' " "object; this won't be part of the declarative mapping" - % (key, cls) ) def _produce_column_copies( self, attributes_for_class, attribute_is_overridden ): cls = self.cls - dict_ = self.dict_ + dict_ = self.clsdict_view + collected_attributes = self.collected_attributes column_copies = self.column_copies # copy mixin columns to the mapped class - for name, obj, is_dataclass in attributes_for_class(): - if isinstance(obj, Column): + for name, obj, annotation, is_dataclass in attributes_for_class(): + if isinstance(obj, (Column, MappedColumn)): if attribute_is_overridden(name, obj): # if column has been overridden # (like by the InstrumentedAttribute of the # superclass), skip continue - elif obj.foreign_keys: - raise exc.InvalidRequestError( - "Columns with foreign keys to other columns " - "must be declared as @declared_attr callables " - "on declarative mixin classes. For dataclass " - "field() objects, use a lambda:." - ) elif name not in dict_ and not ( "__table__" in dict_ and (obj.name or name) in dict_["__table__"].c ): + if obj.foreign_keys: + for fk in obj.foreign_keys: + if ( + fk._table_column is not None + and fk._table_column.table is None + ): + raise exc.InvalidRequestError( + "Columns with foreign keys to " + "non-table-bound " + "columns must be declared as " + "@declared_attr callables " + "on declarative mixin classes. " + "For dataclass " + "field() objects, use a lambda:." + ) + column_copies[obj] = copy_ = obj._copy() - copy_._creation_order = obj._creation_order + collected_attributes[name] = copy_ + setattr(cls, name, copy_) - dict_[name] = copy_ def _extract_mappable_attributes(self): cls = self.cls - dict_ = self.dict_ + collected_attributes = self.collected_attributes our_stuff = self.properties @@ -665,13 +772,17 @@ class _ClassScanMapperConfig(_MapperConfig): cls, "_sa_decl_prepare_nocascade", strict=True ) - for k in list(dict_): + for k in list(collected_attributes): if k in ("__table__", "__tablename__", "__mapper_args__"): continue - value = dict_[k] + value = collected_attributes[k] + if _is_declarative_props(value): + # @declared_attr in collected_attributes only occurs here for a + # @declared_attr that's directly on the mapped class; + # for a mixin, these have already been evaluated if value._cascading: util.warn( "Use of @declared_attr.cascading only applies to " @@ -689,13 +800,13 @@ class _ClassScanMapperConfig(_MapperConfig): ): # detect a QueryableAttribute that's already mapped being # assigned elsewhere in userland, turn into a synonym() - value = SynonymProperty(value.key) + value = Synonym(value.key) setattr(cls, k, value) if ( isinstance(value, tuple) and len(value) == 1 - and isinstance(value[0], (Column, MapperProperty)) + and isinstance(value[0], (Column, _MappedAttribute)) ): util.warn( "Ignoring declarative-like tuple value of attribute " @@ -703,12 +814,12 @@ class _ClassScanMapperConfig(_MapperConfig): "accidentally placed at the end of the line?" % k ) continue - elif not isinstance(value, (Column, MapperProperty)): + elif not isinstance(value, (Column, MapperProperty, _MapsColumns)): # using @declared_attr for some object that - # isn't Column/MapperProperty; remove from the dict_ + # isn't Column/MapperProperty; remove from the clsdict_view # and place the evaluated value onto the class. if not k.startswith("__"): - dict_.pop(k) + collected_attributes.pop(k) self._warn_for_decl_attributes(cls, k, value) if not late_mapped: setattr(cls, k, value) @@ -722,27 +833,37 @@ class _ClassScanMapperConfig(_MapperConfig): "for the MetaData instance when using a " "declarative base class." ) + elif isinstance(value, _IntrospectsAnnotations): + annotation, is_dataclass = self.collected_annotations.get( + k, (None, None) + ) + value.declarative_scan( + self.registry, cls, k, annotation, is_dataclass + ) our_stuff[k] = value def _extract_declared_columns(self): our_stuff = self.properties - # set up attributes in the order they were created - util.sort_dictionary( - our_stuff, key=lambda key: our_stuff[key]._creation_order - ) - # extract columns from the class dict declared_columns = self.declared_columns name_to_prop_key = collections.defaultdict(set) for key, c in list(our_stuff.items()): - if isinstance(c, (ColumnProperty, CompositeProperty)): - for col in c.columns: - if isinstance(col, Column) and col.table is None: - _undefer_column_name(key, col) - if not isinstance(c, CompositeProperty): - name_to_prop_key[col.name].add(key) - declared_columns.add(col) + if isinstance(c, _MapsColumns): + for col in c.columns_to_assign: + if not isinstance(c, Composite): + name_to_prop_key[col.name].add(key) + declared_columns.add(col) + + # remove object from the dictionary that will be passed + # as mapper(properties={...}) if it is not a MapperProperty + # (i.e. this currently means it's a MappedColumn) + mp_to_assign = c.mapper_property_to_assign + if mp_to_assign: + our_stuff[key] = mp_to_assign + else: + del our_stuff[key] + elif isinstance(c, Column): _undefer_column_name(key, c) name_to_prop_key[c.name].add(key) @@ -769,16 +890,12 @@ class _ClassScanMapperConfig(_MapperConfig): cls = self.cls tablename = self.tablename table_args = self.table_args - dict_ = self.dict_ + clsdict_view = self.clsdict_view declared_columns = self.declared_columns manager = attributes.manager_of_class(cls) - declared_columns = self.declared_columns = sorted( - declared_columns, key=lambda c: c._creation_order - ) - - if "__table__" not in dict_ and table is None: + if "__table__" not in clsdict_view and table is None: if hasattr(cls, "__table_cls__"): table_cls = util.unbound_method_to_callable(cls.__table_cls__) else: @@ -796,11 +913,11 @@ class _ClassScanMapperConfig(_MapperConfig): else: args = table_args - autoload_with = dict_.get("__autoload_with__") + autoload_with = clsdict_view.get("__autoload_with__") if autoload_with: table_kw["autoload_with"] = autoload_with - autoload = dict_.get("__autoload__") + autoload = clsdict_view.get("__autoload__") if autoload: table_kw["autoload"] = True @@ -1095,18 +1212,21 @@ def _add_attribute(cls, key, value): _undefer_column_name(key, value) cls.__table__.append_column(value, replace_existing=True) cls.__mapper__.add_property(key, value) - elif isinstance(value, ColumnProperty): - for col in value.columns: - if isinstance(col, Column) and col.table is None: - _undefer_column_name(key, col) - cls.__table__.append_column(col, replace_existing=True) - cls.__mapper__.add_property(key, value) + elif isinstance(value, _MapsColumns): + mp = value.mapper_property_to_assign + for col in value.columns_to_assign: + _undefer_column_name(key, col) + cls.__table__.append_column(col, replace_existing=True) + if not mp: + cls.__mapper__.add_property(key, col) + if mp: + cls.__mapper__.add_property(key, mp) elif isinstance(value, MapperProperty): cls.__mapper__.add_property(key, value) elif isinstance(value, QueryableAttribute) and value.key != key: # detect a QueryableAttribute that's already mapped being # assigned elsewhere in userland, turn into a synonym() - value = SynonymProperty(value.key) + value = Synonym(value.key) cls.__mapper__.add_property(key, value) else: type.__setattr__(cls, key, value) @@ -1124,7 +1244,7 @@ def _del_attribute(cls, key): ): value = cls.__dict__[key] if isinstance( - value, (Column, ColumnProperty, MapperProperty, QueryableAttribute) + value, (Column, _MapsColumns, MapperProperty, QueryableAttribute) ): raise NotImplementedError( "Can't un-map individual mapped attributes on a mapped class." diff --git a/lib/sqlalchemy/orm/descriptor_props.py b/lib/sqlalchemy/orm/descriptor_props.py index 5e67b64cd..4526a8b33 100644 --- a/lib/sqlalchemy/orm/descriptor_props.py +++ b/lib/sqlalchemy/orm/descriptor_props.py @@ -10,14 +10,26 @@ that exist as configurational elements, but don't participate as actively in the load/persist ORM loop. """ +import inspect +import itertools +import operator +import typing from typing import Any -from typing import Type +from typing import Callable +from typing import List +from typing import Optional +from typing import Tuple from typing import TypeVar +from typing import Union from . import attributes from . import util as orm_util +from .base import Mapped +from .interfaces import _IntrospectsAnnotations +from .interfaces import _MapsColumns from .interfaces import MapperProperty from .interfaces import PropComparator +from .util import _extract_mapped_subtype from .util import _none_set from .. import event from .. import exc as sa_exc @@ -27,6 +39,9 @@ from .. import util from ..sql import expression from ..sql import operators +if typing.TYPE_CHECKING: + from .properties import MappedColumn + _T = TypeVar("_T", bound=Any) _PT = TypeVar("_PT", bound=Any) @@ -92,30 +107,48 @@ class DescriptorProperty(MapperProperty[_T]): mapper.class_manager.instrument_attribute(self.key, proxy_attr) -class CompositeProperty(DescriptorProperty[_T]): +class Composite( + _MapsColumns[_T], _IntrospectsAnnotations, DescriptorProperty[_T] +): """Defines a "composite" mapped attribute, representing a collection of columns as one attribute. - :class:`.CompositeProperty` is constructed using the :func:`.composite` + :class:`.Composite` is constructed using the :func:`.composite` function. + .. versionchanged:: 2.0 Renamed :class:`_orm.CompositeProperty` + to :class:`_orm.Composite`. The old name + :class:`_orm.CompositeProperty` remains as an alias. + .. seealso:: :ref:`mapper_composite` """ - def __init__(self, class_: Type[_T], *attrs, **kwargs): - super(CompositeProperty, self).__init__() + composite_class: Union[type, Callable[..., type]] + attrs: Tuple[ + Union[sql.ColumnElement[Any], "MappedColumn", str, Mapped[Any]], ... + ] + + def __init__(self, class_=None, *attrs, **kwargs): + super().__init__() + + if isinstance(class_, (Mapped, str, sql.ColumnElement)): + self.attrs = (class_,) + attrs + # will initialize within declarative_scan + self.composite_class = None # type: ignore + else: + self.composite_class = class_ + self.attrs = attrs - self.attrs = attrs - self.composite_class = class_ self.active_history = kwargs.get("active_history", False) self.deferred = kwargs.get("deferred", False) self.group = kwargs.get("group", None) self.comparator_factory = kwargs.pop( "comparator_factory", self.__class__.Comparator ) + self._generated_composite_accessor = None if "info" in kwargs: self.info = kwargs.pop("info") @@ -123,11 +156,26 @@ class CompositeProperty(DescriptorProperty[_T]): self._create_descriptor() def instrument_class(self, mapper): - super(CompositeProperty, self).instrument_class(mapper) + super().instrument_class(mapper) self._setup_event_handlers() + def _composite_values_from_instance(self, value): + if self._generated_composite_accessor: + return self._generated_composite_accessor(value) + else: + try: + accessor = value.__composite_values__ + except AttributeError as ae: + raise sa_exc.InvalidRequestError( + f"Composite class {self.composite_class.__name__} is not " + f"a dataclass and does not define a __composite_values__()" + " method; can't get state" + ) from ae + else: + return accessor() + def do_init(self): - """Initialization which occurs after the :class:`.CompositeProperty` + """Initialization which occurs after the :class:`.Composite` has been associated with its parent mapper. """ @@ -181,7 +229,8 @@ class CompositeProperty(DescriptorProperty[_T]): setattr(instance, key, None) else: for key, value in zip( - self._attribute_keys, value.__composite_values__() + self._attribute_keys, + self._composite_values_from_instance(value), ): setattr(instance, key, value) @@ -196,18 +245,74 @@ class CompositeProperty(DescriptorProperty[_T]): self.descriptor = property(fget, fset, fdel) + @util.preload_module("sqlalchemy.orm.properties") + @util.preload_module("sqlalchemy.orm.decl_base") + def declarative_scan( + self, registry, cls, key, annotation, is_dataclass_field + ): + MappedColumn = util.preloaded.orm_properties.MappedColumn + decl_base = util.preloaded.orm_decl_base + + argument = _extract_mapped_subtype( + annotation, + cls, + key, + MappedColumn, + self.composite_class is None, + is_dataclass_field, + ) + + if argument and self.composite_class is None: + if isinstance(argument, str) or hasattr( + argument, "__forward_arg__" + ): + raise sa_exc.ArgumentError( + f"Can't use forward ref {argument} for composite " + f"class argument" + ) + self.composite_class = argument + insp = inspect.signature(self.composite_class) + for param, attr in itertools.zip_longest( + insp.parameters.values(), self.attrs + ): + if param is None or attr is None: + raise sa_exc.ArgumentError( + f"number of arguments to {self.composite_class.__name__} " + f"class and number of attributes don't match" + ) + if isinstance(attr, MappedColumn): + attr.declarative_scan_for_composite( + registry, cls, key, param.name, param.annotation + ) + elif isinstance(attr, schema.Column): + decl_base._undefer_column_name(param.name, attr) + + if not hasattr(cls, "__composite_values__"): + getter = operator.attrgetter( + *[p.name for p in insp.parameters.values()] + ) + if len(insp.parameters) == 1: + self._generated_composite_accessor = lambda obj: (getter(obj),) + else: + self._generated_composite_accessor = getter + @util.memoized_property def _comparable_elements(self): return [getattr(self.parent.class_, prop.key) for prop in self.props] @util.memoized_property + @util.preload_module("orm.properties") def props(self): props = [] + MappedColumn = util.preloaded.orm_properties.MappedColumn + for attr in self.attrs: if isinstance(attr, str): prop = self.parent.get_property(attr, _configure_mappers=False) elif isinstance(attr, schema.Column): prop = self.parent._columntoproperty[attr] + elif isinstance(attr, MappedColumn): + prop = self.parent._columntoproperty[attr.column] elif isinstance(attr, attributes.InstrumentedAttribute): prop = attr.property else: @@ -220,8 +325,22 @@ class CompositeProperty(DescriptorProperty[_T]): return props @property + @util.preload_module("orm.properties") def columns(self): - return [a for a in self.attrs if isinstance(a, schema.Column)] + MappedColumn = util.preloaded.orm_properties.MappedColumn + return [ + a.column if isinstance(a, MappedColumn) else a + for a in self.attrs + if isinstance(a, (schema.Column, MappedColumn)) + ] + + @property + def mapper_property_to_assign(self) -> Optional["MapperProperty[_T]"]: + return self + + @property + def columns_to_assign(self) -> List[schema.Column]: + return [c for c in self.columns if c.table is None] def _setup_arguments_on_columns(self): """Propagate configuration arguments made on this composite @@ -351,9 +470,7 @@ class CompositeProperty(DescriptorProperty[_T]): class CompositeBundle(orm_util.Bundle): def __init__(self, property_, expr): self.property = property_ - super(CompositeProperty.CompositeBundle, self).__init__( - property_.key, *expr - ) + super().__init__(property_.key, *expr) def create_row_processor(self, query, procs, labels): def proc(row): @@ -365,7 +482,7 @@ class CompositeProperty(DescriptorProperty[_T]): class Comparator(PropComparator[_PT]): """Produce boolean, comparison, and other operators for - :class:`.CompositeProperty` attributes. + :class:`.Composite` attributes. See the example in :ref:`composite_operations` for an overview of usage , as well as the documentation for :class:`.PropComparator`. @@ -402,7 +519,7 @@ class CompositeProperty(DescriptorProperty[_T]): "proxy_key": self.prop.key, } ) - return CompositeProperty.CompositeBundle(self.prop, clauses) + return Composite.CompositeBundle(self.prop, clauses) def _bulk_update_tuples(self, value): if isinstance(value, sql.elements.BindParameter): @@ -411,7 +528,7 @@ class CompositeProperty(DescriptorProperty[_T]): if value is None: values = [None for key in self.prop._attribute_keys] elif isinstance(value, self.prop.composite_class): - values = value.__composite_values__() + values = self.prop._composite_values_from_instance(value) else: raise sa_exc.ArgumentError( "Can't UPDATE composite attribute %s to %r" @@ -434,7 +551,7 @@ class CompositeProperty(DescriptorProperty[_T]): if other is None: values = [None] * len(self.prop._comparable_elements) else: - values = other.__composite_values__() + values = self.prop._composite_values_from_instance(other) comparisons = [ a == b for a, b in zip(self.prop._comparable_elements, values) ] @@ -477,7 +594,7 @@ class ConcreteInheritedProperty(DescriptorProperty[_T]): return comparator_callable def __init__(self): - super(ConcreteInheritedProperty, self).__init__() + super().__init__() def warn(): raise AttributeError( @@ -502,7 +619,24 @@ class ConcreteInheritedProperty(DescriptorProperty[_T]): self.descriptor = NoninheritedConcreteProp() -class SynonymProperty(DescriptorProperty[_T]): +class Synonym(DescriptorProperty[_T]): + """Denote an attribute name as a synonym to a mapped property, + in that the attribute will mirror the value and expression behavior + of another attribute. + + :class:`.Synonym` is constructed using the :func:`_orm.synonym` + function. + + .. versionchanged:: 2.0 Renamed :class:`_orm.SynonymProperty` + to :class:`_orm.Synonym`. The old name + :class:`_orm.SynonymProperty` remains as an alias. + + .. seealso:: + + :ref:`synonyms` - Overview of synonyms + + """ + def __init__( self, name, @@ -512,7 +646,7 @@ class SynonymProperty(DescriptorProperty[_T]): doc=None, info=None, ): - super(SynonymProperty, self).__init__() + super().__init__() self.name = name self.map_column = map_column diff --git a/lib/sqlalchemy/orm/dynamic.py b/lib/sqlalchemy/orm/dynamic.py index ade47480d..3d9c61c20 100644 --- a/lib/sqlalchemy/orm/dynamic.py +++ b/lib/sqlalchemy/orm/dynamic.py @@ -28,7 +28,7 @@ from ..engine import result @log.class_logger -@relationships.RelationshipProperty.strategy_for(lazy="dynamic") +@relationships.Relationship.strategy_for(lazy="dynamic") class DynaLoader(strategies.AbstractRelationshipLoader): def init_class_attribute(self, mapper): self.is_class_level = True diff --git a/lib/sqlalchemy/orm/interfaces.py b/lib/sqlalchemy/orm/interfaces.py index b9a5aaf51..1f9ec78f7 100644 --- a/lib/sqlalchemy/orm/interfaces.py +++ b/lib/sqlalchemy/orm/interfaces.py @@ -20,7 +20,12 @@ import collections import typing from typing import Any from typing import cast +from typing import List +from typing import Optional +from typing import Tuple +from typing import Type from typing import TypeVar +from typing import Union from . import exc as orm_exc from . import path_registry @@ -41,8 +46,15 @@ from .. import util from ..sql import operators from ..sql import roles from ..sql import visitors +from ..sql._typing import _ColumnsClauseElement from ..sql.base import ExecutableOption from ..sql.cache_key import HasCacheKey +from ..sql.schema import Column +from ..sql.type_api import TypeEngine +from ..util.typing import TypedDict + +if typing.TYPE_CHECKING: + from .decl_api import RegistryType _T = TypeVar("_T", bound=Any) @@ -85,6 +97,54 @@ class ORMFromClauseRole(roles.StrictFromClauseRole): _role_name = "ORM mapped entity, aliased entity, or FROM expression" +class ORMColumnDescription(TypedDict): + name: str + type: Union[Type, TypeEngine] + aliased: bool + expr: _ColumnsClauseElement + entity: Optional[_ColumnsClauseElement] + + +class _IntrospectsAnnotations: + __slots__ = () + + def declarative_scan( + self, + registry: "RegistryType", + cls: type, + key: str, + annotation: Optional[type], + is_dataclass_field: Optional[bool], + ) -> None: + """Perform class-specific initializaton at early declarative scanning + time. + + .. versionadded:: 2.0 + + """ + + +class _MapsColumns(_MappedAttribute[_T]): + """interface for declarative-capable construct that delivers one or more + Column objects to the declarative process to be part of a Table. + """ + + __slots__ = () + + @property + def mapper_property_to_assign(self) -> Optional["MapperProperty[_T]"]: + """return a MapperProperty to be assigned to the declarative mapping""" + raise NotImplementedError() + + @property + def columns_to_assign(self) -> List[Column]: + """A list of Column objects that should be declaratively added to the + new Table object. + + """ + raise NotImplementedError() + + @inspection._self_inspects class MapperProperty( HasCacheKey, _MappedAttribute[_T], InspectionAttr, util.MemoizedSlots @@ -96,7 +156,7 @@ class MapperProperty( an instance of :class:`.ColumnProperty`, and a reference to another class produced by :func:`_orm.relationship`, represented in the mapping as an instance of - :class:`.RelationshipProperty`. + :class:`.Relationship`. """ @@ -118,7 +178,7 @@ class MapperProperty( This collection is checked before the 'cascade_iterator' method is called. - The collection typically only applies to a RelationshipProperty. + The collection typically only applies to a Relationship. """ @@ -132,7 +192,7 @@ class MapperProperty( def _links_to_entity(self): """True if this MapperProperty refers to a mapped entity. - Should only be True for RelationshipProperty, False for all others. + Should only be True for Relationship, False for all others. """ raise NotImplementedError() @@ -189,7 +249,7 @@ class MapperProperty( Note that the 'cascade' collection on this MapperProperty is checked first for the given type before cascade_iterator is called. - This method typically only applies to RelationshipProperty. + This method typically only applies to Relationship. """ @@ -323,7 +383,7 @@ class PropComparator( be redefined at both the Core and ORM level. :class:`.PropComparator` is the base class of operator redefinition for ORM-level operations, including those of :class:`.ColumnProperty`, - :class:`.RelationshipProperty`, and :class:`.CompositeProperty`. + :class:`.Relationship`, and :class:`.Composite`. User-defined subclasses of :class:`.PropComparator` may be created. The built-in Python comparison and math operator methods, such as @@ -339,19 +399,19 @@ class PropComparator( from sqlalchemy.orm.properties import \ ColumnProperty,\ - CompositeProperty,\ - RelationshipProperty + Composite,\ + Relationship class MyColumnComparator(ColumnProperty.Comparator): def __eq__(self, other): return self.__clause_element__() == other - class MyRelationshipComparator(RelationshipProperty.Comparator): + class MyRelationshipComparator(Relationship.Comparator): def any(self, expression): "define the 'any' operation" # ... - class MyCompositeComparator(CompositeProperty.Comparator): + class MyCompositeComparator(Composite.Comparator): def __gt__(self, other): "redefine the 'greater than' operation" @@ -386,9 +446,9 @@ class PropComparator( :class:`.ColumnProperty.Comparator` - :class:`.RelationshipProperty.Comparator` + :class:`.Relationship.Comparator` - :class:`.CompositeProperty.Comparator` + :class:`.Composite.Comparator` :class:`.ColumnOperators` @@ -552,7 +612,7 @@ class PropComparator( given criterion. The usual implementation of ``any()`` is - :meth:`.RelationshipProperty.Comparator.any`. + :meth:`.Relationship.Comparator.any`. :param criterion: an optional ClauseElement formulated against the member class' table or attributes. @@ -570,7 +630,7 @@ class PropComparator( given criterion. The usual implementation of ``has()`` is - :meth:`.RelationshipProperty.Comparator.has`. + :meth:`.Relationship.Comparator.has`. :param criterion: an optional ClauseElement formulated against the member class' table or attributes. @@ -606,10 +666,13 @@ class StrategizedProperty(MapperProperty[_T]): "strategy", "_wildcard_token", "_default_path_loader_key", + "strategy_key", ) inherit_cache = True strategy_wildcard_key = None + strategy_key: Tuple[Any, ...] + def _memoized_attr__wildcard_token(self): return ( f"{self.strategy_wildcard_key}:{path_registry._WILDCARD_TOKEN}", diff --git a/lib/sqlalchemy/orm/mapped_collection.py b/lib/sqlalchemy/orm/mapped_collection.py new file mode 100644 index 000000000..75abeef4c --- /dev/null +++ b/lib/sqlalchemy/orm/mapped_collection.py @@ -0,0 +1,232 @@ +# orm/collections.py +# Copyright (C) 2005-2022 the SQLAlchemy authors and contributors +# <see AUTHORS file> +# +# This module is part of SQLAlchemy and is released under +# the MIT License: https://www.opensource.org/licenses/mit-license.php + +import operator +from typing import Any +from typing import Callable +from typing import Dict +from typing import Type +from typing import TypeVar + +from . import base +from .collections import collection +from .. import exc as sa_exc +from .. import util +from ..sql import coercions +from ..sql import expression +from ..sql import roles + +_KT = TypeVar("_KT", bound=Any) +_VT = TypeVar("_VT", bound=Any) + + +class _PlainColumnGetter: + """Plain column getter, stores collection of Column objects + directly. + + Serializes to a :class:`._SerializableColumnGetterV2` + which has more expensive __call__() performance + and some rare caveats. + + """ + + __slots__ = ("cols", "composite") + + def __init__(self, cols): + self.cols = cols + self.composite = len(cols) > 1 + + def __reduce__(self): + return _SerializableColumnGetterV2._reduce_from_cols(self.cols) + + def _cols(self, mapper): + return self.cols + + def __call__(self, value): + state = base.instance_state(value) + m = base._state_mapper(state) + + key = [ + m._get_state_attr_by_column(state, state.dict, col) + for col in self._cols(m) + ] + + if self.composite: + return tuple(key) + else: + return key[0] + + +class _SerializableColumnGetterV2(_PlainColumnGetter): + """Updated serializable getter which deals with + multi-table mapped classes. + + Two extremely unusual cases are not supported. + Mappings which have tables across multiple metadata + objects, or which are mapped to non-Table selectables + linked across inheriting mappers may fail to function + here. + + """ + + __slots__ = ("colkeys",) + + def __init__(self, colkeys): + self.colkeys = colkeys + self.composite = len(colkeys) > 1 + + def __reduce__(self): + return self.__class__, (self.colkeys,) + + @classmethod + def _reduce_from_cols(cls, cols): + def _table_key(c): + if not isinstance(c.table, expression.TableClause): + return None + else: + return c.table.key + + colkeys = [(c.key, _table_key(c)) for c in cols] + return _SerializableColumnGetterV2, (colkeys,) + + def _cols(self, mapper): + cols = [] + metadata = getattr(mapper.local_table, "metadata", None) + for (ckey, tkey) in self.colkeys: + if tkey is None or metadata is None or tkey not in metadata: + cols.append(mapper.local_table.c[ckey]) + else: + cols.append(metadata.tables[tkey].c[ckey]) + return cols + + +def column_mapped_collection(mapping_spec): + """A dictionary-based collection type with column-based keying. + + Returns a :class:`.MappedCollection` factory with a keying function + generated from mapping_spec, which may be a Column or a sequence + of Columns. + + The key value must be immutable for the lifetime of the object. You + can not, for example, map on foreign key values if those key values will + change during the session, i.e. from None to a database-assigned integer + after a session flush. + + """ + cols = [ + coercions.expect(roles.ColumnArgumentRole, q, argname="mapping_spec") + for q in util.to_list(mapping_spec) + ] + keyfunc = _PlainColumnGetter(cols) + return _mapped_collection_cls(keyfunc) + + +def attribute_mapped_collection(attr_name: str) -> Type["MappedCollection"]: + """A dictionary-based collection type with attribute-based keying. + + Returns a :class:`.MappedCollection` factory with a keying based on the + 'attr_name' attribute of entities in the collection, where ``attr_name`` + is the string name of the attribute. + + .. warning:: the key value must be assigned to its final value + **before** it is accessed by the attribute mapped collection. + Additionally, changes to the key attribute are **not tracked** + automatically, which means the key in the dictionary is not + automatically synchronized with the key value on the target object + itself. See the section :ref:`key_collections_mutations` + for an example. + + """ + getter = operator.attrgetter(attr_name) + return _mapped_collection_cls(getter) + + +def mapped_collection( + keyfunc: Callable[[Any], _KT] +) -> Type["MappedCollection[_KT, Any]"]: + """A dictionary-based collection type with arbitrary keying. + + Returns a :class:`.MappedCollection` factory with a keying function + generated from keyfunc, a callable that takes an entity and returns a + key value. + + The key value must be immutable for the lifetime of the object. You + can not, for example, map on foreign key values if those key values will + change during the session, i.e. from None to a database-assigned integer + after a session flush. + + """ + return _mapped_collection_cls(keyfunc) + + +class MappedCollection(Dict[_KT, _VT]): + """A basic dictionary-based collection class. + + Extends dict with the minimal bag semantics that collection + classes require. ``set`` and ``remove`` are implemented in terms + of a keying function: any callable that takes an object and + returns an object for use as a dictionary key. + + """ + + def __init__(self, keyfunc): + """Create a new collection with keying provided by keyfunc. + + keyfunc may be any callable that takes an object and returns an object + for use as a dictionary key. + + The keyfunc will be called every time the ORM needs to add a member by + value-only (such as when loading instances from the database) or + remove a member. The usual cautions about dictionary keying apply- + ``keyfunc(object)`` should return the same output for the life of the + collection. Keying based on mutable properties can result in + unreachable instances "lost" in the collection. + + """ + self.keyfunc = keyfunc + + @classmethod + def _unreduce(cls, keyfunc, values): + mp = MappedCollection(keyfunc) + mp.update(values) + return mp + + def __reduce__(self): + return (MappedCollection._unreduce, (self.keyfunc, dict(self))) + + @collection.appender + @collection.internally_instrumented + def set(self, value, _sa_initiator=None): + """Add an item by value, consulting the keyfunc for the key.""" + + key = self.keyfunc(value) + self.__setitem__(key, value, _sa_initiator) + + @collection.remover + @collection.internally_instrumented + def remove(self, value, _sa_initiator=None): + """Remove an item by value, consulting the keyfunc for the key.""" + + key = self.keyfunc(value) + # Let self[key] raise if key is not in this collection + # testlib.pragma exempt:__ne__ + if self[key] != value: + raise sa_exc.InvalidRequestError( + "Can not remove '%s': collection holds '%s' for key '%s'. " + "Possible cause: is the MappedCollection key function " + "based on mutable properties or properties that only obtain " + "values after flush?" % (value, self[key], key) + ) + self.__delitem__(key, _sa_initiator) + + +def _mapped_collection_cls(keyfunc): + class _MKeyfuncMapped(MappedCollection): + def __init__(self): + super().__init__(keyfunc) + + return _MKeyfuncMapped diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py index fdf065488..cd0d1e820 100644 --- a/lib/sqlalchemy/orm/mapper.py +++ b/lib/sqlalchemy/orm/mapper.py @@ -580,7 +580,16 @@ class Mapper( self.version_id_prop = version_id_col self.version_id_col = None else: - self.version_id_col = version_id_col + self.version_id_col = ( + coercions.expect( + roles.ColumnArgumentOrKeyRole, + version_id_col, + argname="version_id_col", + ) + if version_id_col is not None + else None + ) + if version_id_generator is False: self.version_id_generator = False elif version_id_generator is None: @@ -2473,7 +2482,7 @@ class Mapper( @HasMemoized.memoized_attribute @util.preload_module("sqlalchemy.orm.descriptor_props") def synonyms(self): - """Return a namespace of all :class:`.SynonymProperty` + """Return a namespace of all :class:`.Synonym` properties maintained by this :class:`_orm.Mapper`. .. seealso:: @@ -2485,7 +2494,7 @@ class Mapper( """ descriptor_props = util.preloaded.orm_descriptor_props - return self._filter_properties(descriptor_props.SynonymProperty) + return self._filter_properties(descriptor_props.Synonym) @property def entity_namespace(self): @@ -2508,7 +2517,7 @@ class Mapper( @util.preload_module("sqlalchemy.orm.relationships") @HasMemoized.memoized_attribute def relationships(self): - """A namespace of all :class:`.RelationshipProperty` properties + """A namespace of all :class:`.Relationship` properties maintained by this :class:`_orm.Mapper`. .. warning:: @@ -2531,13 +2540,13 @@ class Mapper( """ return self._filter_properties( - util.preloaded.orm_relationships.RelationshipProperty + util.preloaded.orm_relationships.Relationship ) @HasMemoized.memoized_attribute @util.preload_module("sqlalchemy.orm.descriptor_props") def composites(self): - """Return a namespace of all :class:`.CompositeProperty` + """Return a namespace of all :class:`.Composite` properties maintained by this :class:`_orm.Mapper`. .. seealso:: @@ -2548,7 +2557,7 @@ class Mapper( """ return self._filter_properties( - util.preloaded.orm_descriptor_props.CompositeProperty + util.preloaded.orm_descriptor_props.Composite ) def _filter_properties(self, type_): diff --git a/lib/sqlalchemy/orm/properties.py b/lib/sqlalchemy/orm/properties.py index b035dbef2..f28c45fab 100644 --- a/lib/sqlalchemy/orm/properties.py +++ b/lib/sqlalchemy/orm/properties.py @@ -13,37 +13,60 @@ mapped attributes. """ from typing import Any +from typing import cast +from typing import List +from typing import Optional +from typing import Set from typing import TypeVar from . import attributes from . import strategy_options -from .descriptor_props import CompositeProperty +from .base import SQLCoreOperations +from .descriptor_props import Composite from .descriptor_props import ConcreteInheritedProperty -from .descriptor_props import SynonymProperty +from .descriptor_props import Synonym +from .interfaces import _IntrospectsAnnotations +from .interfaces import _MapsColumns +from .interfaces import MapperProperty from .interfaces import PropComparator from .interfaces import StrategizedProperty -from .relationships import RelationshipProperty +from .relationships import Relationship +from .util import _extract_mapped_subtype from .util import _orm_full_deannotate +from .. import exc as sa_exc +from .. import ForeignKey from .. import log from .. import sql from .. import util from ..sql import coercions +from ..sql import operators from ..sql import roles +from ..sql import sqltypes +from ..sql.schema import Column +from ..util.typing import de_optionalize_union_types +from ..util.typing import de_stringify_annotation +from ..util.typing import is_fwd_ref +from ..util.typing import NoneType _T = TypeVar("_T", bound=Any) _PT = TypeVar("_PT", bound=Any) __all__ = [ "ColumnProperty", - "CompositeProperty", + "Composite", "ConcreteInheritedProperty", - "RelationshipProperty", - "SynonymProperty", + "Relationship", + "Synonym", ] @log.class_logger -class ColumnProperty(StrategizedProperty[_T]): +class ColumnProperty( + _MapsColumns[_T], + StrategizedProperty[_T], + _IntrospectsAnnotations, + log.Identified, +): """Describes an object attribute that corresponds to a table column. Public constructor is the :func:`_orm.column_property` function. @@ -65,7 +88,6 @@ class ColumnProperty(StrategizedProperty[_T]): "active_history", "expire_on_flush", "doc", - "strategy_key", "_creation_order", "_is_polymorphic_discriminator", "_mapped_by_synonym", @@ -84,8 +106,8 @@ class ColumnProperty(StrategizedProperty[_T]): coercions.expect(roles.LabeledColumnExprRole, c) for c in columns ] self.columns = [ - coercions.expect( - roles.LabeledColumnExprRole, _orm_full_deannotate(c) + _orm_full_deannotate( + coercions.expect(roles.LabeledColumnExprRole, c) ) for c in columns ] @@ -130,6 +152,27 @@ class ColumnProperty(StrategizedProperty[_T]): if self.raiseload: self.strategy_key += (("raiseload", True),) + def declarative_scan( + self, registry, cls, key, annotation, is_dataclass_field + ): + column = self.columns[0] + if column.key is None: + column.key = key + if column.name is None: + column.name = key + + @property + def mapper_property_to_assign(self) -> Optional["MapperProperty[_T]"]: + return self + + @property + def columns_to_assign(self) -> List[Column]: + return [ + c + for c in self.columns + if isinstance(c, Column) and c.table is None + ] + def _memoized_attr__renders_in_subqueries(self): return ("deferred", True) not in self.strategy_key or ( self not in self.parent._readonly_props @@ -197,7 +240,7 @@ class ColumnProperty(StrategizedProperty[_T]): ) def do_init(self): - super(ColumnProperty, self).do_init() + super().do_init() if len(self.columns) > 1 and set(self.parent.primary_key).issuperset( self.columns @@ -364,3 +407,135 @@ class ColumnProperty(StrategizedProperty[_T]): if not self.parent or not self.key: return object.__repr__(self) return str(self.parent.class_.__name__) + "." + self.key + + +class MappedColumn( + SQLCoreOperations[_T], + operators.ColumnOperators[SQLCoreOperations], + _IntrospectsAnnotations, + _MapsColumns[_T], +): + """Maps a single :class:`_schema.Column` on a class. + + :class:`_orm.MappedColumn` is a specialization of the + :class:`_orm.ColumnProperty` class and is oriented towards declarative + configuration. + + To construct :class:`_orm.MappedColumn` objects, use the + :func:`_orm.mapped_column` constructor function. + + .. versionadded:: 2.0 + + + """ + + __slots__ = ( + "column", + "_creation_order", + "foreign_keys", + "_has_nullable", + "deferred", + ) + + deferred: bool + column: Column[_T] + foreign_keys: Optional[Set[ForeignKey]] + + def __init__(self, *arg, **kw): + self.deferred = kw.pop("deferred", False) + self.column = cast("Column[_T]", Column(*arg, **kw)) + self.foreign_keys = self.column.foreign_keys + self._has_nullable = "nullable" in kw + util.set_creation_order(self) + + def _copy(self, **kw): + new = self.__class__.__new__(self.__class__) + new.column = self.column._copy(**kw) + new.deferred = self.deferred + new.foreign_keys = new.column.foreign_keys + new._has_nullable = self._has_nullable + util.set_creation_order(new) + return new + + @property + def mapper_property_to_assign(self) -> Optional["MapperProperty[_T]"]: + if self.deferred: + return ColumnProperty(self.column, deferred=True) + else: + return None + + @property + def columns_to_assign(self) -> List[Column]: + return [self.column] + + def __clause_element__(self): + return self.column + + def operate(self, op, *other, **kwargs): + return op(self.__clause_element__(), *other, **kwargs) + + def reverse_operate(self, op, other, **kwargs): + col = self.__clause_element__() + return op(col._bind_param(op, other), col, **kwargs) + + def declarative_scan( + self, registry, cls, key, annotation, is_dataclass_field + ): + column = self.column + if column.key is None: + column.key = key + if column.name is None: + column.name = key + + sqltype = column.type + + argument = _extract_mapped_subtype( + annotation, + cls, + key, + MappedColumn, + sqltype._isnull and not self.column.foreign_keys, + is_dataclass_field, + ) + if argument is None: + return + + self._init_column_for_annotation(cls, registry, argument) + + @util.preload_module("sqlalchemy.orm.decl_base") + def declarative_scan_for_composite( + self, registry, cls, key, param_name, param_annotation + ): + decl_base = util.preloaded.orm_decl_base + decl_base._undefer_column_name(param_name, self.column) + self._init_column_for_annotation(cls, registry, param_annotation) + + def _init_column_for_annotation(self, cls, registry, argument): + sqltype = self.column.type + + nullable = False + + if hasattr(argument, "__origin__"): + nullable = NoneType in argument.__args__ + + if not self._has_nullable: + self.column.nullable = nullable + + if sqltype._isnull and not self.column.foreign_keys: + sqltype = None + our_type = de_optionalize_union_types(argument) + + if is_fwd_ref(our_type): + our_type = de_stringify_annotation(cls, our_type) + + if registry.type_annotation_map: + sqltype = registry.type_annotation_map.get(our_type) + if sqltype is None: + sqltype = sqltypes._type_map_get(our_type) + + if sqltype is None: + raise sa_exc.ArgumentError( + f"Could not locate SQLAlchemy Core " + f"type for Python type: {our_type}" + ) + self.column.type = sqltype diff --git a/lib/sqlalchemy/orm/query.py b/lib/sqlalchemy/orm/query.py index 15259f130..61174487a 100644 --- a/lib/sqlalchemy/orm/query.py +++ b/lib/sqlalchemy/orm/query.py @@ -21,7 +21,12 @@ database to return iterable result sets. import collections.abc as collections_abc import itertools import operator -import typing +from typing import Any +from typing import Generic +from typing import Iterable +from typing import List +from typing import Optional +from typing import TypeVar from . import exc as orm_exc from . import interfaces @@ -35,8 +40,9 @@ from .context import LABEL_STYLE_LEGACY_ORM from .context import ORMCompileState from .context import ORMFromStatementCompileState from .context import QueryContext +from .interfaces import ORMColumnDescription from .interfaces import ORMColumnsClauseRole -from .util import aliased +from .util import AliasedClass from .util import object_mapper from .util import with_parent from .. import exc as sa_exc @@ -45,16 +51,19 @@ from .. import inspection from .. import log from .. import sql from .. import util +from ..engine import Result from ..sql import coercions from ..sql import expression from ..sql import roles from ..sql import Select from ..sql import util as sql_util from ..sql import visitors +from ..sql._typing import _FromClauseElement from ..sql.annotation import SupportsCloneAnnotations from ..sql.base import _entity_namespace_key from ..sql.base import _generative from ..sql.base import Executable +from ..sql.expression import Exists from ..sql.selectable import _MemoizedSelectEntities from ..sql.selectable import _SelectFromElements from ..sql.selectable import ForUpdateArg @@ -67,9 +76,12 @@ from ..sql.selectable import SelectBase from ..sql.selectable import SelectStatementGrouping from ..sql.visitors import InternalTraversal -__all__ = ["Query", "QueryContext", "aliased"] -SelfQuery = typing.TypeVar("SelfQuery", bound="Query") +__all__ = ["Query", "QueryContext"] + +_T = TypeVar("_T", bound=Any) + +SelfQuery = TypeVar("SelfQuery", bound="Query") @inspection._self_inspects @@ -80,7 +92,9 @@ class Query( HasPrefixes, HasSuffixes, HasHints, + log.Identified, Executable, + Generic[_T], ): """ORM-level SQL construction object. @@ -1040,7 +1054,7 @@ class Query( for prop in mapper.iterate_properties: if ( - isinstance(prop, relationships.RelationshipProperty) + isinstance(prop, relationships.Relationship) and prop.mapper is entity_zero.mapper ): property = prop # noqa @@ -1064,7 +1078,7 @@ class Query( if alias is not None: # TODO: deprecate - entity = aliased(entity, alias) + entity = AliasedClass(entity, alias) self._raw_columns = list(self._raw_columns) @@ -1992,7 +2006,9 @@ class Query( @_generative @_assertions(_no_clauseelement_condition) - def select_from(self: SelfQuery, *from_obj) -> SelfQuery: + def select_from( + self: SelfQuery, *from_obj: _FromClauseElement + ) -> SelfQuery: r"""Set the FROM clause of this :class:`.Query` explicitly. :meth:`.Query.select_from` is often used in conjunction with @@ -2144,7 +2160,7 @@ class Query( self._distinct = True return self - def all(self): + def all(self) -> List[_T]: """Return the results represented by this :class:`_query.Query` as a list. @@ -2183,7 +2199,7 @@ class Query( self._statement = statement return self - def first(self): + def first(self) -> Optional[_T]: """Return the first result of this ``Query`` or None if the result doesn't contain any row. @@ -2209,7 +2225,7 @@ class Query( else: return self.limit(1)._iter().first() - def one_or_none(self): + def one_or_none(self) -> Optional[_T]: """Return at most one result or raise an exception. Returns ``None`` if the query selects @@ -2235,7 +2251,7 @@ class Query( """ return self._iter().one_or_none() - def one(self): + def one(self) -> _T: """Return exactly one result or raise an exception. Raises ``sqlalchemy.orm.exc.NoResultFound`` if the query selects @@ -2255,7 +2271,7 @@ class Query( """ return self._iter().one() - def scalar(self): + def scalar(self) -> Any: """Return the first element of the first result or None if no rows present. If multiple rows are returned, raises MultipleResultsFound. @@ -2283,7 +2299,7 @@ class Query( except orm_exc.NoResultFound: return None - def __iter__(self): + def __iter__(self) -> Iterable[_T]: return self._iter().__iter__() def _iter(self): @@ -2309,7 +2325,7 @@ class Query( return result - def __str__(self): + def __str__(self) -> str: statement = self._statement_20() try: @@ -2327,7 +2343,7 @@ class Query( return fn(clause=statement, **kw) @property - def column_descriptions(self): + def column_descriptions(self) -> List[ORMColumnDescription]: """Return metadata about the columns which would be returned by this :class:`_query.Query`. @@ -2368,7 +2384,7 @@ class Query( return _column_descriptions(self, legacy=True) - def instances(self, result_proxy, context=None): + def instances(self, result_proxy: Result, context=None) -> Any: """Return an ORM result given a :class:`_engine.CursorResult` and :class:`.QueryContext`. @@ -2400,6 +2416,7 @@ class Query( if result._attributes.get("filtered", False): result = result.unique() + # TODO: isn't this supposed to be a list? return result @util.became_legacy_20( @@ -2436,7 +2453,7 @@ class Query( return loading.merge_result(self, iterator, load) - def exists(self): + def exists(self) -> Exists: """A convenience method that turns a query into an EXISTS subquery of the form EXISTS (SELECT 1 FROM ... WHERE ...). diff --git a/lib/sqlalchemy/orm/relationships.py b/lib/sqlalchemy/orm/relationships.py index c5ea07051..1b8f778c0 100644 --- a/lib/sqlalchemy/orm/relationships.py +++ b/lib/sqlalchemy/orm/relationships.py @@ -13,10 +13,15 @@ SQL annotation and aliasing behavior focused on the `primaryjoin` and `secondaryjoin` aspects of :func:`_orm.relationship`. """ +from __future__ import annotations + import collections +from collections import abc import re +import typing from typing import Any from typing import Callable +from typing import Optional from typing import Type from typing import TypeVar from typing import Union @@ -26,11 +31,13 @@ from . import attributes from . import strategy_options from .base import _is_mapped_class from .base import state_str +from .interfaces import _IntrospectsAnnotations from .interfaces import MANYTOMANY from .interfaces import MANYTOONE from .interfaces import ONETOMANY from .interfaces import PropComparator from .interfaces import StrategizedProperty +from .util import _extract_mapped_subtype from .util import _orm_annotate from .util import _orm_deannotate from .util import CascadeOptions @@ -53,10 +60,26 @@ from ..sql.util import join_condition from ..sql.util import selectables_overlap from ..sql.util import visit_binary_product +if typing.TYPE_CHECKING: + from .mapper import Mapper + from .util import AliasedClass + from .util import AliasedInsp + _T = TypeVar("_T", bound=Any) _PT = TypeVar("_PT", bound=Any) +_RelationshipArgumentType = Union[ + str, + Type[_T], + Callable[[], Type[_T]], + "Mapper[_T]", + "AliasedClass[_T]", + Callable[[], "Mapper[_T]"], + Callable[[], "AliasedClass[_T]"], +] + + def remote(expr): """Annotate a portion of a primaryjoin expression with a 'remote' annotation. @@ -97,7 +120,9 @@ def foreign(expr): @log.class_logger -class RelationshipProperty(StrategizedProperty[_T]): +class Relationship( + _IntrospectsAnnotations, StrategizedProperty[_T], log.Identified +): """Describes an object property that holds a single item or list of items that correspond to a related database table. @@ -107,6 +132,10 @@ class RelationshipProperty(StrategizedProperty[_T]): :ref:`relationship_config_toplevel` + .. versionchanged:: 2.0 Renamed :class:`_orm.RelationshipProperty` + to :class:`_orm.Relationship`. The old name + :class:`_orm.RelationshipProperty` remains as an alias. + """ strategy_wildcard_key = strategy_options._RELATIONSHIP_TOKEN @@ -126,7 +155,7 @@ class RelationshipProperty(StrategizedProperty[_T]): def __init__( self, - argument: Union[str, Type[_T], Callable[[], Type[_T]]], + argument: Optional[_RelationshipArgumentType[_T]] = None, secondary=None, primaryjoin=None, secondaryjoin=None, @@ -162,7 +191,7 @@ class RelationshipProperty(StrategizedProperty[_T]): sync_backref=None, _legacy_inactive_history_style=False, ): - super(RelationshipProperty, self).__init__() + super(Relationship, self).__init__() self.uselist = uselist self.argument = argument @@ -221,9 +250,7 @@ class RelationshipProperty(StrategizedProperty[_T]): self.local_remote_pairs = _local_remote_pairs self.bake_queries = bake_queries self.load_on_pending = load_on_pending - self.comparator_factory = ( - comparator_factory or RelationshipProperty.Comparator - ) + self.comparator_factory = comparator_factory or Relationship.Comparator self.comparator = self.comparator_factory(self, None) util.set_creation_order(self) @@ -288,7 +315,7 @@ class RelationshipProperty(StrategizedProperty[_T]): class Comparator(PropComparator[_PT]): """Produce boolean, comparison, and other operators for - :class:`.RelationshipProperty` attributes. + :class:`.Relationship` attributes. See the documentation for :class:`.PropComparator` for a brief overview of ORM level operator definition. @@ -318,7 +345,7 @@ class RelationshipProperty(StrategizedProperty[_T]): of_type=None, extra_criteria=(), ): - """Construction of :class:`.RelationshipProperty.Comparator` + """Construction of :class:`.Relationship.Comparator` is internal to the ORM's attribute mechanics. """ @@ -340,7 +367,7 @@ class RelationshipProperty(StrategizedProperty[_T]): @util.memoized_property def entity(self): """The target entity referred to by this - :class:`.RelationshipProperty.Comparator`. + :class:`.Relationship.Comparator`. This is either a :class:`_orm.Mapper` or :class:`.AliasedInsp` object. @@ -360,7 +387,7 @@ class RelationshipProperty(StrategizedProperty[_T]): @util.memoized_property def mapper(self): """The target :class:`_orm.Mapper` referred to by this - :class:`.RelationshipProperty.Comparator`. + :class:`.Relationship.Comparator`. This is the "target" or "remote" side of the :func:`_orm.relationship`. @@ -411,7 +438,7 @@ class RelationshipProperty(StrategizedProperty[_T]): """ - return RelationshipProperty.Comparator( + return Relationship.Comparator( self.property, self._parententity, adapt_to_entity=self._adapt_to_entity, @@ -427,7 +454,7 @@ class RelationshipProperty(StrategizedProperty[_T]): .. versionadded:: 1.4 """ - return RelationshipProperty.Comparator( + return Relationship.Comparator( self.property, self._parententity, adapt_to_entity=self._adapt_to_entity, @@ -468,7 +495,7 @@ class RelationshipProperty(StrategizedProperty[_T]): many-to-one comparisons: * Comparisons against collections are not supported. - Use :meth:`~.RelationshipProperty.Comparator.contains`. + Use :meth:`~.Relationship.Comparator.contains`. * Compared to a scalar one-to-many, will produce a clause that compares the target columns in the parent to the given target. @@ -479,7 +506,7 @@ class RelationshipProperty(StrategizedProperty[_T]): queries that go beyond simple AND conjunctions of comparisons, such as those which use OR. Use explicit joins, outerjoins, or - :meth:`~.RelationshipProperty.Comparator.has` for + :meth:`~.Relationship.Comparator.has` for more comprehensive non-many-to-one scalar membership tests. * Comparisons against ``None`` given in a one-to-many @@ -613,12 +640,12 @@ class RelationshipProperty(StrategizedProperty[_T]): EXISTS (SELECT 1 FROM related WHERE related.my_id=my_table.id AND related.x=2) - Because :meth:`~.RelationshipProperty.Comparator.any` uses + Because :meth:`~.Relationship.Comparator.any` uses a correlated subquery, its performance is not nearly as good when compared against large target tables as that of using a join. - :meth:`~.RelationshipProperty.Comparator.any` is particularly + :meth:`~.Relationship.Comparator.any` is particularly useful for testing for empty collections:: session.query(MyClass).filter( @@ -631,10 +658,10 @@ class RelationshipProperty(StrategizedProperty[_T]): NOT (EXISTS (SELECT 1 FROM related WHERE related.my_id=my_table.id)) - :meth:`~.RelationshipProperty.Comparator.any` is only + :meth:`~.Relationship.Comparator.any` is only valid for collections, i.e. a :func:`_orm.relationship` that has ``uselist=True``. For scalar references, - use :meth:`~.RelationshipProperty.Comparator.has`. + use :meth:`~.Relationship.Comparator.has`. """ if not self.property.uselist: @@ -662,15 +689,15 @@ class RelationshipProperty(StrategizedProperty[_T]): EXISTS (SELECT 1 FROM related WHERE related.id==my_table.related_id AND related.x=2) - Because :meth:`~.RelationshipProperty.Comparator.has` uses + Because :meth:`~.Relationship.Comparator.has` uses a correlated subquery, its performance is not nearly as good when compared against large target tables as that of using a join. - :meth:`~.RelationshipProperty.Comparator.has` is only + :meth:`~.Relationship.Comparator.has` is only valid for scalar references, i.e. a :func:`_orm.relationship` that has ``uselist=False``. For collection references, - use :meth:`~.RelationshipProperty.Comparator.any`. + use :meth:`~.Relationship.Comparator.any`. """ if self.property.uselist: @@ -683,7 +710,7 @@ class RelationshipProperty(StrategizedProperty[_T]): """Return a simple expression that tests a collection for containment of a particular item. - :meth:`~.RelationshipProperty.Comparator.contains` is + :meth:`~.Relationship.Comparator.contains` is only valid for a collection, i.e. a :func:`_orm.relationship` that implements one-to-many or many-to-many with ``uselist=True``. @@ -700,12 +727,12 @@ class RelationshipProperty(StrategizedProperty[_T]): Where ``<some id>`` is the value of the foreign key attribute on ``other`` which refers to the primary key of its parent object. From this it follows that - :meth:`~.RelationshipProperty.Comparator.contains` is + :meth:`~.Relationship.Comparator.contains` is very useful when used with simple one-to-many operations. For many-to-many operations, the behavior of - :meth:`~.RelationshipProperty.Comparator.contains` + :meth:`~.Relationship.Comparator.contains` has more caveats. The association table will be rendered in the statement, producing an "implicit" join, that is, includes multiple tables in the FROM @@ -722,14 +749,14 @@ class RelationshipProperty(StrategizedProperty[_T]): Where ``<some id>`` would be the primary key of ``other``. From the above, it is clear that - :meth:`~.RelationshipProperty.Comparator.contains` + :meth:`~.Relationship.Comparator.contains` will **not** work with many-to-many collections when used in queries that move beyond simple AND conjunctions, such as multiple - :meth:`~.RelationshipProperty.Comparator.contains` + :meth:`~.Relationship.Comparator.contains` expressions joined by OR. In such cases subqueries or explicit "outer joins" will need to be used instead. - See :meth:`~.RelationshipProperty.Comparator.any` for + See :meth:`~.Relationship.Comparator.any` for a less-performant alternative using EXISTS, or refer to :meth:`_query.Query.outerjoin` as well as :ref:`ormtutorial_joins` @@ -818,7 +845,7 @@ class RelationshipProperty(StrategizedProperty[_T]): * Comparisons against collections are not supported. Use - :meth:`~.RelationshipProperty.Comparator.contains` + :meth:`~.Relationship.Comparator.contains` in conjunction with :func:`_expression.not_`. * Compared to a scalar one-to-many, will produce a clause that compares the target columns in the parent to @@ -830,7 +857,7 @@ class RelationshipProperty(StrategizedProperty[_T]): queries that go beyond simple AND conjunctions of comparisons, such as those which use OR. Use explicit joins, outerjoins, or - :meth:`~.RelationshipProperty.Comparator.has` in + :meth:`~.Relationship.Comparator.has` in conjunction with :func:`_expression.not_` for more comprehensive non-many-to-one scalar membership tests. @@ -1249,7 +1276,7 @@ class RelationshipProperty(StrategizedProperty[_T]): def _add_reverse_property(self, key): other = self.mapper.get_property(key, _configure_mappers=False) - if not isinstance(other, RelationshipProperty): + if not isinstance(other, Relationship): raise sa_exc.InvalidRequestError( "back_populates on relationship '%s' refers to attribute '%s' " "that is not a relationship. The back_populates parameter " @@ -1269,6 +1296,8 @@ class RelationshipProperty(StrategizedProperty[_T]): self._reverse_property.add(other) other._reverse_property.add(self) + other._setup_entity() + if not other.mapper.common_parent(self.parent): raise sa_exc.ArgumentError( "reverse_property %r on " @@ -1289,48 +1318,18 @@ class RelationshipProperty(StrategizedProperty[_T]): ) @util.memoized_property - @util.preload_module("sqlalchemy.orm.mapper") - def entity(self): + def entity(self) -> Union["Mapper", "AliasedInsp"]: """Return the target mapped entity, which is an inspect() of the class or aliased class that is referred towards. """ - - mapperlib = util.preloaded.orm_mapper - - if isinstance(self.argument, str): - argument = self._clsregistry_resolve_name(self.argument)() - - elif callable(self.argument) and not isinstance( - self.argument, (type, mapperlib.Mapper) - ): - argument = self.argument() - else: - argument = self.argument - - if isinstance(argument, type): - return mapperlib.class_mapper(argument, configure=False) - - try: - entity = inspect(argument) - except sa_exc.NoInspectionAvailable: - pass - else: - if hasattr(entity, "mapper"): - return entity - - raise sa_exc.ArgumentError( - "relationship '%s' expects " - "a class or a mapper argument (received: %s)" - % (self.key, type(argument)) - ) + self.parent._check_configure() + return self.entity @util.memoized_property - def mapper(self): + def mapper(self) -> "Mapper": """Return the targeted :class:`_orm.Mapper` for this - :class:`.RelationshipProperty`. - - This is a lazy-initializing static attribute. + :class:`.Relationship`. """ return self.entity.mapper @@ -1338,13 +1337,14 @@ class RelationshipProperty(StrategizedProperty[_T]): def do_init(self): self._check_conflicts() self._process_dependent_arguments() + self._setup_entity() self._setup_registry_dependencies() self._setup_join_conditions() self._check_cascade_settings(self._cascade) self._post_init() self._generate_backref() self._join_condition._warn_for_conflicting_sync_targets() - super(RelationshipProperty, self).do_init() + super(Relationship, self).do_init() self._lazy_strategy = self._get_strategy((("lazy", "select"),)) def _setup_registry_dependencies(self): @@ -1432,6 +1432,84 @@ class RelationshipProperty(StrategizedProperty[_T]): for x in util.to_column_set(self.remote_side) ) + def declarative_scan( + self, registry, cls, key, annotation, is_dataclass_field + ): + argument = _extract_mapped_subtype( + annotation, + cls, + key, + Relationship, + self.argument is None, + is_dataclass_field, + ) + if argument is None: + return + + if hasattr(argument, "__origin__"): + + collection_class = argument.__origin__ + if issubclass(collection_class, abc.Collection): + if self.collection_class is None: + self.collection_class = collection_class + else: + self.uselist = False + if argument.__args__: + if issubclass(argument.__origin__, typing.Mapping): + type_arg = argument.__args__[1] + else: + type_arg = argument.__args__[0] + if hasattr(type_arg, "__forward_arg__"): + str_argument = type_arg.__forward_arg__ + argument = str_argument + else: + argument = type_arg + else: + raise sa_exc.ArgumentError( + f"Generic alias {argument} requires an argument" + ) + elif hasattr(argument, "__forward_arg__"): + argument = argument.__forward_arg__ + + self.argument = argument + + @util.preload_module("sqlalchemy.orm.mapper") + def _setup_entity(self, __argument=None): + if "entity" in self.__dict__: + return + + mapperlib = util.preloaded.orm_mapper + + if __argument: + argument = __argument + else: + argument = self.argument + + if isinstance(argument, str): + argument = self._clsregistry_resolve_name(argument)() + elif callable(argument) and not isinstance( + argument, (type, mapperlib.Mapper) + ): + argument = argument() + else: + argument = argument + + if isinstance(argument, type): + entity = mapperlib.class_mapper(argument, configure=False) + else: + try: + entity = inspect(argument) + except sa_exc.NoInspectionAvailable: + entity = None + + if not hasattr(entity, "mapper"): + raise sa_exc.ArgumentError( + "relationship '%s' expects " + "a class or a mapper argument (received: %s)" + % (self.key, type(argument)) + ) + + self.entity = entity # type: ignore self.target = self.entity.persist_selectable def _setup_join_conditions(self): @@ -1502,7 +1580,7 @@ class RelationshipProperty(StrategizedProperty[_T]): @property def cascade(self): """Return the current cascade setting for this - :class:`.RelationshipProperty`. + :class:`.Relationship`. """ return self._cascade @@ -1666,7 +1744,7 @@ class RelationshipProperty(StrategizedProperty[_T]): kwargs.setdefault("passive_updates", self.passive_updates) kwargs.setdefault("sync_backref", self.sync_backref) self.back_populates = backref_key - relationship = RelationshipProperty( + relationship = Relationship( parent, self.secondary, pj, diff --git a/lib/sqlalchemy/orm/session.py b/lib/sqlalchemy/orm/session.py index cf47ee729..6911ab505 100644 --- a/lib/sqlalchemy/orm/session.py +++ b/lib/sqlalchemy/orm/session.py @@ -9,6 +9,15 @@ import contextlib import itertools import sys +import typing +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import overload +from typing import Tuple +from typing import Type +from typing import Union import weakref from . import attributes @@ -20,12 +29,15 @@ from . import persistence from . import query from . import state as statelib from .base import _class_to_mapper +from .base import _IdentityKeyType from .base import _none_set from .base import _state_mapper from .base import instance_str from .base import object_mapper from .base import object_state from .base import state_str +from .query import Query +from .state import InstanceState from .state_changes import _StateChange from .state_changes import _StateChangeState from .state_changes import _StateChangeStates @@ -34,14 +46,26 @@ from .. import engine from .. import exc as sa_exc from .. import sql from .. import util +from ..engine import Connection +from ..engine import Engine from ..engine.util import TransactionalContext from ..inspection import inspect from ..sql import coercions from ..sql import dml from ..sql import roles from ..sql import visitors +from ..sql._typing import _ColumnsClauseElement from ..sql.base import CompileState from ..sql.selectable import LABEL_STYLE_TABLENAME_PLUS_COL +from ..util.typing import Literal + +if typing.TYPE_CHECKING: + from .mapper import Mapper + from ..engine import Row + from ..sql._typing import _ExecuteOptions + from ..sql._typing import _ExecuteParams + from ..sql.base import Executable + from ..sql.schema import Table __all__ = [ "Session", @@ -78,23 +102,60 @@ class _SessionClassMethods: "removed in a future release. Please refer to " ":func:`.session.close_all_sessions`.", ) - def close_all(cls): + def close_all(cls) -> None: """Close *all* sessions in memory.""" close_all_sessions() @classmethod + @overload + def identity_key( + cls, + class_: type, + ident: Tuple[Any, ...], + *, + identity_token: Optional[str], + ) -> _IdentityKeyType: + ... + + @classmethod + @overload + def identity_key(cls, *, instance: Any) -> _IdentityKeyType: + ... + + @classmethod + @overload + def identity_key( + cls, class_: type, *, row: "Row", identity_token: Optional[str] + ) -> _IdentityKeyType: + ... + + @classmethod @util.preload_module("sqlalchemy.orm.util") - def identity_key(cls, *args, **kwargs): + def identity_key( + cls, + class_=None, + ident=None, + *, + instance=None, + row=None, + identity_token=None, + ) -> _IdentityKeyType: """Return an identity key. This is an alias of :func:`.util.identity_key`. """ - return util.preloaded.orm_util.identity_key(*args, **kwargs) + return util.preloaded.orm_util.identity_key( + class_, + ident, + instance=instance, + row=row, + identity_token=identity_token, + ) @classmethod - def object_session(cls, instance): + def object_session(cls, instance: Any) -> "Session": """Return the :class:`.Session` to which an object belongs. This is an alias of :func:`.object_session`. @@ -142,15 +203,26 @@ class ORMExecuteState(util.MemoizedSlots): "_update_execution_options", ) + session: "Session" + statement: "Executable" + parameters: "_ExecuteParams" + execution_options: "_ExecuteOptions" + local_execution_options: "_ExecuteOptions" + bind_arguments: Dict[str, Any] + _compile_state_cls: Type[context.ORMCompileState] + _starting_event_idx: Optional[int] + _events_todo: List[Any] + _update_execution_options: Optional["_ExecuteOptions"] + def __init__( self, - session, - statement, - parameters, - execution_options, - bind_arguments, - compile_state_cls, - events_todo, + session: "Session", + statement: "Executable", + parameters: "_ExecuteParams", + execution_options: "_ExecuteOptions", + bind_arguments: Dict[str, Any], + compile_state_cls: Type[context.ORMCompileState], + events_todo: List[Any], ): self.session = session self.statement = statement @@ -834,7 +906,7 @@ class SessionTransaction(_StateChange, TransactionalContext): (SessionTransactionState.ACTIVE, SessionTransactionState.PREPARED), SessionTransactionState.CLOSED, ) - def commit(self, _to_root=False): + def commit(self, _to_root: bool = False) -> None: if self._state is not SessionTransactionState.PREPARED: with self._expect_state(SessionTransactionState.PREPARED): self._prepare_impl() @@ -981,18 +1053,42 @@ class Session(_SessionClassMethods): _is_asyncio = False + identity_map: identity.IdentityMap + _new: Dict["InstanceState", Any] + _deleted: Dict["InstanceState", Any] + bind: Optional[Union[Engine, Connection]] + __binds: Dict[ + Union[type, "Mapper", "Table"], + Union[engine.Engine, engine.Connection], + ] + _flusing: bool + _warn_on_events: bool + _transaction: Optional[SessionTransaction] + _nested_transaction: Optional[SessionTransaction] + hash_key: int + autoflush: bool + expire_on_commit: bool + enable_baked_queries: bool + twophase: bool + _query_cls: Type[Query] + def __init__( self, - bind=None, - autoflush=True, - future=True, - expire_on_commit=True, - twophase=False, - binds=None, - enable_baked_queries=True, - info=None, - query_cls=None, - autocommit=False, + bind: Optional[Union[engine.Engine, engine.Connection]] = None, + autoflush: bool = True, + future: Literal[True] = True, + expire_on_commit: bool = True, + twophase: bool = False, + binds: Optional[ + Dict[ + Union[type, "Mapper", "Table"], + Union[engine.Engine, engine.Connection], + ] + ] = None, + enable_baked_queries: bool = True, + info: Optional[Dict[Any, Any]] = None, + query_cls: Optional[Type[query.Query]] = None, + autocommit: Literal[False] = False, ): r"""Construct a new Session. @@ -1054,7 +1150,8 @@ class Session(_SessionClassMethods): :class:`.sessionmaker` function, and is not sent directly to the constructor for ``Session``. - :param enable_baked_queries: defaults to ``True``. A flag consumed + :param enable_baked_queries: legacy; defaults to ``True``. + A parameter consumed by the :mod:`sqlalchemy.ext.baked` extension to determine if "baked queries" should be cached, as is the normal operation of this extension. When set to ``False``, caching as used by @@ -1331,7 +1428,7 @@ class Session(_SessionClassMethods): else: self._transaction.rollback(_to_root=True) - def commit(self): + def commit(self) -> None: """Flush pending changes and commit the current transaction. If no transaction is in progress, the method will first @@ -1353,7 +1450,7 @@ class Session(_SessionClassMethods): self._transaction.commit(_to_root=True) - def prepare(self): + def prepare(self) -> None: """Prepare the current transaction in progress for two phase commit. If no transaction is in progress, this method raises an @@ -1370,7 +1467,11 @@ class Session(_SessionClassMethods): self._transaction.prepare() - def connection(self, bind_arguments=None, execution_options=None): + def connection( + self, + bind_arguments: Optional[Dict[str, Any]] = None, + execution_options: Optional["_ExecuteOptions"] = None, + ) -> "Connection": r"""Return a :class:`_engine.Connection` object corresponding to this :class:`.Session` object's transactional state. @@ -1425,12 +1526,12 @@ class Session(_SessionClassMethods): def execute( self, - statement, - params=None, - execution_options=util.EMPTY_DICT, - bind_arguments=None, - _parent_execute_state=None, - _add_event=None, + statement: "Executable", + params: Optional["_ExecuteParams"] = None, + execution_options: "_ExecuteOptions" = util.EMPTY_DICT, + bind_arguments: Optional[Dict[str, Any]] = None, + _parent_execute_state: Optional[Any] = None, + _add_event: Optional[Any] = None, ): r"""Execute a SQL expression construct. @@ -1936,7 +2037,9 @@ class Session(_SessionClassMethods): % (", ".join(context),), ) - def query(self, *entities, **kwargs): + def query( + self, *entities: "_ColumnsClauseElement", **kwargs: Any + ) -> "Query": """Return a new :class:`_query.Query` object corresponding to this :class:`_orm.Session`. @@ -2391,7 +2494,7 @@ class Session(_SessionClassMethods): if persistent_to_deleted is not None: persistent_to_deleted(self, state) - def add(self, instance, _warn=True): + def add(self, instance: Any, _warn: bool = True) -> None: """Place an object in the ``Session``. Its state will be persisted to the database on the next flush diff --git a/lib/sqlalchemy/orm/strategies.py b/lib/sqlalchemy/orm/strategies.py index 07e71d4c0..316aa7ed7 100644 --- a/lib/sqlalchemy/orm/strategies.py +++ b/lib/sqlalchemy/orm/strategies.py @@ -34,7 +34,7 @@ from .interfaces import StrategizedProperty from .session import _state_session from .state import InstanceState from .util import _none_set -from .util import aliased +from .util import AliasedClass from .. import event from .. import exc as sa_exc from .. import inspect @@ -564,7 +564,7 @@ class AbstractRelationshipLoader(LoaderStrategy): @log.class_logger -@relationships.RelationshipProperty.strategy_for(do_nothing=True) +@relationships.Relationship.strategy_for(do_nothing=True) class DoNothingLoader(LoaderStrategy): """Relationship loader that makes no change to the object's state. @@ -576,10 +576,10 @@ class DoNothingLoader(LoaderStrategy): @log.class_logger -@relationships.RelationshipProperty.strategy_for(lazy="noload") -@relationships.RelationshipProperty.strategy_for(lazy=None) +@relationships.Relationship.strategy_for(lazy="noload") +@relationships.Relationship.strategy_for(lazy=None) class NoLoader(AbstractRelationshipLoader): - """Provide loading behavior for a :class:`.RelationshipProperty` + """Provide loading behavior for a :class:`.Relationship` with "lazy=None". """ @@ -617,13 +617,13 @@ class NoLoader(AbstractRelationshipLoader): @log.class_logger -@relationships.RelationshipProperty.strategy_for(lazy=True) -@relationships.RelationshipProperty.strategy_for(lazy="select") -@relationships.RelationshipProperty.strategy_for(lazy="raise") -@relationships.RelationshipProperty.strategy_for(lazy="raise_on_sql") -@relationships.RelationshipProperty.strategy_for(lazy="baked_select") +@relationships.Relationship.strategy_for(lazy=True) +@relationships.Relationship.strategy_for(lazy="select") +@relationships.Relationship.strategy_for(lazy="raise") +@relationships.Relationship.strategy_for(lazy="raise_on_sql") +@relationships.Relationship.strategy_for(lazy="baked_select") class LazyLoader(AbstractRelationshipLoader, util.MemoizedSlots): - """Provide loading behavior for a :class:`.RelationshipProperty` + """Provide loading behavior for a :class:`.Relationship` with "lazy=True", that is loads when first accessed. """ @@ -1214,7 +1214,7 @@ class PostLoader(AbstractRelationshipLoader): ) -@relationships.RelationshipProperty.strategy_for(lazy="immediate") +@relationships.Relationship.strategy_for(lazy="immediate") class ImmediateLoader(PostLoader): __slots__ = () @@ -1250,7 +1250,7 @@ class ImmediateLoader(PostLoader): @log.class_logger -@relationships.RelationshipProperty.strategy_for(lazy="subquery") +@relationships.Relationship.strategy_for(lazy="subquery") class SubqueryLoader(PostLoader): __slots__ = ("join_depth",) @@ -1906,10 +1906,10 @@ class SubqueryLoader(PostLoader): @log.class_logger -@relationships.RelationshipProperty.strategy_for(lazy="joined") -@relationships.RelationshipProperty.strategy_for(lazy=False) +@relationships.Relationship.strategy_for(lazy="joined") +@relationships.Relationship.strategy_for(lazy=False) class JoinedLoader(AbstractRelationshipLoader): - """Provide loading behavior for a :class:`.RelationshipProperty` + """Provide loading behavior for a :class:`.Relationship` using joined eager loading. """ @@ -2628,7 +2628,7 @@ class JoinedLoader(AbstractRelationshipLoader): @log.class_logger -@relationships.RelationshipProperty.strategy_for(lazy="selectin") +@relationships.Relationship.strategy_for(lazy="selectin") class SelectInLoader(PostLoader, util.MemoizedSlots): __slots__ = ( "join_depth", @@ -2721,7 +2721,7 @@ class SelectInLoader(PostLoader, util.MemoizedSlots): ) def _init_for_join(self): - self._parent_alias = aliased(self.parent.class_) + self._parent_alias = AliasedClass(self.parent.class_) pa_insp = inspect(self._parent_alias) pk_cols = [ pa_insp._adapt_element(col) for col in self.parent.primary_key diff --git a/lib/sqlalchemy/orm/strategy_options.py b/lib/sqlalchemy/orm/strategy_options.py index 0f993b86c..3f093e543 100644 --- a/lib/sqlalchemy/orm/strategy_options.py +++ b/lib/sqlalchemy/orm/strategy_options.py @@ -1808,7 +1808,7 @@ class _AttributeStrategyLoad(_LoadElement): assert pwpi if not pwpi.is_aliased_class: pwpi = inspect( - orm_util.with_polymorphic( + orm_util.AliasedInsp._with_polymorphic_factory( pwpi.mapper.base_mapper, pwpi.mapper, aliased=True, diff --git a/lib/sqlalchemy/orm/util.py b/lib/sqlalchemy/orm/util.py index 75f711007..45c578355 100644 --- a/lib/sqlalchemy/orm/util.py +++ b/lib/sqlalchemy/orm/util.py @@ -5,13 +5,22 @@ # This module is part of SQLAlchemy and is released under # the MIT License: https://www.opensource.org/licenses/mit-license.php - import re import types +import typing +from typing import Any +from typing import Generic +from typing import Optional +from typing import overload +from typing import Tuple +from typing import Type +from typing import TypeVar +from typing import Union import weakref from . import attributes # noqa from .base import _class_to_mapper # noqa +from .base import _IdentityKeyType from .base import _never_set # noqa from .base import _none_set # noqa from .base import attribute_str # noqa @@ -45,8 +54,17 @@ from ..sql import util as sql_util from ..sql import visitors from ..sql.annotation import SupportsCloneAnnotations from ..sql.base import ColumnCollection +from ..sql.selectable import FromClause from ..util.langhelpers import MemoizedSlots +from ..util.typing import de_stringify_annotation +from ..util.typing import is_origin_of + +if typing.TYPE_CHECKING: + from .mapper import Mapper + from ..engine import Row + from ..sql.selectable import Alias +_T = TypeVar("_T", bound=Any) all_cascades = frozenset( ( @@ -276,7 +294,28 @@ def polymorphic_union( return sql.union_all(*result).alias(aliasname) -def identity_key(*args, **kwargs): +@overload +def identity_key( + class_: type, ident: Tuple[Any, ...], *, identity_token: Optional[str] +) -> _IdentityKeyType: + ... + + +@overload +def identity_key(*, instance: Any) -> _IdentityKeyType: + ... + + +@overload +def identity_key( + class_: type, *, row: "Row", identity_token: Optional[str] +) -> _IdentityKeyType: + ... + + +def identity_key( + class_=None, ident=None, *, instance=None, row=None, identity_token=None +) -> _IdentityKeyType: r"""Generate "identity key" tuples, as are used as keys in the :attr:`.Session.identity_map` dictionary. @@ -340,29 +379,11 @@ def identity_key(*args, **kwargs): .. versionadded:: 1.2 added identity_token """ - if args: - row = None - largs = len(args) - if largs == 1: - class_ = args[0] - try: - row = kwargs.pop("row") - except KeyError: - ident = kwargs.pop("ident") - elif largs in (2, 3): - class_, ident = args - else: - raise sa_exc.ArgumentError( - "expected up to three positional arguments, " "got %s" % largs - ) - - identity_token = kwargs.pop("identity_token", None) - if kwargs: - raise sa_exc.ArgumentError( - "unknown keyword arguments: %s" % ", ".join(kwargs) - ) + if class_ is not None: mapper = class_mapper(class_) if row is None: + if ident is None: + raise sa_exc.ArgumentError("ident or row is required") return mapper.identity_key_from_primary_key( util.to_list(ident), identity_token=identity_token ) @@ -370,14 +391,11 @@ def identity_key(*args, **kwargs): return mapper.identity_key_from_row( row, identity_token=identity_token ) - else: - instance = kwargs.pop("instance") - if kwargs: - raise sa_exc.ArgumentError( - "unknown keyword arguments: %s" % ", ".join(kwargs.keys) - ) + elif instance is not None: mapper = object_mapper(instance) return mapper.identity_key_from_instance(instance) + else: + raise sa_exc.ArgumentError("class or instance is required") class ORMAdapter(sql_util.ColumnAdapter): @@ -420,7 +438,7 @@ class ORMAdapter(sql_util.ColumnAdapter): return not entity or entity.isa(self.mapper) -class AliasedClass: +class AliasedClass(inspection.Inspectable["AliasedInsp"], Generic[_T]): r"""Represents an "aliased" form of a mapped class for usage with Query. The ORM equivalent of a :func:`~sqlalchemy.sql.expression.alias` @@ -481,7 +499,7 @@ class AliasedClass: def __init__( self, - mapped_class_or_ac, + mapped_class_or_ac: Union[Type[_T], "Mapper[_T]", "AliasedClass[_T]"], alias=None, name=None, flat=False, @@ -611,6 +629,7 @@ class AliasedInsp( ORMEntityColumnsClauseRole, ORMFromClauseRole, sql_base.HasCacheKey, + roles.HasFromClauseElement, InspectionAttr, MemoizedSlots, ): @@ -747,6 +766,73 @@ class AliasedInsp( self._target = mapped_class_or_ac # self._target = mapper.class_ # mapped_class_or_ac + @classmethod + def _alias_factory( + cls, + element: Union[ + Type[_T], "Mapper[_T]", "FromClause", "AliasedClass[_T]" + ], + alias=None, + name=None, + flat=False, + adapt_on_names=False, + ) -> Union["AliasedClass[_T]", "Alias"]: + + if isinstance(element, FromClause): + if adapt_on_names: + raise sa_exc.ArgumentError( + "adapt_on_names only applies to ORM elements" + ) + if name: + return element.alias(name=name, flat=flat) + else: + return coercions.expect( + roles.AnonymizedFromClauseRole, element, flat=flat + ) + else: + return AliasedClass( + element, + alias=alias, + flat=flat, + name=name, + adapt_on_names=adapt_on_names, + ) + + @classmethod + def _with_polymorphic_factory( + cls, + base, + classes, + selectable=False, + flat=False, + polymorphic_on=None, + aliased=False, + innerjoin=False, + _use_mapper_path=False, + ): + + primary_mapper = _class_to_mapper(base) + + if selectable not in (None, False) and flat: + raise sa_exc.ArgumentError( + "the 'flat' and 'selectable' arguments cannot be passed " + "simultaneously to with_polymorphic()" + ) + + mappers, selectable = primary_mapper._with_polymorphic_args( + classes, selectable, innerjoin=innerjoin + ) + if aliased or flat: + selectable = selectable._anonymous_fromclause(flat=flat) + return AliasedClass( + base, + selectable, + with_polymorphic_mappers=mappers, + with_polymorphic_discriminator=polymorphic_on, + use_mapper_path=_use_mapper_path, + represents_outer_join=not innerjoin, + ) + @property def entity(self): # to eliminate reference cycles, the AliasedClass is held weakly. @@ -1107,215 +1193,6 @@ inspection._inspects(AliasedClass)(lambda target: target._aliased_insp) inspection._inspects(AliasedInsp)(lambda target: target) -def aliased(element, alias=None, name=None, flat=False, adapt_on_names=False): - """Produce an alias of the given element, usually an :class:`.AliasedClass` - instance. - - E.g.:: - - my_alias = aliased(MyClass) - - session.query(MyClass, my_alias).filter(MyClass.id > my_alias.id) - - The :func:`.aliased` function is used to create an ad-hoc mapping of a - mapped class to a new selectable. By default, a selectable is generated - from the normally mapped selectable (typically a :class:`_schema.Table` - ) using the - :meth:`_expression.FromClause.alias` method. However, :func:`.aliased` - can also be - used to link the class to a new :func:`_expression.select` statement. - Also, the :func:`.with_polymorphic` function is a variant of - :func:`.aliased` that is intended to specify a so-called "polymorphic - selectable", that corresponds to the union of several joined-inheritance - subclasses at once. - - For convenience, the :func:`.aliased` function also accepts plain - :class:`_expression.FromClause` constructs, such as a - :class:`_schema.Table` or - :func:`_expression.select` construct. In those cases, the - :meth:`_expression.FromClause.alias` - method is called on the object and the new - :class:`_expression.Alias` object returned. The returned - :class:`_expression.Alias` is not - ORM-mapped in this case. - - .. seealso:: - - :ref:`tutorial_orm_entity_aliases` - in the :ref:`unified_tutorial` - - :ref:`orm_queryguide_orm_aliases` - in the :ref:`queryguide_toplevel` - - :ref:`ormtutorial_aliases` - in the legacy :ref:`ormtutorial_toplevel` - - :param element: element to be aliased. Is normally a mapped class, - but for convenience can also be a :class:`_expression.FromClause` - element. - - :param alias: Optional selectable unit to map the element to. This is - usually used to link the object to a subquery, and should be an aliased - select construct as one would produce from the - :meth:`_query.Query.subquery` method or - the :meth:`_expression.Select.subquery` or - :meth:`_expression.Select.alias` methods of the :func:`_expression.select` - construct. - - :param name: optional string name to use for the alias, if not specified - by the ``alias`` parameter. The name, among other things, forms the - attribute name that will be accessible via tuples returned by a - :class:`_query.Query` object. Not supported when creating aliases - of :class:`_sql.Join` objects. - - :param flat: Boolean, will be passed through to the - :meth:`_expression.FromClause.alias` call so that aliases of - :class:`_expression.Join` objects will alias the individual tables - inside the join, rather than creating a subquery. This is generally - supported by all modern databases with regards to right-nested joins - and generally produces more efficient queries. - - :param adapt_on_names: if True, more liberal "matching" will be used when - mapping the mapped columns of the ORM entity to those of the - given selectable - a name-based match will be performed if the - given selectable doesn't otherwise have a column that corresponds - to one on the entity. The use case for this is when associating - an entity with some derived selectable such as one that uses - aggregate functions:: - - class UnitPrice(Base): - __tablename__ = 'unit_price' - ... - unit_id = Column(Integer) - price = Column(Numeric) - - aggregated_unit_price = Session.query( - func.sum(UnitPrice.price).label('price') - ).group_by(UnitPrice.unit_id).subquery() - - aggregated_unit_price = aliased(UnitPrice, - alias=aggregated_unit_price, adapt_on_names=True) - - Above, functions on ``aggregated_unit_price`` which refer to - ``.price`` will return the - ``func.sum(UnitPrice.price).label('price')`` column, as it is - matched on the name "price". Ordinarily, the "price" function - wouldn't have any "column correspondence" to the actual - ``UnitPrice.price`` column as it is not a proxy of the original. - - """ - if isinstance(element, expression.FromClause): - if adapt_on_names: - raise sa_exc.ArgumentError( - "adapt_on_names only applies to ORM elements" - ) - if name: - return element.alias(name=name, flat=flat) - else: - return coercions.expect( - roles.AnonymizedFromClauseRole, element, flat=flat - ) - else: - return AliasedClass( - element, - alias=alias, - flat=flat, - name=name, - adapt_on_names=adapt_on_names, - ) - - -def with_polymorphic( - base, - classes, - selectable=False, - flat=False, - polymorphic_on=None, - aliased=False, - innerjoin=False, - _use_mapper_path=False, -): - """Produce an :class:`.AliasedClass` construct which specifies - columns for descendant mappers of the given base. - - Using this method will ensure that each descendant mapper's - tables are included in the FROM clause, and will allow filter() - criterion to be used against those tables. The resulting - instances will also have those columns already loaded so that - no "post fetch" of those columns will be required. - - .. seealso:: - - :ref:`with_polymorphic` - full discussion of - :func:`_orm.with_polymorphic`. - - :param base: Base class to be aliased. - - :param classes: a single class or mapper, or list of - class/mappers, which inherit from the base class. - Alternatively, it may also be the string ``'*'``, in which case - all descending mapped classes will be added to the FROM clause. - - :param aliased: when True, the selectable will be aliased. For a - JOIN, this means the JOIN will be SELECTed from inside of a subquery - unless the :paramref:`_orm.with_polymorphic.flat` flag is set to - True, which is recommended for simpler use cases. - - :param flat: Boolean, will be passed through to the - :meth:`_expression.FromClause.alias` call so that aliases of - :class:`_expression.Join` objects will alias the individual tables - inside the join, rather than creating a subquery. This is generally - supported by all modern databases with regards to right-nested joins - and generally produces more efficient queries. Setting this flag is - recommended as long as the resulting SQL is functional. - - :param selectable: a table or subquery that will - be used in place of the generated FROM clause. This argument is - required if any of the desired classes use concrete table - inheritance, since SQLAlchemy currently cannot generate UNIONs - among tables automatically. If used, the ``selectable`` argument - must represent the full set of tables and columns mapped by every - mapped class. Otherwise, the unaccounted mapped columns will - result in their table being appended directly to the FROM clause - which will usually lead to incorrect results. - - When left at its default value of ``False``, the polymorphic - selectable assigned to the base mapper is used for selecting rows. - However, it may also be passed as ``None``, which will bypass the - configured polymorphic selectable and instead construct an ad-hoc - selectable for the target classes given; for joined table inheritance - this will be a join that includes all target mappers and their - subclasses. - - :param polymorphic_on: a column to be used as the "discriminator" - column for the given selectable. If not given, the polymorphic_on - attribute of the base classes' mapper will be used, if any. This - is useful for mappings that don't have polymorphic loading - behavior by default. - - :param innerjoin: if True, an INNER JOIN will be used. This should - only be specified if querying for one specific subtype only - """ - primary_mapper = _class_to_mapper(base) - - if selectable not in (None, False) and flat: - raise sa_exc.ArgumentError( - "the 'flat' and 'selectable' arguments cannot be passed " - "simultaneously to with_polymorphic()" - ) - - mappers, selectable = primary_mapper._with_polymorphic_args( - classes, selectable, innerjoin=innerjoin - ) - if aliased or flat: - selectable = selectable._anonymous_fromclause(flat=flat) - return AliasedClass( - base, - selectable, - with_polymorphic_mappers=mappers, - with_polymorphic_discriminator=polymorphic_on, - use_mapper_path=_use_mapper_path, - represents_outer_join=not innerjoin, - ) - - @inspection._self_inspects class Bundle( ORMColumnsClauseRole, @@ -1667,62 +1544,6 @@ class _ORMJoin(expression.Join): return _ORMJoin(self, right, onclause, isouter=True, full=full) -def join( - left, right, onclause=None, isouter=False, full=False, join_to_left=None -): - r"""Produce an inner join between left and right clauses. - - :func:`_orm.join` is an extension to the core join interface - provided by :func:`_expression.join()`, where the - left and right selectables may be not only core selectable - objects such as :class:`_schema.Table`, but also mapped classes or - :class:`.AliasedClass` instances. The "on" clause can - be a SQL expression, or an attribute or string name - referencing a configured :func:`_orm.relationship`. - - :func:`_orm.join` is not commonly needed in modern usage, - as its functionality is encapsulated within that of the - :meth:`_query.Query.join` method, which features a - significant amount of automation beyond :func:`_orm.join` - by itself. Explicit usage of :func:`_orm.join` - with :class:`_query.Query` involves usage of the - :meth:`_query.Query.select_from` method, as in:: - - from sqlalchemy.orm import join - session.query(User).\ - select_from(join(User, Address, User.addresses)).\ - filter(Address.email_address=='foo@bar.com') - - In modern SQLAlchemy the above join can be written more - succinctly as:: - - session.query(User).\ - join(User.addresses).\ - filter(Address.email_address=='foo@bar.com') - - See :meth:`_query.Query.join` for information on modern usage - of ORM level joins. - - .. deprecated:: 0.8 - - the ``join_to_left`` parameter is deprecated, and will be removed - in a future release. The parameter has no effect. - - """ - return _ORMJoin(left, right, onclause, isouter, full) - - -def outerjoin(left, right, onclause=None, full=False, join_to_left=None): - """Produce a left outer join between left and right clauses. - - This is the "outer join" version of the :func:`_orm.join` function, - featuring the same behavior except that an OUTER JOIN is generated. - See that function's documentation for other usage details. - - """ - return _ORMJoin(left, right, onclause, True, full) - - def with_parent(instance, prop, from_entity=None): """Create filtering criterion that relates this query's primary entity to the given related instance, using established @@ -1964,3 +1785,56 @@ def _getitem(iterable_query, item): return list(iterable_query)[-1] else: return list(iterable_query[item : item + 1])[0] + + +def _is_mapped_annotation(raw_annotation: Union[type, str], cls: type): + annotated = de_stringify_annotation(cls, raw_annotation) + return is_origin_of(annotated, "Mapped", module="sqlalchemy.orm") + + +def _extract_mapped_subtype( + raw_annotation: Union[type, str], + cls: type, + key: str, + attr_cls: type, + required: bool, + is_dataclass_field: bool, +) -> Optional[Union[type, str]]: + + if raw_annotation is None: + + if required: + raise sa_exc.ArgumentError( + f"Python typing annotation is required for attribute " + f'"{cls.__name__}.{key}" when primary argument(s) for ' + f'"{attr_cls.__name__}" construct are None or not present' + ) + return None + + annotated = de_stringify_annotation(cls, raw_annotation) + + if is_dataclass_field: + return annotated + else: + if ( + not hasattr(annotated, "__origin__") + or not issubclass(annotated.__origin__, attr_cls) + and not issubclass(attr_cls, annotated.__origin__) + ): + our_annotated_str = ( + annotated.__name__ + if not isinstance(annotated, str) + else repr(annotated) + ) + raise sa_exc.ArgumentError( + f'Type annotation for "{cls.__name__}.{key}" should use the ' + f'syntax "Mapped[{our_annotated_str}]" or ' + f'"{attr_cls.__name__}[{our_annotated_str}]".' + ) + + if len(annotated.__args__) != 1: + raise sa_exc.ArgumentError( + "Expected sub-type for Mapped[] annotation" + ) + + return annotated.__args__[0] |