Add docs for observers
This commit is contained in:
@@ -13,6 +13,7 @@ SQLAlchemy-Utils provides custom data types and various utility functions for SQ
|
|||||||
data_types
|
data_types
|
||||||
range_data_types
|
range_data_types
|
||||||
aggregates
|
aggregates
|
||||||
|
observers
|
||||||
decorators
|
decorators
|
||||||
generic_relationship
|
generic_relationship
|
||||||
database_helpers
|
database_helpers
|
||||||
|
|||||||
6
docs/observers.rst
Normal file
6
docs/observers.rst
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Observers
|
||||||
|
=========
|
||||||
|
|
||||||
|
.. automodule:: sqlalchemy_utils.observer
|
||||||
|
|
||||||
|
.. autofunction:: observes
|
||||||
@@ -79,6 +79,9 @@ generator = AttributeValueGenerator()
|
|||||||
|
|
||||||
def generates(attr, source=None, generator=generator):
|
def generates(attr, source=None, generator=generator):
|
||||||
"""
|
"""
|
||||||
|
.. deprecated:: 0.28.0
|
||||||
|
Use :meth:`.observer.observes` instead.
|
||||||
|
|
||||||
Decorator that marks given function as attribute value generator.
|
Decorator that marks given function as attribute value generator.
|
||||||
|
|
||||||
Many times you may have generated property values. Usual cases include
|
Many times you may have generated property values. Usual cases include
|
||||||
|
|||||||
@@ -1,3 +1,154 @@
|
|||||||
|
"""
|
||||||
|
This module provides a decorator function for observing changes in given
|
||||||
|
property. Internally the decorator is implemented using SQLAlchemy event
|
||||||
|
listeners. Both column properties and relationship properties can be observed.
|
||||||
|
|
||||||
|
Property observers can be used for pre-calculating aggregates and automatic
|
||||||
|
real-time data denormalization.
|
||||||
|
|
||||||
|
Simple observers
|
||||||
|
----------------
|
||||||
|
|
||||||
|
At the heart of the observer extension is the :func:`observes` decorator. You
|
||||||
|
mark some property path as being observed and the marked method will get
|
||||||
|
notified when any changes are made to given path.
|
||||||
|
|
||||||
|
Consider the following model structure:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
class Director(Base):
|
||||||
|
__tablename__ = 'director'
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
name = sa.Column(sa.String)
|
||||||
|
date_of_birth = sa.Column(sa.Date)
|
||||||
|
|
||||||
|
class Movie(Base):
|
||||||
|
__tablename__ = 'movie'
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
name = sa.Column(sa.String)
|
||||||
|
director_id = sa.Column(sa.Integer, sa.ForeignKey(Director.id))
|
||||||
|
director = sa.orm.relationship(Director, backref='movies')
|
||||||
|
|
||||||
|
|
||||||
|
Now consider we want to show movies in some listing ordered by director id
|
||||||
|
first and movie id secondly. If we have many movies then using joins and
|
||||||
|
ordering by Director.name will be very slow. Here is where denormalization
|
||||||
|
and :func:`observes` comes to rescue the day. Let's add a new column called
|
||||||
|
director_name to Movie which will get automatically copied from associated
|
||||||
|
Director.
|
||||||
|
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
from sqlalchemy_utils import observes
|
||||||
|
|
||||||
|
|
||||||
|
class Movie(Base):
|
||||||
|
# same as before..
|
||||||
|
director_name = sa.Column(sa.String)
|
||||||
|
|
||||||
|
@observes('director')
|
||||||
|
def director_observer(self, director):
|
||||||
|
self.director_name = director.name
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This example could be done much more efficiently using a compound foreing
|
||||||
|
key from direcor_name, director_id to Director.name, Director.id but for
|
||||||
|
the sake of simplicity we added this as an example.
|
||||||
|
|
||||||
|
|
||||||
|
Observes vs aggregated
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
:func:`observes` and :func:`.aggregates.aggregated` can be used for similar
|
||||||
|
things. However performance wise you should take the following things into
|
||||||
|
consideration:
|
||||||
|
|
||||||
|
* :func:`observes` works always inside transaction and deals with objects. If
|
||||||
|
the relationship observer is observing has large number of objects its better
|
||||||
|
to use :func:`.aggregates.aggregated`.
|
||||||
|
* :func:`.aggregates.aggregated` always executes one additional query per
|
||||||
|
aggregate so in scenarios where the observed relationship has only handful of
|
||||||
|
objects its better to use :func:`observes` instead.
|
||||||
|
|
||||||
|
|
||||||
|
Example 1. Movie with many ratings
|
||||||
|
|
||||||
|
Let's say we have a Movie object with potentially thousands of ratings. In this
|
||||||
|
case we should always use :func:`.aggregates.aggregated` since iterating
|
||||||
|
through thousands of objects is slow and very memory consuming.
|
||||||
|
|
||||||
|
Example 2. Product with denormalized catalog name
|
||||||
|
|
||||||
|
Each product belongs to one catalog. Here it is natural to use :func:`observes`
|
||||||
|
for data denormalization.
|
||||||
|
|
||||||
|
|
||||||
|
Deeply nested observing
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Consider the following model structure where Catalog has many Categories and
|
||||||
|
Category has many Products.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
class Catalog(Base):
|
||||||
|
__tablename__ = 'catalog'
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
product_count = sa.Column(sa.Integer, default=0)
|
||||||
|
|
||||||
|
@observes('categories.products')
|
||||||
|
def product_observer(self, products):
|
||||||
|
self.product_count = len(products)
|
||||||
|
|
||||||
|
categories = sa.orm.relationship('Category', backref='catalog')
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = 'category'
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
catalog_id = sa.Column(sa.Integer, sa.ForeignKey('catalog.id'))
|
||||||
|
|
||||||
|
products = sa.orm.relationship('Product', backref='category')
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
__tablename__ = 'product'
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
price = sa.Column(sa.Numeric)
|
||||||
|
|
||||||
|
category_id = sa.Column(sa.Integer, sa.ForeignKey('category.id'))
|
||||||
|
|
||||||
|
|
||||||
|
:func:`observes` is smart enough to:
|
||||||
|
|
||||||
|
* Notify catalog objects of any changes in associated Product objects
|
||||||
|
* Notify catalog objects of any changes in Category objects that affect
|
||||||
|
products (for example if Category gets deleted, or a new Category is added to
|
||||||
|
Catalog with any number of Products)
|
||||||
|
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
category = Category(
|
||||||
|
products=[Product(), Product()]
|
||||||
|
)
|
||||||
|
category2 = Category(
|
||||||
|
product=[Product()]
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = Catalog(
|
||||||
|
categories=[category, category2]
|
||||||
|
)
|
||||||
|
session.add(catalog)
|
||||||
|
session.commit()
|
||||||
|
catalog.product_count # 2
|
||||||
|
|
||||||
|
session.delete(category)
|
||||||
|
session.commit()
|
||||||
|
catalog.product_count # 1
|
||||||
|
|
||||||
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from collections import defaultdict, namedtuple, Iterable
|
from collections import defaultdict, namedtuple, Iterable
|
||||||
@@ -42,6 +193,9 @@ class PropertyObserver(object):
|
|||||||
if not sa.event.contains(*args):
|
if not sa.event.contains(*args):
|
||||||
sa.event.listen(*args)
|
sa.event.listen(*args)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<PropertyObserver>'
|
||||||
|
|
||||||
def update_generator_registry(self, mapper, class_):
|
def update_generator_registry(self, mapper, class_):
|
||||||
"""
|
"""
|
||||||
Adds generator functions to generator_registry.
|
Adds generator functions to generator_registry.
|
||||||
@@ -129,6 +283,44 @@ observer = PropertyObserver()
|
|||||||
|
|
||||||
|
|
||||||
def observes(path, observer=observer):
|
def observes(path, observer=observer):
|
||||||
|
"""
|
||||||
|
Mark method as property observer for given property path. Inside
|
||||||
|
transaction observer gathers all changes made in given property path and
|
||||||
|
feeds the changed objects to observer-marked method at the before flush
|
||||||
|
phase.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
from sqlalchemy_utils import observes
|
||||||
|
|
||||||
|
|
||||||
|
class Catalog(Base):
|
||||||
|
__tablename__ = 'catalog'
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
category_count = sa.Column(sa.Integer, default=0)
|
||||||
|
|
||||||
|
@observes('categories')
|
||||||
|
def category_observer(self, categories):
|
||||||
|
self.category_count = len(categories)
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = 'category'
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
catalog_id = sa.Column(sa.Integer, sa.ForeignKey('catalog.id'))
|
||||||
|
|
||||||
|
|
||||||
|
catalog = Catalog(categories=[Category(), Category()])
|
||||||
|
session.add(catalog)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
catalog.category_count # 2
|
||||||
|
|
||||||
|
|
||||||
|
.. versionadded: 0.28.0
|
||||||
|
|
||||||
|
:param path: Dot-notated property path, eg. 'categories.products.price'
|
||||||
|
:param observer: :meth:`PropertyObserver` object
|
||||||
|
"""
|
||||||
observer.register_listeners()
|
observer.register_listeners()
|
||||||
|
|
||||||
def wraps(func):
|
def wraps(func):
|
||||||
|
|||||||
Reference in New Issue
Block a user