diff options
author | mike bayer <mike_mp@zzzcomputing.com> | 2022-11-30 14:04:34 +0000 |
---|---|---|
committer | Gerrit Code Review <gerrit@ci3.zzzcomputing.com> | 2022-11-30 14:04:34 +0000 |
commit | 1057b47bca2522e45d9621a709d033aa4fb88888 (patch) | |
tree | 4ca4cd649b2f4f5f068051a7c2d74815e9d52f1a /lib/sqlalchemy | |
parent | 7857a1de32169858367446d11089c34f8daee957 (diff) | |
parent | 3e3e3ab0d46b8912649afc7c3eb63b76c19d93fe (diff) | |
download | sqlalchemy-1057b47bca2522e45d9621a709d033aa4fb88888.tar.gz |
Merge "annotated / DC forms for association proxy" into main
Diffstat (limited to 'lib/sqlalchemy')
-rw-r--r-- | lib/sqlalchemy/ext/associationproxy.py | 223 | ||||
-rw-r--r-- | lib/sqlalchemy/ext/mutable.py | 47 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/_orm_constructors.py | 3 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/decl_base.py | 102 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/interfaces.py | 38 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/relationships.py | 7 |
6 files changed, 260 insertions, 160 deletions
diff --git a/lib/sqlalchemy/ext/associationproxy.py b/lib/sqlalchemy/ext/associationproxy.py index f4adf3d29..15193e563 100644 --- a/lib/sqlalchemy/ext/associationproxy.py +++ b/lib/sqlalchemy/ext/associationproxy.py @@ -19,6 +19,7 @@ import operator import typing from typing import AbstractSet from typing import Any +from typing import Callable from typing import cast from typing import Collection from typing import Dict @@ -51,8 +52,12 @@ from ..orm import InspectionAttrExtensionType from ..orm import interfaces from ..orm import ORMDescriptor from ..orm.base import SQLORMOperations +from ..orm.interfaces import _AttributeOptions +from ..orm.interfaces import _DCAttributeOptions +from ..orm.interfaces import _DEFAULT_ATTRIBUTE_OPTIONS from ..sql import operators from ..sql import or_ +from ..sql.base import _NoArg from ..util.typing import Literal from ..util.typing import Protocol from ..util.typing import Self @@ -76,7 +81,20 @@ _VT = TypeVar("_VT", bound=Any) def association_proxy( - target_collection: str, attr: str, **kw: Any + target_collection: str, + attr: str, + *, + creator: Optional[_CreatorProtocol] = None, + getset_factory: Optional[_GetSetFactoryProtocol] = None, + proxy_factory: Optional[_ProxyFactoryProtocol] = None, + proxy_bulk_set: Optional[_ProxyBulkSetProtocol] = None, + info: Optional[_InfoType] = None, + cascade_scalar_deletes: bool = False, + init: Union[_NoArg, bool] = _NoArg.NO_ARG, + repr: Union[_NoArg, bool] = _NoArg.NO_ARG, # noqa: A002 + default: Optional[Any] = _NoArg.NO_ARG, + default_factory: Union[_NoArg, Callable[[], _T]] = _NoArg.NO_ARG, + kw_only: Union[_NoArg, bool] = _NoArg.NO_ARG, ) -> AssociationProxy[Any]: r"""Return a Python property implementing a view of a target attribute which references an attribute on members of the @@ -89,47 +107,120 @@ def association_proxy( the collection type of the target (list, dict or set), or, in the case of a one to one relationship, a simple scalar value. - :param target_collection: Name of the attribute we'll proxy to. - This attribute is typically mapped by + :param target_collection: Name of the attribute that is the immediate + target. This attribute is typically mapped by :func:`~sqlalchemy.orm.relationship` to link to a target collection, but can also be a many-to-one or non-scalar relationship. - :param attr: Attribute on the associated instance or instances we'll - proxy for. + :param attr: Attribute on the associated instance or instances that + are available on instances of the target object. - For example, given a target collection of [obj1, obj2], a list created - by this proxy property would look like [getattr(obj1, *attr*), - getattr(obj2, *attr*)] + :param creator: optional. - If the relationship is one-to-one or otherwise uselist=False, then - simply: getattr(obj, *attr*) + Defines custom behavior when new items are added to the proxied + collection. - :param creator: optional. + By default, adding new items to the collection will trigger a + construction of an instance of the target object, passing the given + item as a positional argument to the target constructor. For cases + where this isn't sufficient, :paramref:`.association_proxy.creator` + can supply a callable that will construct the object in the + appropriate way, given the item that was passed. + + For list- and set- oriented collections, a single argument is + passed to the callable. For dictionary oriented collections, two + arguments are passed, corresponding to the key and value. + + The :paramref:`.association_proxy.creator` callable is also invoked + for scalar (i.e. many-to-one, one-to-one) relationships. If the + current value of the target relationship attribute is ``None``, the + callable is used to construct a new object. If an object value already + exists, the given attribute value is populated onto that object. + + .. seealso:: + + :ref:`associationproxy_creator` + + :param cascade_scalar_deletes: when True, indicates that setting + the proxied value to ``None``, or deleting it via ``del``, should + also remove the source object. Only applies to scalar attributes. + Normally, removing the proxied target will not remove the proxy + source, as this object may have other state that is still to be + kept. + + .. versionadded:: 1.3 + + .. seealso:: + + :ref:`cascade_scalar_deletes` - complete usage example + + :param init: Specific to :ref:`orm_declarative_native_dataclasses`, + specifies if the mapped attribute should be part of the ``__init__()`` + method as generated by the dataclass process. + + .. versionadded:: 2.0.0b4 + + :param repr: Specific to :ref:`orm_declarative_native_dataclasses`, + specifies if the attribute established by this :class:`.AssociationProxy` + should be part of the ``__repr__()`` method as generated by the dataclass + process. + + .. versionadded:: 2.0.0b4 + + :param default_factory: Specific to + :ref:`orm_declarative_native_dataclasses`, specifies a default-value + generation function that will take place as part of the ``__init__()`` + method as generated by the dataclass process. + + .. versionadded:: 2.0.0b4 + + :param kw_only: Specific to :ref:`orm_declarative_native_dataclasses`, + indicates if this field should be marked as keyword-only when generating + the ``__init__()`` method as generated by the dataclass process. + + .. versionadded:: 2.0.0b4 - When new items are added to this proxied collection, new instances of - the class collected by the target collection will be created. For list - and set collections, the target class constructor will be called with - the 'value' for the new instance. For dict types, two arguments are - passed: key and value. + :param info: optional, will be assigned to + :attr:`.AssociationProxy.info` if present. - If you want to construct instances differently, supply a *creator* - function that takes arguments as above and returns instances. - For scalar relationships, creator() will be called if the target is None. - If the target is present, set operations are proxied to setattr() on the - associated object. + The following additional parameters involve injection of custom behaviors + within the :class:`.AssociationProxy` object and are for advanced use + only: - If you have an associated object with multiple attributes, you may set - up multiple association proxies mapping to different attributes. See - the unit tests for examples, and for examples of how creator() functions - can be used to construct the scalar relationship on-demand in this - situation. + :param getset_factory: Optional. Proxied attribute access is + automatically handled by routines that get and set values based on + the `attr` argument for this proxy. + + If you would like to customize this behavior, you may supply a + `getset_factory` callable that produces a tuple of `getter` and + `setter` functions. The factory is called with two arguments, the + abstract type of the underlying collection and this proxy instance. + + :param proxy_factory: Optional. The type of collection to emulate is + determined by sniffing the target collection. If your collection + type can't be determined by duck typing or you'd like to use a + different collection implementation, you may supply a factory + function to produce those collections. Only applicable to + non-scalar relationships. + + :param proxy_bulk_set: Optional, use with proxy_factory. - :param \*\*kw: Passes along any other keyword arguments to - :class:`.AssociationProxy`. """ - return AssociationProxy(target_collection, attr, **kw) + return AssociationProxy( + target_collection, + attr, + creator=creator, + getset_factory=getset_factory, + proxy_factory=proxy_factory, + proxy_bulk_set=proxy_bulk_set, + info=info, + cascade_scalar_deletes=cascade_scalar_deletes, + attribute_options=_AttributeOptions( + init, repr, default, default_factory, kw_only + ), + ) class AssociationProxyExtensionType(InspectionAttrExtensionType): @@ -247,6 +338,7 @@ _SelfAssociationProxy = TypeVar( class AssociationProxy( interfaces.InspectionAttrInfo, ORMDescriptor[_T], + _DCAttributeOptions, _AssociationProxyProtocol[_T], ): """A descriptor that presents a read/write view of an object attribute.""" @@ -258,73 +350,22 @@ class AssociationProxy( self, target_collection: str, attr: str, + *, creator: Optional[_CreatorProtocol] = None, getset_factory: Optional[_GetSetFactoryProtocol] = None, proxy_factory: Optional[_ProxyFactoryProtocol] = None, proxy_bulk_set: Optional[_ProxyBulkSetProtocol] = None, info: Optional[_InfoType] = None, cascade_scalar_deletes: bool = False, + attribute_options: Optional[_AttributeOptions] = None, ): """Construct a new :class:`.AssociationProxy`. - The :func:`.association_proxy` function is provided as the usual - entrypoint here, though :class:`.AssociationProxy` can be instantiated - and/or subclassed directly. - - :param target_collection: Name of the collection we'll proxy to, - usually created with :func:`_orm.relationship`. - - :param attr: Attribute on the collected instances we'll proxy - for. For example, given a target collection of [obj1, obj2], a - list created by this proxy property would look like - [getattr(obj1, attr), getattr(obj2, attr)] - - :param creator: Optional. When new items are added to this proxied - collection, new instances of the class collected by the target - collection will be created. For list and set collections, the - target class constructor will be called with the 'value' for the - new instance. For dict types, two arguments are passed: - key and value. - - If you want to construct instances differently, supply a 'creator' - function that takes arguments as above and returns instances. - - :param cascade_scalar_deletes: when True, indicates that setting - the proxied value to ``None``, or deleting it via ``del``, should - also remove the source object. Only applies to scalar attributes. - Normally, removing the proxied target will not remove the proxy - source, as this object may have other state that is still to be - kept. - - .. versionadded:: 1.3 - - .. seealso:: + The :class:`.AssociationProxy` object is typically constructed using + the :func:`.association_proxy` constructor function. See the + description of :func:`.association_proxy` for a description of all + parameters. - :ref:`cascade_scalar_deletes` - complete usage example - - :param getset_factory: Optional. Proxied attribute access is - automatically handled by routines that get and set values based on - the `attr` argument for this proxy. - - If you would like to customize this behavior, you may supply a - `getset_factory` callable that produces a tuple of `getter` and - `setter` functions. The factory is called with two arguments, the - abstract type of the underlying collection and this proxy instance. - - :param proxy_factory: Optional. The type of collection to emulate is - determined by sniffing the target collection. If your collection - type can't be determined by duck typing or you'd like to use a - different collection implementation, you may supply a factory - function to produce those collections. Only applicable to - non-scalar relationships. - - :param proxy_bulk_set: Optional, use with proxy_factory. See - the _set() method for details. - - :param info: optional, will be assigned to - :attr:`.AssociationProxy.info` if present. - - .. versionadded:: 1.0.9 """ self.target_collection = target_collection @@ -343,6 +384,16 @@ class AssociationProxy( if info: self.info = info # type: ignore + if ( + attribute_options + and attribute_options != _DEFAULT_ATTRIBUTE_OPTIONS + ): + self._has_dataclass_arguments = True + self._attribute_options = attribute_options + else: + self._has_dataclass_arguments = False + self._attribute_options = _DEFAULT_ATTRIBUTE_OPTIONS + @overload def __get__( self: _SelfAssociationProxy, instance: Any, owner: Literal[None] diff --git a/lib/sqlalchemy/ext/mutable.py b/lib/sqlalchemy/ext/mutable.py index f9ed17efc..242f5ee8f 100644 --- a/lib/sqlalchemy/ext/mutable.py +++ b/lib/sqlalchemy/ext/mutable.py @@ -111,34 +111,27 @@ Above, :meth:`~.Mutable.as_mutable` returns an instance of ``JSONEncodedDict`` attributes which are mapped against this type. Below we establish a simple mapping against the ``my_data`` table:: - from sqlalchemy import mapper + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy.orm import Mapped + from sqlalchemy.orm import mapped_column - class MyDataClass: + class Base(DeclarativeBase): pass - # associates mutation listeners with MyDataClass.data - mapper(MyDataClass, my_data) + class MyDataClass(Base): + __tablename__ = 'my_data' + id: Mapped[int] = mapped_column(primary_key=True) + data: Mapped[dict[str, str]] = mapped_column(MutableDict.as_mutable(JSONEncodedDict)) The ``MyDataClass.data`` member will now be notified of in place changes to its value. -There's no difference in usage when using declarative:: - - from sqlalchemy.ext.declarative import declarative_base - - Base = declarative_base() - - class MyDataClass(Base): - __tablename__ = 'my_data' - id = Column(Integer, primary_key=True) - data = Column(MutableDict.as_mutable(JSONEncodedDict)) - Any in-place changes to the ``MyDataClass.data`` member will flag the attribute as "dirty" on the parent object:: >>> from sqlalchemy.orm import Session - >>> sess = Session() + >>> sess = Session(some_engine) >>> m1 = MyDataClass(data={'value1':'foo'}) >>> sess.add(m1) >>> sess.commit() @@ -154,12 +147,19 @@ of ``JSONEncodedDict`` in one step, using of ``MutableDict`` in all mappings unconditionally, without the need to declare it individually:: + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy.orm import Mapped + from sqlalchemy.orm import mapped_column + MutableDict.associate_with(JSONEncodedDict) + class Base(DeclarativeBase): + pass + class MyDataClass(Base): __tablename__ = 'my_data' - id = Column(Integer, primary_key=True) - data = Column(JSONEncodedDict) + id: Mapped[int] = mapped_column(primary_key=True) + data: Mapped[dict[str, str]] = mapped_column(JSONEncodedDict) Supporting Pickling @@ -208,15 +208,18 @@ an event when a mutable scalar emits a change event. This event handler is called when the :func:`.attributes.flag_modified` function is called from within the mutable extension:: - from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import DeclarativeBase + from sqlalchemy.orm import Mapped + from sqlalchemy.orm import mapped_column from sqlalchemy import event - Base = declarative_base() + class Base(DeclarativeBase): + pass class MyDataClass(Base): __tablename__ = 'my_data' - id = Column(Integer, primary_key=True) - data = Column(MutableDict.as_mutable(JSONEncodedDict)) + id: Mapped[int] = mapped_column(primary_key=True) + data: Mapped[dict[str, str]] = mapped_column(MutableDict.as_mutable(JSONEncodedDict)) @event.listens_for(MyDataClass.data, "modified") def modified_json(instance, initiator): diff --git a/lib/sqlalchemy/orm/_orm_constructors.py b/lib/sqlalchemy/orm/_orm_constructors.py index c4abb1c8e..2450d1e83 100644 --- a/lib/sqlalchemy/orm/_orm_constructors.py +++ b/lib/sqlalchemy/orm/_orm_constructors.py @@ -29,6 +29,7 @@ from .properties import MappedColumn from .properties import MappedSQLExpression from .query import AliasOption from .relationships import _RelationshipArgumentType +from .relationships import _RelationshipSecondaryArgument from .relationships import Relationship from .relationships import RelationshipProperty from .session import Session @@ -736,7 +737,7 @@ def with_loader_criteria( def relationship( argument: Optional[_RelationshipArgumentType[Any]] = None, - secondary: Optional[Union[FromClause, str]] = None, + secondary: Optional[_RelationshipSecondaryArgument] = None, *, uselist: Optional[bool] = None, collection_class: Optional[ diff --git a/lib/sqlalchemy/orm/decl_base.py b/lib/sqlalchemy/orm/decl_base.py index 21e3c3344..1e716e687 100644 --- a/lib/sqlalchemy/orm/decl_base.py +++ b/lib/sqlalchemy/orm/decl_base.py @@ -44,6 +44,7 @@ from .base import InspectionAttr from .descriptor_props import CompositeProperty from .descriptor_props import SynonymProperty from .interfaces import _AttributeOptions +from .interfaces import _DCAttributeOptions from .interfaces import _IntrospectsAnnotations from .interfaces import _MappedAttribute from .interfaces import _MapsColumns @@ -1262,6 +1263,8 @@ class _ClassScanMapperConfig(_MapperConfig): or self.is_dataclass_prior_to_mapping ) + look_for_dataclass_things = bool(self.dataclass_setup_arguments) + for k in list(collected_attributes): if k in _include_dunders: @@ -1304,15 +1307,21 @@ class _ClassScanMapperConfig(_MapperConfig): "accidentally placed at the end of the line?" % k ) continue - elif not isinstance(value, (Column, MapperProperty, _MapsColumns)): + elif look_for_dataclass_things and isinstance( + value, dataclasses.Field + ): + # we collected a dataclass Field; dataclasses would have + # set up the correct state on the class + continue + elif not isinstance(value, (Column, _DCAttributeOptions)): # using @declared_attr for some object that - # isn't Column/MapperProperty; remove from the clsdict_view + # isn't Column/MapperProperty/_DCAttributeOptions; remove + # from the clsdict_view # and place the evaluated value onto the class. - if not k.startswith("__"): - collected_attributes.pop(k) - self._warn_for_decl_attributes(cls, k, value) - if not late_mapped: - setattr(cls, k, value) + collected_attributes.pop(k) + self._warn_for_decl_attributes(cls, k, value) + if not late_mapped: + setattr(cls, k, value) continue # we expect to see the name 'metadata' in some valid cases; # however at this point we see it's assigned to something trying @@ -1372,38 +1381,59 @@ class _ClassScanMapperConfig(_MapperConfig): # by util._extract_mapped_subtype before we got here. assert expect_annotations_wo_mapped - if ( - isinstance(value, (MapperProperty, _MapsColumns)) - and value._has_dataclass_arguments - and not self.dataclass_setup_arguments - ): - if isinstance(value, MapperProperty): - argnames = [ - "init", - "default_factory", - "repr", - "default", - ] - else: - argnames = ["init", "default_factory", "repr"] + if isinstance(value, _DCAttributeOptions): + + if ( + value._has_dataclass_arguments + and not look_for_dataclass_things + ): + if isinstance(value, MapperProperty): + argnames = [ + "init", + "default_factory", + "repr", + "default", + ] + else: + argnames = ["init", "default_factory", "repr"] + + args = { + a + for a in argnames + if getattr( + value._attribute_options, f"dataclasses_{a}" + ) + is not _NoArg.NO_ARG + } - args = { - a - for a in argnames - if getattr( - value._attribute_options, f"dataclasses_{a}" + raise exc.ArgumentError( + f"Attribute '{k}' on class {cls} includes " + f"dataclasses argument(s): " + f"{', '.join(sorted(repr(a) for a in args))} but " + f"class does not specify " + "SQLAlchemy native dataclass configuration." ) - is not _NoArg.NO_ARG - } - raise exc.ArgumentError( - f"Attribute '{k}' on class {cls} includes dataclasses " - f"argument(s): " - f"{', '.join(sorted(repr(a) for a in args))} but " - f"class does not specify " - "SQLAlchemy native dataclass configuration." - ) - our_stuff[k] = value + if not isinstance(value, (MapperProperty, _MapsColumns)): + # filter for _DCAttributeOptions objects that aren't + # MapperProperty / mapped_column(). Currently this + # includes AssociationProxy. pop it from the things + # we're going to map and set it up as a descriptor + # on the class. + collected_attributes.pop(k) + + # Assoc Prox (or other descriptor object that may + # use _DCAttributeOptions) is usually here, except if + # 1. we're a + # dataclass, dataclasses would have removed the + # attr here or 2. assoc proxy is coming from a + # superclass, we want it to be direct here so it + # tracks state or 3. assoc prox comes from + # declared_attr, uncommon case + setattr(cls, k, value) + continue + + our_stuff[k] = value # type: ignore def _extract_declared_columns(self) -> None: our_stuff = self.properties diff --git a/lib/sqlalchemy/orm/interfaces.py b/lib/sqlalchemy/orm/interfaces.py index ff003f654..3d2f9708f 100644 --- a/lib/sqlalchemy/orm/interfaces.py +++ b/lib/sqlalchemy/orm/interfaces.py @@ -230,7 +230,7 @@ class _AttributeOptions(NamedTuple): for this attribute. """ - if isinstance(elem, (MapperProperty, _MapsColumns)): + if isinstance(elem, _DCAttributeOptions): dc_field = elem._attribute_options._as_dataclass_field() return (key, annotation, dc_field) @@ -260,16 +260,36 @@ _DEFAULT_ATTRIBUTE_OPTIONS = _AttributeOptions( ) -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. +class _DCAttributeOptions: + """mixin for descriptors or configurational objects that include dataclass + field options. + + This includes :class:`.MapperProperty`, :class:`._MapsColumn` within + the ORM, but also includes :class:`.AssociationProxy` within ext. + Can in theory be used for other descriptors that serve a similar role + as association proxy. (*maybe* hybrids, not sure yet.) + """ __slots__ = () _attribute_options: _AttributeOptions + """behavioral options for ORM-enabled Python attributes + + .. versionadded:: 2.0 + + """ + _has_dataclass_arguments: bool + +class _MapsColumns(_DCAttributeOptions, _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""" @@ -296,6 +316,7 @@ class _MapsColumns(_MappedAttribute[_T]): @inspection._self_inspects class MapperProperty( HasCacheKey, + _DCAttributeOptions, _MappedAttribute[_T], InspectionAttrInfo, util.MemoizedSlots, @@ -358,13 +379,6 @@ class MapperProperty( doc: Optional[str] """optional documentation string""" - _attribute_options: _AttributeOptions - """behavioral options for ORM-enabled Python attributes - - .. versionadded:: 2.0 - - """ - info: _InfoType """Info dictionary associated with the object, allowing user-defined data to be associated with this :class:`.InspectionAttr`. @@ -386,8 +400,6 @@ class MapperProperty( """ - _has_dataclass_arguments: bool - def _memoized_attr_info(self) -> _InfoType: """Info dictionary associated with the object, allowing user-defined data to be associated with this :class:`.InspectionAttr`. diff --git a/lib/sqlalchemy/orm/relationships.py b/lib/sqlalchemy/orm/relationships.py index 443801b32..73d11e880 100644 --- a/lib/sqlalchemy/orm/relationships.py +++ b/lib/sqlalchemy/orm/relationships.py @@ -160,6 +160,9 @@ _LazyLoadArgumentType = Literal[ _RelationshipJoinConditionArgument = Union[ str, _ColumnExpressionArgument[bool] ] +_RelationshipSecondaryArgument = Union[ + "FromClause", str, Callable[[], "FromClause"] +] _ORMOrderByArgument = Union[ Literal[False], str, @@ -269,7 +272,7 @@ class _RelationshipArgs(NamedTuple): """ secondary: _RelationshipArg[ - Optional[Union[FromClause, str]], + Optional[_RelationshipSecondaryArgument], Optional[FromClause], ] primaryjoin: _RelationshipArg[ @@ -352,7 +355,7 @@ class RelationshipProperty( def __init__( self, argument: Optional[_RelationshipArgumentType[_T]] = None, - secondary: Optional[Union[FromClause, str]] = None, + secondary: Optional[_RelationshipSecondaryArgument] = None, *, uselist: Optional[bool] = None, collection_class: Optional[ |