You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
299 lines
9.6 KiB
299 lines
9.6 KiB
# orm/dynamic.py
|
|
# Copyright (C) 2005-2024 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
|
|
|
|
|
|
"""Dynamic collection API.
|
|
|
|
Dynamic collections act like Query() objects for read operations and support
|
|
basic add/delete mutation.
|
|
|
|
.. legacy:: the "dynamic" loader is a legacy feature, superseded by the
|
|
"write_only" loader.
|
|
|
|
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from typing import Iterable
|
|
from typing import Iterator
|
|
from typing import List
|
|
from typing import Optional
|
|
from typing import Tuple
|
|
from typing import Type
|
|
from typing import TYPE_CHECKING
|
|
from typing import TypeVar
|
|
from typing import Union
|
|
|
|
from . import attributes
|
|
from . import exc as orm_exc
|
|
from . import relationships
|
|
from . import util as orm_util
|
|
from .base import PassiveFlag
|
|
from .query import Query
|
|
from .session import object_session
|
|
from .writeonly import AbstractCollectionWriter
|
|
from .writeonly import WriteOnlyAttributeImpl
|
|
from .writeonly import WriteOnlyHistory
|
|
from .writeonly import WriteOnlyLoader
|
|
from .. import util
|
|
from ..engine import result
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from . import QueryableAttribute
|
|
from .mapper import Mapper
|
|
from .relationships import _RelationshipOrderByArg
|
|
from .session import Session
|
|
from .state import InstanceState
|
|
from .util import AliasedClass
|
|
from ..event import _Dispatch
|
|
from ..sql.elements import ColumnElement
|
|
|
|
_T = TypeVar("_T", bound=Any)
|
|
|
|
|
|
class DynamicCollectionHistory(WriteOnlyHistory[_T]):
|
|
def __init__(
|
|
self,
|
|
attr: DynamicAttributeImpl,
|
|
state: InstanceState[_T],
|
|
passive: PassiveFlag,
|
|
apply_to: Optional[DynamicCollectionHistory[_T]] = None,
|
|
) -> None:
|
|
if apply_to:
|
|
coll = AppenderQuery(attr, state).autoflush(False)
|
|
self.unchanged_items = util.OrderedIdentitySet(coll)
|
|
self.added_items = apply_to.added_items
|
|
self.deleted_items = apply_to.deleted_items
|
|
self._reconcile_collection = True
|
|
else:
|
|
self.deleted_items = util.OrderedIdentitySet()
|
|
self.added_items = util.OrderedIdentitySet()
|
|
self.unchanged_items = util.OrderedIdentitySet()
|
|
self._reconcile_collection = False
|
|
|
|
|
|
class DynamicAttributeImpl(WriteOnlyAttributeImpl):
|
|
_supports_dynamic_iteration = True
|
|
collection_history_cls = DynamicCollectionHistory[Any]
|
|
query_class: Type[AppenderMixin[Any]] # type: ignore[assignment]
|
|
|
|
def __init__(
|
|
self,
|
|
class_: Union[Type[Any], AliasedClass[Any]],
|
|
key: str,
|
|
dispatch: _Dispatch[QueryableAttribute[Any]],
|
|
target_mapper: Mapper[_T],
|
|
order_by: _RelationshipOrderByArg,
|
|
query_class: Optional[Type[AppenderMixin[_T]]] = None,
|
|
**kw: Any,
|
|
) -> None:
|
|
attributes.AttributeImpl.__init__(
|
|
self, class_, key, None, dispatch, **kw
|
|
)
|
|
self.target_mapper = target_mapper
|
|
if order_by:
|
|
self.order_by = tuple(order_by)
|
|
if not query_class:
|
|
self.query_class = AppenderQuery
|
|
elif AppenderMixin in query_class.mro():
|
|
self.query_class = query_class
|
|
else:
|
|
self.query_class = mixin_user_query(query_class)
|
|
|
|
|
|
@relationships.RelationshipProperty.strategy_for(lazy="dynamic")
|
|
class DynaLoader(WriteOnlyLoader):
|
|
impl_class = DynamicAttributeImpl
|
|
|
|
|
|
class AppenderMixin(AbstractCollectionWriter[_T]):
|
|
"""A mixin that expects to be mixing in a Query class with
|
|
AbstractAppender.
|
|
|
|
|
|
"""
|
|
|
|
query_class: Optional[Type[Query[_T]]] = None
|
|
_order_by_clauses: Tuple[ColumnElement[Any], ...]
|
|
|
|
def __init__(
|
|
self, attr: DynamicAttributeImpl, state: InstanceState[_T]
|
|
) -> None:
|
|
Query.__init__(
|
|
self, # type: ignore[arg-type]
|
|
attr.target_mapper,
|
|
None,
|
|
)
|
|
super().__init__(attr, state)
|
|
|
|
@property
|
|
def session(self) -> Optional[Session]:
|
|
sess = object_session(self.instance)
|
|
if sess is not None and sess.autoflush and self.instance in sess:
|
|
sess.flush()
|
|
if not orm_util.has_identity(self.instance):
|
|
return None
|
|
else:
|
|
return sess
|
|
|
|
@session.setter
|
|
def session(self, session: Session) -> None:
|
|
self.sess = session
|
|
|
|
def _iter(self) -> Union[result.ScalarResult[_T], result.Result[_T]]:
|
|
sess = self.session
|
|
if sess is None:
|
|
state = attributes.instance_state(self.instance)
|
|
if state.detached:
|
|
util.warn(
|
|
"Instance %s is detached, dynamic relationship cannot "
|
|
"return a correct result. This warning will become "
|
|
"a DetachedInstanceError in a future release."
|
|
% (orm_util.state_str(state))
|
|
)
|
|
|
|
return result.IteratorResult(
|
|
result.SimpleResultMetaData([self.attr.class_.__name__]),
|
|
self.attr._get_collection_history( # type: ignore[arg-type]
|
|
attributes.instance_state(self.instance),
|
|
PassiveFlag.PASSIVE_NO_INITIALIZE,
|
|
).added_items,
|
|
_source_supports_scalars=True,
|
|
).scalars()
|
|
else:
|
|
return self._generate(sess)._iter()
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
def __iter__(self) -> Iterator[_T]: ...
|
|
|
|
def __getitem__(self, index: Any) -> Union[_T, List[_T]]:
|
|
sess = self.session
|
|
if sess is None:
|
|
return self.attr._get_collection_history(
|
|
attributes.instance_state(self.instance),
|
|
PassiveFlag.PASSIVE_NO_INITIALIZE,
|
|
).indexed(index)
|
|
else:
|
|
return self._generate(sess).__getitem__(index) # type: ignore[no-any-return] # noqa: E501
|
|
|
|
def count(self) -> int:
|
|
sess = self.session
|
|
if sess is None:
|
|
return len(
|
|
self.attr._get_collection_history(
|
|
attributes.instance_state(self.instance),
|
|
PassiveFlag.PASSIVE_NO_INITIALIZE,
|
|
).added_items
|
|
)
|
|
else:
|
|
return self._generate(sess).count()
|
|
|
|
def _generate(
|
|
self,
|
|
sess: Optional[Session] = None,
|
|
) -> Query[_T]:
|
|
# note we're returning an entirely new Query class instance
|
|
# here without any assignment capabilities; the class of this
|
|
# query is determined by the session.
|
|
instance = self.instance
|
|
if sess is None:
|
|
sess = object_session(instance)
|
|
if sess is None:
|
|
raise orm_exc.DetachedInstanceError(
|
|
"Parent instance %s is not bound to a Session, and no "
|
|
"contextual session is established; lazy load operation "
|
|
"of attribute '%s' cannot proceed"
|
|
% (orm_util.instance_str(instance), self.attr.key)
|
|
)
|
|
|
|
if self.query_class:
|
|
query = self.query_class(self.attr.target_mapper, session=sess)
|
|
else:
|
|
query = sess.query(self.attr.target_mapper)
|
|
|
|
query._where_criteria = self._where_criteria
|
|
query._from_obj = self._from_obj
|
|
query._order_by_clauses = self._order_by_clauses
|
|
|
|
return query
|
|
|
|
def add_all(self, iterator: Iterable[_T]) -> None:
|
|
"""Add an iterable of items to this :class:`_orm.AppenderQuery`.
|
|
|
|
The given items will be persisted to the database in terms of
|
|
the parent instance's collection on the next flush.
|
|
|
|
This method is provided to assist in delivering forwards-compatibility
|
|
with the :class:`_orm.WriteOnlyCollection` collection class.
|
|
|
|
.. versionadded:: 2.0
|
|
|
|
"""
|
|
self._add_all_impl(iterator)
|
|
|
|
def add(self, item: _T) -> None:
|
|
"""Add an item to this :class:`_orm.AppenderQuery`.
|
|
|
|
The given item will be persisted to the database in terms of
|
|
the parent instance's collection on the next flush.
|
|
|
|
This method is provided to assist in delivering forwards-compatibility
|
|
with the :class:`_orm.WriteOnlyCollection` collection class.
|
|
|
|
.. versionadded:: 2.0
|
|
|
|
"""
|
|
self._add_all_impl([item])
|
|
|
|
def extend(self, iterator: Iterable[_T]) -> None:
|
|
"""Add an iterable of items to this :class:`_orm.AppenderQuery`.
|
|
|
|
The given items will be persisted to the database in terms of
|
|
the parent instance's collection on the next flush.
|
|
|
|
"""
|
|
self._add_all_impl(iterator)
|
|
|
|
def append(self, item: _T) -> None:
|
|
"""Append an item to this :class:`_orm.AppenderQuery`.
|
|
|
|
The given item will be persisted to the database in terms of
|
|
the parent instance's collection on the next flush.
|
|
|
|
"""
|
|
self._add_all_impl([item])
|
|
|
|
def remove(self, item: _T) -> None:
|
|
"""Remove an item from this :class:`_orm.AppenderQuery`.
|
|
|
|
The given item will be removed from the parent instance's collection on
|
|
the next flush.
|
|
|
|
"""
|
|
self._remove_impl(item)
|
|
|
|
|
|
class AppenderQuery(AppenderMixin[_T], Query[_T]): # type: ignore[misc]
|
|
"""A dynamic query that supports basic collection storage operations.
|
|
|
|
Methods on :class:`.AppenderQuery` include all methods of
|
|
:class:`_orm.Query`, plus additional methods used for collection
|
|
persistence.
|
|
|
|
|
|
"""
|
|
|
|
|
|
def mixin_user_query(cls: Any) -> type[AppenderMixin[Any]]:
|
|
"""Return a new class with AppenderQuery functionality layered over."""
|
|
name = "Appender" + cls.__name__
|
|
return type(name, (AppenderMixin, cls), {"query_class": cls})
|