diff options
Diffstat (limited to 'lib/sqlalchemy')
-rw-r--r-- | lib/sqlalchemy/orm/__init__.py | 4 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/base.py | 9 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/interfaces.py | 1 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/mapped_collection.py | 203 |
4 files changed, 185 insertions, 32 deletions
diff --git a/lib/sqlalchemy/orm/__init__.py b/lib/sqlalchemy/orm/__init__.py index 3a0f425fc..7f0de6782 100644 --- a/lib/sqlalchemy/orm/__init__.py +++ b/lib/sqlalchemy/orm/__init__.py @@ -46,9 +46,11 @@ from .attributes import InstrumentedAttribute as InstrumentedAttribute from .attributes import QueryableAttribute as QueryableAttribute from .base import class_mapper as class_mapper from .base import InspectionAttrExtensionType as InspectionAttrExtensionType +from .base import LoaderCallableStatus as LoaderCallableStatus from .base import Mapped as Mapped from .base import NotExtension as NotExtension from .base import ORMDescriptor as ORMDescriptor +from .base import PassiveFlag as PassiveFlag from .context import FromStatement as FromStatement from .context import QueryContext as QueryContext from .decl_api import add_mapped_attribute as add_mapped_attribute @@ -83,8 +85,10 @@ from .interfaces import MANYTOMANY as MANYTOMANY from .interfaces import MANYTOONE as MANYTOONE from .interfaces import MapperProperty as MapperProperty from .interfaces import NO_KEY as NO_KEY +from .interfaces import NO_VALUE as NO_VALUE from .interfaces import ONETOMANY as ONETOMANY from .interfaces import PropComparator as PropComparator +from .interfaces import RelationshipDirection as RelationshipDirection from .interfaces import UserDefinedOption as UserDefinedOption from .loading import merge_frozen_result as merge_frozen_result from .loading import merge_result as merge_result diff --git a/lib/sqlalchemy/orm/base.py b/lib/sqlalchemy/orm/base.py index 66b7b8c2e..47ae99efe 100644 --- a/lib/sqlalchemy/orm/base.py +++ b/lib/sqlalchemy/orm/base.py @@ -209,6 +209,15 @@ EXT_CONTINUE, EXT_STOP, EXT_SKIP, NO_KEY = tuple(EventConstants) class RelationshipDirection(Enum): + """enumeration which indicates the 'direction' of a + :class:`_orm.Relationship`. + + :class:`.RelationshipDirection` is accessible from the + :attr:`_orm.Relationship.direction` attribute of + :class:`_orm.Relationship`. + + """ + ONETOMANY = 1 """Indicates the one-to-many direction for a :func:`_orm.relationship`. diff --git a/lib/sqlalchemy/orm/interfaces.py b/lib/sqlalchemy/orm/interfaces.py index 72f5c6a7b..452a26103 100644 --- a/lib/sqlalchemy/orm/interfaces.py +++ b/lib/sqlalchemy/orm/interfaces.py @@ -50,6 +50,7 @@ from .base import InspectionAttrInfo as InspectionAttrInfo from .base import MANYTOMANY as MANYTOMANY # noqa: F401 from .base import MANYTOONE as MANYTOONE # noqa: F401 from .base import NO_KEY as NO_KEY # noqa: F401 +from .base import NO_VALUE as NO_VALUE # noqa: F401 from .base import NotExtension as NotExtension # noqa: F401 from .base import ONETOMANY as ONETOMANY # noqa: F401 from .base import RelationshipDirection as RelationshipDirection # noqa: F401 diff --git a/lib/sqlalchemy/orm/mapped_collection.py b/lib/sqlalchemy/orm/mapped_collection.py index f34083c91..1f95d9d77 100644 --- a/lib/sqlalchemy/orm/mapped_collection.py +++ b/lib/sqlalchemy/orm/mapped_collection.py @@ -8,7 +8,6 @@ from __future__ import annotations -import operator from typing import Any from typing import Callable from typing import Dict @@ -57,7 +56,6 @@ class _PlainColumnGetter: m._get_state_attr_by_column(state, state.dict, col) for col in self._cols(m) ] - if self.composite: return tuple(key) else: @@ -107,17 +105,45 @@ class _SerializableColumnGetterV2(_PlainColumnGetter): return cols -def column_mapped_collection(mapping_spec): +def column_mapped_collection( + mapping_spec, *, ignore_unpopulated_attribute: bool = False +): """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. + Returns a :class:`.MappedCollection` factory which will produce new + dictionary keys based on the value of a particular :class:`.Column`-mapped + attribute on ORM mapped instances to be added to the dictionary. + + .. note:: the value of the target attribute must be assigned with its + value at the time that the object is being added to the + dictionary collection. Additionally, changes to the key attribute + are **not tracked**, which means the key in the dictionary is not + automatically synchronized with the key value on the target object + itself. See :ref:`key_collections_mutations` for further details. + + .. seealso:: + + :ref:`orm_dictionary_collection` - background on use + + :param mapping_spec: a :class:`_schema.Column` object that is expected + to be mapped by the target mapper to a particular attribute on the + mapped class, the value of which on a particular instance is to be used + as the key for a new dictionary entry for that instance. + :param ignore_unpopulated_attribute: if True, and the mapped attribute + indicated by the given :class:`_schema.Column` target attribute + on an object is not populated at all, the operation will be silently + skipped. By default, an error is raised. + + .. versionadded:: 2.0 an error is raised by default if the attribute + being used for the dictionary key is determined that it was never + populated with any value. The + :paramref:`.column_mapped_collection.ignore_unpopulated_attribute` + parameter may be set which will instead indicate that this condition + should be ignored, and the append operation silently skipped. + This is in contrast to the behavior of the 1.x series which would + erroneously populate the value in the dictionary with an arbitrary key + value of ``None``. - 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 = [ @@ -125,31 +151,75 @@ def column_mapped_collection(mapping_spec): for q in util.to_list(mapping_spec) ] keyfunc = _PlainColumnGetter(cols) - return _mapped_collection_cls(keyfunc) + return _mapped_collection_cls( + keyfunc, ignore_unpopulated_attribute=ignore_unpopulated_attribute + ) + +class _AttrGetter: + __slots__ = ("attr_name",) -def attribute_mapped_collection(attr_name: str) -> Type["MappedCollection"]: + def __init__(self, attr_name: str): + self.attr_name = attr_name + + def __call__(self, mapped_object: Any) -> Any: + dict_ = base.instance_dict(mapped_object) + return dict_.get(self.attr_name, base.NO_VALUE) + + def __reduce__(self): + return _AttrGetter, (self.attr_name,) + + +def attribute_mapped_collection( + attr_name: str, *, ignore_unpopulated_attribute: bool = False +) -> 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. + Returns a :class:`.MappedCollection` factory which will produce new + dictionary keys based on the value of a particular named attribute on + ORM mapped instances to be added to the dictionary. - .. 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 + .. note:: the value of the target attribute must be assigned with its + value at the time that the object is being added to the + dictionary collection. Additionally, changes to the key attribute + are **not tracked**, 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. + itself. See :ref:`key_collections_mutations` for further details. + + .. seealso:: + + :ref:`orm_dictionary_collection` - background on use + + :param attr_name: string name of an ORM-mapped attribute + on the mapped class, the value of which on a particular instance + is to be used as the key for a new dictionary entry for that instance. + :param ignore_unpopulated_attribute: if True, and the target attribute + on an object is not populated at all, the operation will be silently + skipped. By default, an error is raised. + + .. versionadded:: 2.0 an error is raised by default if the attribute + being used for the dictionary key is determined that it was never + populated with any value. The + :paramref:`.attribute_mapped_collection.ignore_unpopulated_attribute` + parameter may be set which will instead indicate that this condition + should be ignored, and the append operation silently skipped. + This is in contrast to the behavior of the 1.x series which would + erroneously populate the value in the dictionary with an arbitrary key + value of ``None``. + """ - getter = operator.attrgetter(attr_name) - return _mapped_collection_cls(getter) + + return _mapped_collection_cls( + _AttrGetter(attr_name), + ignore_unpopulated_attribute=ignore_unpopulated_attribute, + ) def mapped_collection( - keyfunc: Callable[[Any], _KT] + keyfunc: Callable[[Any], _KT], + *, + ignore_unpopulated_attribute: bool = False, ) -> Type["MappedCollection[_KT, Any]"]: """A dictionary-based collection type with arbitrary keying. @@ -157,13 +227,39 @@ def mapped_collection( 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. + .. note:: the given keyfunc is called only once at the time that the + target object is being added to the collection. Changes to the + effective value returned by the function are not tracked. + + + .. seealso:: + + :ref:`orm_dictionary_collection` - background on use + + :param keyfunc: a callable that will be passed the ORM-mapped instance + which should then generate a new key to use in the dictionary. + If the value returned is :attr:`.LoaderCallableStatus.NO_VALUE`, an error + is raised. + :param ignore_unpopulated_attribute: if True, and the callable returns + :attr:`.LoaderCallableStatus.NO_VALUE` for a particular instance, the + operation will be silently skipped. By default, an error is raised. + + .. versionadded:: 2.0 an error is raised by default if the callable + being used for the dictionary key returns + :attr:`.LoaderCallableStatus.NO_VALUE`, which in an ORM attribute + context indicates an attribute that was never populated with any value. + The :paramref:`.mapped_collection.ignore_unpopulated_attribute` + parameter may be set which will instead indicate that this condition + should be ignored, and the append operation silently skipped. This is + in contrast to the behavior of the 1.x series which would erroneously + populate the value in the dictionary with an arbitrary key value of + ``None``. + """ - return _mapped_collection_cls(keyfunc) + return _mapped_collection_cls( + keyfunc, ignore_unpopulated_attribute=ignore_unpopulated_attribute + ) class MappedCollection(Dict[_KT, _VT]): @@ -178,6 +274,10 @@ class MappedCollection(Dict[_KT, _VT]): .. seealso:: + :func:`_orm.attribute_mapped_collection` + + :func:`_orm.column_mapped_collection` + :ref:`orm_dictionary_collection` :ref:`orm_custom_collection` @@ -185,7 +285,7 @@ class MappedCollection(Dict[_KT, _VT]): """ - def __init__(self, keyfunc): + def __init__(self, keyfunc, *, ignore_unpopulated_attribute=False): """Create a new collection with keying provided by keyfunc. keyfunc may be any callable that takes an object and returns an object @@ -200,6 +300,7 @@ class MappedCollection(Dict[_KT, _VT]): """ self.keyfunc = keyfunc + self.ignore_unpopulated_attribute = ignore_unpopulated_attribute @classmethod def _unreduce(cls, keyfunc, values): @@ -210,12 +311,41 @@ class MappedCollection(Dict[_KT, _VT]): def __reduce__(self): return (MappedCollection._unreduce, (self.keyfunc, dict(self))) + def _raise_for_unpopulated(self, value, initiator): + mapper = base.instance_state(value).mapper + + if initiator is None: + relationship = "unknown relationship" + else: + relationship = mapper.attrs[initiator.key] + + raise sa_exc.InvalidRequestError( + f"In event triggered from population of attribute {relationship} " + "(likely from a backref), " + f"can't populate value in MappedCollection; " + "dictionary key " + f"derived from {base.instance_str(value)} is not " + f"populated. Ensure appropriate state is set up on " + f"the {base.instance_str(value)} object " + f"before assigning to the {relationship} attribute. " + f"To skip this assignment entirely, " + f'Set the "ignore_unpopulated_attribute=True" ' + f"parameter on the mapped collection factory." + ) + @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) + + if key is base.NO_VALUE: + if not self.ignore_unpopulated_attribute: + self._raise_for_unpopulated(value, _sa_initiator) + else: + return + self.__setitem__(key, value, _sa_initiator) @collection.remover @@ -224,6 +354,12 @@ class MappedCollection(Dict[_KT, _VT]): """Remove an item by value, consulting the keyfunc for the key.""" key = self.keyfunc(value) + + if key is base.NO_VALUE: + if not self.ignore_unpopulated_attribute: + self._raise_for_unpopulated(value, _sa_initiator) + return + # Let self[key] raise if key is not in this collection # testlib.pragma exempt:__ne__ if self[key] != value: @@ -236,9 +372,12 @@ class MappedCollection(Dict[_KT, _VT]): self.__delitem__(key, _sa_initiator) -def _mapped_collection_cls(keyfunc): +def _mapped_collection_cls(keyfunc, ignore_unpopulated_attribute): class _MKeyfuncMapped(MappedCollection): def __init__(self): - super().__init__(keyfunc) + super().__init__( + keyfunc, + ignore_unpopulated_attribute=ignore_unpopulated_attribute, + ) return _MKeyfuncMapped |