Skip to content

Commit 24e7963

Browse files
committed
Initial draft/prototype implementation of #36.
Active Record and Active Field mix-in behaviors, mostly a proof of concept.
1 parent e3cb436 commit 24e7963

File tree

1 file changed

+95
-0
lines changed

1 file changed

+95
-0
lines changed

marrow/mongo/core/trait/active.py

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from typing import Any, Mapping
2+
3+
from ... import Document, F, Field, Filter, P, S
4+
from .queryable import Queryable
5+
6+
7+
# Type hinting helpers.
8+
9+
FieldCache = Mapping[Field, Field]
10+
Operation = Dict[str: Any] # An operation in the form {'$op': value}
11+
PendingOperations = Dict[Field, Operation] # Pending operations in the form {'field': {'$op': value}}
12+
13+
14+
class _ActiveField(Field):
15+
"""A Field mix-in providing basic set/unset tracking useful for implementing the Active Record pattern."""
16+
17+
def __set__(self, obj:Document, value:Any): # TODO: Active type of Document.
18+
"""Direct assignment of a value is an authoritative operation which always takes precedence."""
19+
20+
super().__set__(obj, value) # Pass up the hierarchy so we utilize the final value suitable for backing store.
21+
22+
obj._pending[self.__name__] = {'$set': obj.__data__[self.__name__]} # Record the change for commit on save.
23+
24+
def __delete__(self, obj:Document):
25+
"""Record an $unset modification if a field is deleted, or a $set if there is an assignable default."""
26+
27+
super().__delete__(obj) # Pass up the hierarchy to let the field actually perform the deletion.
28+
29+
if self.assign:
30+
value = self.default() if callable(self.default) else self.default
31+
obj.__data__[self.__name__] = value # TODO: Move to base Field implementation.
32+
obj._pending[self.__name__] = {'$set': value}
33+
34+
elif self.__name__ in obj.__data__: # There is only work to perform if the field has a value.
35+
# We don't need to worry about merging; you can't combine this operation as any prior value is lost.
36+
obj._pending[self.__name__] = {'$unset': True}
37+
38+
39+
class CachedMixinFactory(dict):
40+
"""A fancy dictionary implementation that automatically constructs (and caches) new "active" field subclasses.
41+
42+
This relies on two important properties of Python: classes are constructable at runtime, and the class of an
43+
object instance is itself mutable, that is, the type of an instance can be changed. In the case of the Active
44+
trait, we need all fields assigned to documents using that trait to be "hot-swapped" for ones which are extended
45+
to track alterations.
46+
"""
47+
def __missing__(self, cls): # TODO: Enum[Field, type(Field)]
48+
if isinstance(cls, Field): cls = cls.__class__ # Permit retrieval from instances.
49+
if not issubclass(cls, Field) or issubclass(cls, _ActiveField): return cls
50+
51+
# This will create a new derivative subclass of the field's class, mixing in _ActiveField from above.
52+
new_class = cls.__class__('Active' + cls.__name__, (cls, _ActiveField), {})
53+
54+
self[cls] = new_class # We assign this to avoid constructing new derivative subclasses on each access.
55+
return new_class
56+
57+
58+
class Active(Queryable):
59+
"""Where Queryable implements "active collection" behaviours, this trait implements "active record" ones.
60+
61+
Operations which alter the document are gathered in a _pending mapping of fields to the operation and value to
62+
apply. This mapping is not intended for direct use with PyMongo, instead, invoke the instance's `.save()` method
63+
or retrieve the body of the update operation by pulling the `.changes` attribute.
64+
65+
The `.update()` method will update the local representation and enqueue those updates within the object for
66+
execution during `.save()` invocation. This is a notable deviation from the behaviour of other MongoDB DAO layers.
67+
"""
68+
69+
# Class (global) mapping of Field subclass to variant with _Active mixed-in.
70+
__field_cache: FieldCache = CachedMixinFactory()
71+
72+
_pending: PendingOperations # A mapping of field-level operations to apply when saved.
73+
74+
def __init__(self, *args, **kw):
75+
"""Construct the mapping of pending operations."""
76+
77+
super().__init__(*args, **kw)
78+
79+
self._pending = {}
80+
81+
def __attributed__(self):
82+
"""Automatically mix active behaviours into field instances used during declarative construction."""
83+
84+
for name, field in self.__attributes__:
85+
field.__class__ = self.__field_cache[field]
86+
87+
@property
88+
def changes(self):
89+
operations = {}
90+
91+
for field, (operation, value) in self._pending.items():
92+
operations.setdefault(operation, {})
93+
operations[operation][field] = value
94+
95+
return operations

0 commit comments

Comments
 (0)