1# orm/descriptor_props.py
2# Copyright (C) 2005-2024 the SQLAlchemy authors and contributors
3# <see AUTHORS file>
4#
5# This module is part of SQLAlchemy and is released under
6# the MIT License: https://www.opensource.org/licenses/mit-license.php
7
8"""Descriptor properties are more "auxiliary" properties
9that exist as configurational elements, but don't participate
10as actively in the load/persist ORM loop.
11
12"""
13
14from . import attributes
15from . import util as orm_util
16from .interfaces import MapperProperty
17from .interfaces import PropComparator
18from .util import _none_set
19from .. import event
20from .. import exc as sa_exc
21from .. import schema
22from .. import sql
23from .. import util
24from ..sql import expression
25from ..sql import operators
26
27
28class DescriptorProperty(MapperProperty):
29 """:class:`.MapperProperty` which proxies access to a
30 user-defined descriptor."""
31
32 doc = None
33
34 uses_objects = False
35 _links_to_entity = False
36
37 def instrument_class(self, mapper):
38 prop = self
39
40 class _ProxyImpl(object):
41 accepts_scalar_loader = False
42 load_on_unexpire = True
43 collection = False
44
45 @property
46 def uses_objects(self):
47 return prop.uses_objects
48
49 def __init__(self, key):
50 self.key = key
51
52 if hasattr(prop, "get_history"):
53
54 def get_history(
55 self, state, dict_, passive=attributes.PASSIVE_OFF
56 ):
57 return prop.get_history(state, dict_, passive)
58
59 if self.descriptor is None:
60 desc = getattr(mapper.class_, self.key, None)
61 if mapper._is_userland_descriptor(self.key, desc):
62 self.descriptor = desc
63
64 if self.descriptor is None:
65
66 def fset(obj, value):
67 setattr(obj, self.name, value)
68
69 def fdel(obj):
70 delattr(obj, self.name)
71
72 def fget(obj):
73 return getattr(obj, self.name)
74
75 self.descriptor = property(fget=fget, fset=fset, fdel=fdel)
76
77 proxy_attr = attributes.create_proxied_attribute(self.descriptor)(
78 self.parent.class_,
79 self.key,
80 self.descriptor,
81 lambda: self._comparator_factory(mapper),
82 doc=self.doc,
83 original_property=self,
84 )
85 proxy_attr.impl = _ProxyImpl(self.key)
86 mapper.class_manager.instrument_attribute(self.key, proxy_attr)
87
88
89class CompositeProperty(DescriptorProperty):
90 """Defines a "composite" mapped attribute, representing a collection
91 of columns as one attribute.
92
93 :class:`.CompositeProperty` is constructed using the :func:`.composite`
94 function.
95
96 .. seealso::
97
98 :ref:`mapper_composite`
99
100 """
101
102 def __init__(self, class_, *attrs, **kwargs):
103 r"""Return a composite column-based property for use with a Mapper.
104
105 See the mapping documentation section :ref:`mapper_composite` for a
106 full usage example.
107
108 The :class:`.MapperProperty` returned by :func:`.composite`
109 is the :class:`.CompositeProperty`.
110
111 :param class\_:
112 The "composite type" class, or any classmethod or callable which
113 will produce a new instance of the composite object given the
114 column values in order.
115
116 :param \*cols:
117 List of Column objects to be mapped.
118
119 :param active_history=False:
120 When ``True``, indicates that the "previous" value for a
121 scalar attribute should be loaded when replaced, if not
122 already loaded. See the same flag on :func:`.column_property`.
123
124 :param group:
125 A group name for this property when marked as deferred.
126
127 :param deferred:
128 When True, the column property is "deferred", meaning that it does
129 not load immediately, and is instead loaded when the attribute is
130 first accessed on an instance. See also
131 :func:`~sqlalchemy.orm.deferred`.
132
133 :param comparator_factory: a class which extends
134 :class:`.CompositeProperty.Comparator` which provides custom SQL
135 clause generation for comparison operations.
136
137 :param doc:
138 optional string that will be applied as the doc on the
139 class-bound descriptor.
140
141 :param info: Optional data dictionary which will be populated into the
142 :attr:`.MapperProperty.info` attribute of this object.
143
144 """
145 super(CompositeProperty, self).__init__()
146
147 self.attrs = attrs
148 self.composite_class = class_
149 self.active_history = kwargs.get("active_history", False)
150 self.deferred = kwargs.get("deferred", False)
151 self.group = kwargs.get("group", None)
152 self.comparator_factory = kwargs.pop(
153 "comparator_factory", self.__class__.Comparator
154 )
155 if "info" in kwargs:
156 self.info = kwargs.pop("info")
157
158 util.set_creation_order(self)
159 self._create_descriptor()
160
161 def instrument_class(self, mapper):
162 super(CompositeProperty, self).instrument_class(mapper)
163 self._setup_event_handlers()
164
165 def do_init(self):
166 """Initialization which occurs after the :class:`.CompositeProperty`
167 has been associated with its parent mapper.
168
169 """
170 self._setup_arguments_on_columns()
171
172 _COMPOSITE_FGET = object()
173
174 def _create_descriptor(self):
175 """Create the Python descriptor that will serve as
176 the access point on instances of the mapped class.
177
178 """
179
180 def fget(instance):
181 dict_ = attributes.instance_dict(instance)
182 state = attributes.instance_state(instance)
183
184 if self.key not in dict_:
185 # key not present. Iterate through related
186 # attributes, retrieve their values. This
187 # ensures they all load.
188 values = [
189 getattr(instance, key) for key in self._attribute_keys
190 ]
191
192 # current expected behavior here is that the composite is
193 # created on access if the object is persistent or if
194 # col attributes have non-None. This would be better
195 # if the composite were created unconditionally,
196 # but that would be a behavioral change.
197 if self.key not in dict_ and (
198 state.key is not None or not _none_set.issuperset(values)
199 ):
200 dict_[self.key] = self.composite_class(*values)
201 state.manager.dispatch.refresh(
202 state, self._COMPOSITE_FGET, [self.key]
203 )
204
205 return dict_.get(self.key, None)
206
207 def fset(instance, value):
208 dict_ = attributes.instance_dict(instance)
209 state = attributes.instance_state(instance)
210 attr = state.manager[self.key]
211 previous = dict_.get(self.key, attributes.NO_VALUE)
212 for fn in attr.dispatch.set:
213 value = fn(state, value, previous, attr.impl)
214 dict_[self.key] = value
215 if value is None:
216 for key in self._attribute_keys:
217 setattr(instance, key, None)
218 else:
219 for key, value in zip(
220 self._attribute_keys, value.__composite_values__()
221 ):
222 setattr(instance, key, value)
223
224 def fdel(instance):
225 state = attributes.instance_state(instance)
226 dict_ = attributes.instance_dict(instance)
227 previous = dict_.pop(self.key, attributes.NO_VALUE)
228 attr = state.manager[self.key]
229 attr.dispatch.remove(state, previous, attr.impl)
230 for key in self._attribute_keys:
231 setattr(instance, key, None)
232
233 self.descriptor = property(fget, fset, fdel)
234
235 @util.memoized_property
236 def _comparable_elements(self):
237 return [getattr(self.parent.class_, prop.key) for prop in self.props]
238
239 @util.memoized_property
240 def props(self):
241 props = []
242 for attr in self.attrs:
243 if isinstance(attr, str):
244 prop = self.parent.get_property(attr, _configure_mappers=False)
245 elif isinstance(attr, schema.Column):
246 prop = self.parent._columntoproperty[attr]
247 elif isinstance(attr, attributes.InstrumentedAttribute):
248 prop = attr.property
249 else:
250 raise sa_exc.ArgumentError(
251 "Composite expects Column objects or mapped "
252 "attributes/attribute names as arguments, got: %r"
253 % (attr,)
254 )
255 props.append(prop)
256 return props
257
258 @property
259 def columns(self):
260 return [a for a in self.attrs if isinstance(a, schema.Column)]
261
262 def _setup_arguments_on_columns(self):
263 """Propagate configuration arguments made on this composite
264 to the target columns, for those that apply.
265
266 """
267 for prop in self.props:
268 prop.active_history = self.active_history
269 if self.deferred:
270 prop.deferred = self.deferred
271 prop.strategy_key = (("deferred", True), ("instrument", True))
272 prop.group = self.group
273
274 def _setup_event_handlers(self):
275 """Establish events that populate/expire the composite attribute."""
276
277 def load_handler(state, context):
278 _load_refresh_handler(state, context, None, is_refresh=False)
279
280 def refresh_handler(state, context, to_load):
281 # note this corresponds to sqlalchemy.ext.mutable load_attrs()
282
283 if not to_load or (
284 {self.key}.union(self._attribute_keys)
285 ).intersection(to_load):
286 _load_refresh_handler(state, context, to_load, is_refresh=True)
287
288 def _load_refresh_handler(state, context, to_load, is_refresh):
289 dict_ = state.dict
290
291 # if context indicates we are coming from the
292 # fget() handler, this already set the value; skip the
293 # handler here. (other handlers like mutablecomposite will still
294 # want to catch it)
295 # there's an insufficiency here in that the fget() handler
296 # really should not be using the refresh event and there should
297 # be some other event that mutablecomposite can subscribe
298 # towards for this.
299
300 if (
301 not is_refresh or context is self._COMPOSITE_FGET
302 ) and self.key in dict_:
303 return
304
305 # if column elements aren't loaded, skip.
306 # __get__() will initiate a load for those
307 # columns
308 for k in self._attribute_keys:
309 if k not in dict_:
310 return
311
312 dict_[self.key] = self.composite_class(
313 *[state.dict[key] for key in self._attribute_keys]
314 )
315
316 def expire_handler(state, keys):
317 if keys is None or set(self._attribute_keys).intersection(keys):
318 state.dict.pop(self.key, None)
319
320 def insert_update_handler(mapper, connection, state):
321 """After an insert or update, some columns may be expired due
322 to server side defaults, or re-populated due to client side
323 defaults. Pop out the composite value here so that it
324 recreates.
325
326 """
327
328 state.dict.pop(self.key, None)
329
330 event.listen(
331 self.parent, "after_insert", insert_update_handler, raw=True
332 )
333 event.listen(
334 self.parent, "after_update", insert_update_handler, raw=True
335 )
336 event.listen(
337 self.parent, "load", load_handler, raw=True, propagate=True
338 )
339 event.listen(
340 self.parent, "refresh", refresh_handler, raw=True, propagate=True
341 )
342 event.listen(
343 self.parent, "expire", expire_handler, raw=True, propagate=True
344 )
345
346 # TODO: need a deserialize hook here
347
348 @util.memoized_property
349 def _attribute_keys(self):
350 return [prop.key for prop in self.props]
351
352 def get_history(self, state, dict_, passive=attributes.PASSIVE_OFF):
353 """Provided for userland code that uses attributes.get_history()."""
354
355 added = []
356 deleted = []
357
358 has_history = False
359 for prop in self.props:
360 key = prop.key
361 hist = state.manager[key].impl.get_history(state, dict_)
362 if hist.has_changes():
363 has_history = True
364
365 non_deleted = hist.non_deleted()
366 if non_deleted:
367 added.extend(non_deleted)
368 else:
369 added.append(None)
370 if hist.deleted:
371 deleted.extend(hist.deleted)
372 else:
373 deleted.append(None)
374
375 if has_history:
376 return attributes.History(
377 [self.composite_class(*added)],
378 (),
379 [self.composite_class(*deleted)],
380 )
381 else:
382 return attributes.History((), [self.composite_class(*added)], ())
383
384 def _comparator_factory(self, mapper):
385 return self.comparator_factory(self, mapper)
386
387 class CompositeBundle(orm_util.Bundle):
388 def __init__(self, property_, expr):
389 self.property = property_
390 super(CompositeProperty.CompositeBundle, self).__init__(
391 property_.key, *expr
392 )
393
394 def create_row_processor(self, query, procs, labels):
395 def proc(row):
396 return self.property.composite_class(
397 *[proc(row) for proc in procs]
398 )
399
400 return proc
401
402 class Comparator(PropComparator):
403 """Produce boolean, comparison, and other operators for
404 :class:`.CompositeProperty` attributes.
405
406 See the example in :ref:`composite_operations` for an overview
407 of usage , as well as the documentation for :class:`.PropComparator`.
408
409 .. seealso::
410
411 :class:`.PropComparator`
412
413 :class:`.ColumnOperators`
414
415 :ref:`types_operators`
416
417 :attr:`.TypeEngine.comparator_factory`
418
419 """
420
421 __hash__ = None
422
423 @util.memoized_property
424 def clauses(self):
425 return expression.ClauseList(
426 group=False, *self._comparable_elements
427 )
428
429 def __clause_element__(self):
430 return self.expression
431
432 @util.memoized_property
433 def expression(self):
434 clauses = self.clauses._annotate(
435 {
436 "parententity": self._parententity,
437 "parentmapper": self._parententity,
438 "proxy_key": self.prop.key,
439 }
440 )
441 return CompositeProperty.CompositeBundle(self.prop, clauses)
442
443 def _bulk_update_tuples(self, value):
444 if isinstance(value, sql.elements.BindParameter):
445 value = value.value
446
447 if value is None:
448 values = [None for key in self.prop._attribute_keys]
449 elif isinstance(value, self.prop.composite_class):
450 values = value.__composite_values__()
451 else:
452 raise sa_exc.ArgumentError(
453 "Can't UPDATE composite attribute %s to %r"
454 % (self.prop, value)
455 )
456
457 return zip(self._comparable_elements, values)
458
459 @util.memoized_property
460 def _comparable_elements(self):
461 if self._adapt_to_entity:
462 return [
463 getattr(self._adapt_to_entity.entity, prop.key)
464 for prop in self.prop._comparable_elements
465 ]
466 else:
467 return self.prop._comparable_elements
468
469 def __eq__(self, other):
470 if other is None:
471 values = [None] * len(self.prop._comparable_elements)
472 else:
473 values = other.__composite_values__()
474 comparisons = [
475 a == b for a, b in zip(self.prop._comparable_elements, values)
476 ]
477 if self._adapt_to_entity:
478 comparisons = [self.adapter(x) for x in comparisons]
479 return sql.and_(*comparisons)
480
481 def __ne__(self, other):
482 return sql.not_(self.__eq__(other))
483
484 def __str__(self):
485 return str(self.parent.class_.__name__) + "." + self.key
486
487
488class ConcreteInheritedProperty(DescriptorProperty):
489 """A 'do nothing' :class:`.MapperProperty` that disables
490 an attribute on a concrete subclass that is only present
491 on the inherited mapper, not the concrete classes' mapper.
492
493 Cases where this occurs include:
494
495 * When the superclass mapper is mapped against a
496 "polymorphic union", which includes all attributes from
497 all subclasses.
498 * When a relationship() is configured on an inherited mapper,
499 but not on the subclass mapper. Concrete mappers require
500 that relationship() is configured explicitly on each
501 subclass.
502
503 """
504
505 def _comparator_factory(self, mapper):
506 comparator_callable = None
507
508 for m in self.parent.iterate_to_root():
509 p = m._props[self.key]
510 if not isinstance(p, ConcreteInheritedProperty):
511 comparator_callable = p.comparator_factory
512 break
513 return comparator_callable
514
515 def __init__(self):
516 super(ConcreteInheritedProperty, self).__init__()
517
518 def warn():
519 raise AttributeError(
520 "Concrete %s does not implement "
521 "attribute %r at the instance level. Add "
522 "this property explicitly to %s."
523 % (self.parent, self.key, self.parent)
524 )
525
526 class NoninheritedConcreteProp(object):
527 def __set__(s, obj, value):
528 warn()
529
530 def __delete__(s, obj):
531 warn()
532
533 def __get__(s, obj, owner):
534 if obj is None:
535 return self.descriptor
536 warn()
537
538 self.descriptor = NoninheritedConcreteProp()
539
540
541class SynonymProperty(DescriptorProperty):
542 def __init__(
543 self,
544 name,
545 map_column=None,
546 descriptor=None,
547 comparator_factory=None,
548 doc=None,
549 info=None,
550 ):
551 """Denote an attribute name as a synonym to a mapped property,
552 in that the attribute will mirror the value and expression behavior
553 of another attribute.
554
555 e.g.::
556
557 class MyClass(Base):
558 __tablename__ = 'my_table'
559
560 id = Column(Integer, primary_key=True)
561 job_status = Column(String(50))
562
563 status = synonym("job_status")
564
565
566 :param name: the name of the existing mapped property. This
567 can refer to the string name ORM-mapped attribute
568 configured on the class, including column-bound attributes
569 and relationships.
570
571 :param descriptor: a Python :term:`descriptor` that will be used
572 as a getter (and potentially a setter) when this attribute is
573 accessed at the instance level.
574
575 :param map_column: **For classical mappings and mappings against
576 an existing Table object only**. if ``True``, the :func:`.synonym`
577 construct will locate the :class:`_schema.Column`
578 object upon the mapped
579 table that would normally be associated with the attribute name of
580 this synonym, and produce a new :class:`.ColumnProperty` that instead
581 maps this :class:`_schema.Column`
582 to the alternate name given as the "name"
583 argument of the synonym; in this way, the usual step of redefining
584 the mapping of the :class:`_schema.Column`
585 to be under a different name is
586 unnecessary. This is usually intended to be used when a
587 :class:`_schema.Column`
588 is to be replaced with an attribute that also uses a
589 descriptor, that is, in conjunction with the
590 :paramref:`.synonym.descriptor` parameter::
591
592 my_table = Table(
593 "my_table", metadata,
594 Column('id', Integer, primary_key=True),
595 Column('job_status', String(50))
596 )
597
598 class MyClass(object):
599 @property
600 def _job_status_descriptor(self):
601 return "Status: %s" % self._job_status
602
603
604 mapper(
605 MyClass, my_table, properties={
606 "job_status": synonym(
607 "_job_status", map_column=True,
608 descriptor=MyClass._job_status_descriptor)
609 }
610 )
611
612 Above, the attribute named ``_job_status`` is automatically
613 mapped to the ``job_status`` column::
614
615 >>> j1 = MyClass()
616 >>> j1._job_status = "employed"
617 >>> j1.job_status
618 Status: employed
619
620 When using Declarative, in order to provide a descriptor in
621 conjunction with a synonym, use the
622 :func:`sqlalchemy.ext.declarative.synonym_for` helper. However,
623 note that the :ref:`hybrid properties <mapper_hybrids>` feature
624 should usually be preferred, particularly when redefining attribute
625 behavior.
626
627 :param info: Optional data dictionary which will be populated into the
628 :attr:`.InspectionAttr.info` attribute of this object.
629
630 .. versionadded:: 1.0.0
631
632 :param comparator_factory: A subclass of :class:`.PropComparator`
633 that will provide custom comparison behavior at the SQL expression
634 level.
635
636 .. note::
637
638 For the use case of providing an attribute which redefines both
639 Python-level and SQL-expression level behavior of an attribute,
640 please refer to the Hybrid attribute introduced at
641 :ref:`mapper_hybrids` for a more effective technique.
642
643 .. seealso::
644
645 :ref:`synonyms` - Overview of synonyms
646
647 :func:`.synonym_for` - a helper oriented towards Declarative
648
649 :ref:`mapper_hybrids` - The Hybrid Attribute extension provides an
650 updated approach to augmenting attribute behavior more flexibly
651 than can be achieved with synonyms.
652
653 """
654 super(SynonymProperty, self).__init__()
655
656 self.name = name
657 self.map_column = map_column
658 self.descriptor = descriptor
659 self.comparator_factory = comparator_factory
660 self.doc = doc or (descriptor and descriptor.__doc__) or None
661 if info:
662 self.info = info
663
664 util.set_creation_order(self)
665
666 @property
667 def uses_objects(self):
668 return getattr(self.parent.class_, self.name).impl.uses_objects
669
670 # TODO: when initialized, check _proxied_object,
671 # emit a warning if its not a column-based property
672
673 @util.memoized_property
674 def _proxied_object(self):
675 attr = getattr(self.parent.class_, self.name)
676 if not hasattr(attr, "property") or not isinstance(
677 attr.property, MapperProperty
678 ):
679 # attribute is a non-MapperProprerty proxy such as
680 # hybrid or association proxy
681 if isinstance(attr, attributes.QueryableAttribute):
682 return attr.comparator
683 elif isinstance(attr, operators.ColumnOperators):
684 return attr
685
686 raise sa_exc.InvalidRequestError(
687 """synonym() attribute "%s.%s" only supports """
688 """ORM mapped attributes, got %r"""
689 % (self.parent.class_.__name__, self.name, attr)
690 )
691 return attr.property
692
693 def _comparator_factory(self, mapper):
694 prop = self._proxied_object
695
696 if isinstance(prop, MapperProperty):
697 if self.comparator_factory:
698 comp = self.comparator_factory(prop, mapper)
699 else:
700 comp = prop.comparator_factory(prop, mapper)
701 return comp
702 else:
703 return prop
704
705 def get_history(self, *arg, **kw):
706 attr = getattr(self.parent.class_, self.name)
707 return attr.impl.get_history(*arg, **kw)
708
709 @util.preload_module("sqlalchemy.orm.properties")
710 def set_parent(self, parent, init):
711 properties = util.preloaded.orm_properties
712
713 if self.map_column:
714 # implement the 'map_column' option.
715 if self.key not in parent.persist_selectable.c:
716 raise sa_exc.ArgumentError(
717 "Can't compile synonym '%s': no column on table "
718 "'%s' named '%s'"
719 % (
720 self.name,
721 parent.persist_selectable.description,
722 self.key,
723 )
724 )
725 elif (
726 parent.persist_selectable.c[self.key]
727 in parent._columntoproperty
728 and parent._columntoproperty[
729 parent.persist_selectable.c[self.key]
730 ].key
731 == self.name
732 ):
733 raise sa_exc.ArgumentError(
734 "Can't call map_column=True for synonym %r=%r, "
735 "a ColumnProperty already exists keyed to the name "
736 "%r for column %r"
737 % (self.key, self.name, self.name, self.key)
738 )
739 p = properties.ColumnProperty(
740 parent.persist_selectable.c[self.key]
741 )
742 parent._configure_property(self.name, p, init=init, setparent=True)
743 p._mapped_by_synonym = self.key
744
745 self.parent = parent