From e1de4836e50b743776db71690988eff65e0fed83 Mon Sep 17 00:00:00 2001 From: Konsta Vesterinen Date: Fri, 12 Dec 2014 10:57:22 +0200 Subject: [PATCH] Add docs for observers --- docs/index.rst | 1 + docs/observers.rst | 6 ++ sqlalchemy_utils/decorators.py | 3 + sqlalchemy_utils/observer.py | 192 +++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 docs/observers.rst diff --git a/docs/index.rst b/docs/index.rst index e43720d..97cc80a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,6 +13,7 @@ SQLAlchemy-Utils provides custom data types and various utility functions for SQ data_types range_data_types aggregates + observers decorators generic_relationship database_helpers diff --git a/docs/observers.rst b/docs/observers.rst new file mode 100644 index 0000000..33a12a9 --- /dev/null +++ b/docs/observers.rst @@ -0,0 +1,6 @@ +Observers +========= + +.. automodule:: sqlalchemy_utils.observer + +.. autofunction:: observes diff --git a/sqlalchemy_utils/decorators.py b/sqlalchemy_utils/decorators.py index 9a8a071..8df1d4c 100644 --- a/sqlalchemy_utils/decorators.py +++ b/sqlalchemy_utils/decorators.py @@ -79,6 +79,9 @@ generator = AttributeValueGenerator() 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. Many times you may have generated property values. Usual cases include diff --git a/sqlalchemy_utils/observer.py b/sqlalchemy_utils/observer.py index a6bf0b7..d5b51ac 100644 --- a/sqlalchemy_utils/observer.py +++ b/sqlalchemy_utils/observer.py @@ -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 from collections import defaultdict, namedtuple, Iterable @@ -42,6 +193,9 @@ class PropertyObserver(object): if not sa.event.contains(*args): sa.event.listen(*args) + def __repr__(self): + return '' + def update_generator_registry(self, mapper, class_): """ Adds generator functions to generator_registry. @@ -129,6 +283,44 @@ observer = PropertyObserver() 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() def wraps(func):