Показаны сообщения с ярлыком sqlalchemy. Показать все сообщения
Показаны сообщения с ярлыком sqlalchemy. Показать все сообщения

17 дек. 2014 г.

Грязные технологии: миксины моделей SQLAlchemy

SQLAlchemy позволяет выносить часть определения модели в отдельный базовый класс, который будет потом «подмешиваться» к другим. Очень удобно, когда есть какой-то повторяющийся кусок в большом количестве классов моделей. Но есть один неприятный момент: все поля должны «знать», к какой модели они принадлежат, а для этого нужно копировать объект поля. С первым уровнем SQLAlchemy хорошо справляется, так что с простым Column всё путём, но ведь поля могут содержать ссылки на другие объекты (например, ForeignKey), к которым предъявляются такие же требования. И тут авторы SQLAlchemy пошли самым простым путём: если нельзя сделать автоматически на все случаи жизни, то не пусть делает пользователь вручную. В результате код миксина должен выглядеть примерно так:
class WithParent(object):
    @declared_attr
    def parent_id(cls):
        return Column(ForeignKey(Parent.id))
    @declared_attr
    def parent(cls):
        return relationship(Parent)
По сути обработанные declared_attr свойства решают ту же проблему, которую мы уже решали декоратором return_locals, а именно позволить выполнять код в определении класса несколько раз. Поэтому и решение напрашивается то же, только теперь нам нужно все свойства дополнительно завернуть в задекорированный метод. Понятно, что мы не хотим делать отдельный вызов нашей фабрики на каждый дескриптор, поэтому однажды полученный результат надо закешировать:
def declared_mixin(*args):

    def wrapper(func):
        attrs = weakref.WeakKeyDictionary()
        def create_descriptor(name):
            def get_attr(cls):
                if cls not in attrs:
                    # Call func only once per class
                    attrs[cls] = return_locals(func)()
                return attrs[cls][name]
            get_attr.__name__ = name
            return declared_attr(get_attr)
        dict_ = {name: create_descriptor(name)
                 for name in func.func_code.co_varnames}
        dict_['__doc__'] = func.__doc__
        return type(func.__name__, args, dict_)

    if len(args)==1 and not isinstance(args[0], type):
        # Short form (without args) is used
        func = args[0]
        args = ()
        return wrapper(func)
    else:
        return wrapper
Теперь наш пример миксина выглядит гораздо приятнее:
@declared_mixin
def WithParent():
    parent_id = Column(ForeignKey(Parent.id))
    parent = relationship(Parent)
В комментариях к прошлому посту Андрей Светлов резонно заметил, что хак слишком грязен для столь небольшого эффекта. В ситуации же с миксином полученный эффект уже больше: если в исходном варианте на одну смысловую строчку кода приходилось две строчки шума, то здесь мы от шума полностью избавились. И дело даже не в том, что строк стало меньше, зашумлённый код гораздо сложнее читать. Вопрос поиска менее грязных путей получения нужного результата остаётся открытым.

3 дек. 2014 г.

Грязные технологии: фабрика классов на основе функции

Так уж получилось, что нам часто требуется определять одинаковые (или почти одинаковые) классы моделей SQLAlchemy для разных MetaData ну и, соответственно, с разными базовыми классами. Декларативно. Повсеместный copy-paste быстро надоел. Были мысли создавать второй класс путём копирования, но уж больно сложно получается: не так просто определить, где на какой глубине остановиться, а где заменить ссылки на что-то уже отзеркалированное. Гораздо проще сделать фабрику и создавать столько классов, сколько нужно. В питоне ж это просто:
def create_C(Base):
    class C(Base):
        id = Column(Integer, primary_key=True)
        # …
    return C
Всё хорошо, только вот отступ лишний появляется. Кому-о, может, и мелочь, а нам он сильно не понравился. Неужели нельзя без него? Ну типа класс задекорировать чем-о так, чтобы он в фабрику превратился. Проблема в том, что тело класса выполняется сразу и только один раз, и никакими декораторами это правило не отменить. Ну да, можно отменить: всего-то с байткодом чуть поколдовать. Только уж очень гразным хак получается и переносимость между версиями под большим вопросом.
Зато вот у функции тело можно выполнять когда захочешь и сколько угодно раз. Почему бы этим не воспользоваться и не превратить декоратором функцию в класс?
@create_class(Base)
def C():
    id = Column(Integer, primary_key=True)
    # …
    return locals()
Опять что-то лишнее, теперь return locals(). А в реальных задачах у нас появятся аргументы у функции (пространство имён с другими моделями, например — нам же надо как-то внешние ссылки да связи определять), которыми не захочется пространство имён класса засорять, так что строчка ещё усложнится.
Вот бы здорово было бы вернуть локально определённые переменные автоматически. И это как раз можно сделать. Немного оптимизации (зачем нам держать трейсер весь вызов, это же приличные накладные расходы?), немного уважения к тем, кто это будет потом отлаживать дебаггером или профилировать, и получается такой декоратор:
import sys, functools, inspect


def return_locals(func):
    '''Modifies decorated function to return its locals'''

    @functools.wraps(func)
    def wrap(*args, **kwargs):
        frames = []

        def tracer(frame, event, arg):
            frames.append(frame)
            sys.settrace(old_tracer)
            if old_tracer is not None:
                return old_tracer(frame, event, arg)

        old_tracer = sys.gettrace()
        sys.settrace(tracer)
        try:
            func(*args, **kwargs)
        finally:
            sys.settrace(old_tracer)
        assert len(frames) == 1
        argspec = inspect.getargspec(func)
        argnames = list(argspec.args)
        if argspec.varargs is not None:
            argnames.append(argspec.varargs)
        if argspec.keywords is not None:
            argnames.append(argspec.keywords)
        return {name: value 
                for name, value in frames.pop(0).f_locals.items()
                if name not in argnames}

    return wrap
Осталась мелочь, создать декоратор самой фабрики:
def create_class(*bases):
    def wrapper(func):
        return type(func.__name__, bases, return_locals(func)())
    return wrapper

3 мая 2012 г.

Automatic filtering in SQLAlchemy: motivation

Server side code of web project usually has 3 layers:

  • data classes mapped to relational database,
  • request handlers for each URL pattern,
  • templates used to render pages.

Simple request handlers contain code like the following:

item = session.query(Entry).get(item_id)
or
items = session.query(Entry)[:limit]

When Entry class has public attribute and objects should be shown when Entry.public is True only (the simplest example of publicity condition; in real life it might be composite and even involve related tables) we have to include this condition in queries:

item = session.query(Entry).filter_by(public=True, id=item_id).scalar()
or
items = session.query(Entry).filter_by(public=True)[:limit]

Note, that we already violate DRY principle (the same condition should be used every time we query Entry), but it’s still not problem. Now let’s add relation to some Child class that has similar condition for publicity. If we pass only item or items to template, we have to be careful using their data:

{% for child in item.children %}…{% endfor %}
must be replaced with
{% for child in item.children %}
{% if child.public %}…{% endif %}
{% endfor %}

In real life it becomes even more complex: a simple test for empty list is already not so simple. Do we have other options? Yes, we can pass each relation as separate variable and move filtering to the code. This will prevent mess in templates, but this won’t prevent us from using relations directly by mistake. Do you think this shouldn’t happen? We are lazy, and I doubt anybody will define separate variable for relation that doesn’t have publicity condition (yet). But life changes and eventually we might need this condition. Now one developer adds new field to the database, changes all related request handlers and (if he is a responsible person) even templates. Simultaneously (or even later, since people remember code patterns they often used) other person adds usage of this relation unfiltered in some other place and we have unpublished data leaked to public. International scandal, world war III begins (joke).

In fact, templates developer shouldn’t care about publicity of data. Unpublished data must not reach templates.
Constructing some data structures specially for templates leads to verbose request handler code instead for concise single line:
item = session.query(Entry).filter_by(public=True, id=item_id).scalar()
data = {‘id’: item.id,
        ‘title’: item.title,
        ‘date’: item.date,
        ‘body’: item.body}
data[‘children’] = children = []
for child in item.children:
    if not child.public:
        continue
    child_data = {‘id’: child.id,
                  ‘title’: child.title,
                  ‘data’: child.data,
                  ‘body’: child.body}
    if child.author and child.author.public:
        child_data[‘author’] = author = {‘id’: child.author.id,
                                         ‘name’: child.author.name}
        if child.author.company and child.author.company.public:
            author[‘company’] = {‘id’: child.author.company.id,
                                 ‘title’: child.author.company.title}

Here is statistics from one big project where I’m involved in development. The numbers below cover public segment only (internal services like editor interface are not included).

  • 458 templates
  • 6 databases with 210 tables
  • 135 mapped classes, 5 of them are bases for inheritance trees
  • Data for 63 mapped classes must not go to public unless some condition is met (15 of them indirectly through inheritance). Those are only conditions that can’t be applied when replicating data from internal segment to public without significant impact on performance (changing state field of parent object would trigger publication or deletion of a huge list of children; using publication time in future requires some scheduler to trigger publication), the rest is filtered out before reaching database for public sites.


Having we can’t change relations behavior in request handler (this breaks ORM’s single object for each identity rule) I see the following 2 ways to solve the problem:

  • define separate mapped classes for public site,
  • instruct session to filter all ORM queries.

Both ways have problems and require separate analysis.

4 дек. 2009 г.

Автоматическая фильтрация публичных данных в SQLAlchemy

В CMS в таблице для (почти) каждой сущности обычно добавляется поле-флаг, определяющее, должна ли данная сущность показываться на сайте. В коде сайта, соответвенно, необходимо не забывать добавлять соответствующее условие в каждый запрос. При использовании ORM мы автоматичеси получаем связанные сущности, для которых запрос генерируется автоматически. Это удобно, но теперь нам ещё нужно проверять, нужно ли показывать каждый из связанных объектов. Есть ещё множество ситуаций, когда такие проверки или добавление дополнительных условий также необходимы. Шансы, что в большом проекте где-то об этом забудут, близки к 100%. Поэтому очень желательно процесс фильтрации непубличных данных автоматизировать. В django для этих целей используют специально написанный менеджер. В древней библиотеки QPS с некоторым подобием ORM сделано даже лучше: можно для разных тегов выборки определить разные правила формирования запроса и даже правила переноса тегов на связанные обекты.
Как же быть с решением проблемы автоматической фильтрации в SQLAlchemy? Существует возможность при создании сессии подставить свой конструктор запроса через атрибут query_cls в sessionmaker. Но предлагаемые в архивах Google-группы sqlalchemy решения уже не работают, так как метод Query.get() теперь не предназначен для объектов с условием. Я написал свою реализацию метода, без этого ограничения. Вот что получилось в результате:
class HackedQuery(Query):

    def get(self, ident):
        # Use default implementation when there is no condition
        if not self._criterion:
            return Query.get(self, ident)
        # Copied from Query implementation with some changes.
        if hasattr(ident, '__composite_values__'):
            ident = ident.__composite_values__()
        mapper = self._only_mapper_zero(
                    "get() can only be used against a single mapped class.")
        key = mapper.identity_key_from_primary_key(ident)
        if ident is None:
            if key is not None:
                ident = key[1]
        else:
            from sqlalchemy import util
            ident = util.to_list(ident)
        if ident is not None:
            columns = list(mapper.primary_key)
            if len(columns)!=len(ident):
                raise TypeError("Number of values doen't match number "
                                'of columns in primary key')
            params = {}
            for column, value in zip(columns, ident):
                params[column.key] = value
            return self.filter_by(**params).first()


def QueryPublic(entities, session=None):
    # It's not derectly related to the problem, but is useful too.
    query = HackedQuery(entities, session).with_polymorphic('*')
    # I haven't ever seen examples with several entities, so I can test
    # this case.
    assert len(entities)==1, entities
    cls = _class_to_mapper(entities[0]).class_
    public_condition = getattr(cls, 'public_condition', None)
    if public_condition is not None:
        query = query.filter(public_condition)
    return query

4 мая 2009 г.

Несколько баз данных в SQLAlchemy и наследование

Как я писал ранее, в SQLAlchemy есть средства для работы с нескольким базами. Первая проблема, с которой я столкнулся — автоматический выбор соединения перестаётся работать для моделей с наследованием. Дело в том, что метод get_bind() сессии делает поиск соединения с нашем словаре на основе атрибута mapped_table маппера объекта. Для обычных моделей это объект класса Table, на основе которого я и строил словарь с соединениями. Но для производных классов при использовании joined table inheritance это объект класса Join для нескольких таблиц. На самом деле выбор соединения для запросов (каковым объект Join и является) в SQLAlchemy реализован, но почему-то не используется для моделей. Во избежание переписывания всего метода get_bind() я просто передал mapped_table вторым аргументом:
class DBSession(sqlalchemy.orm.session.Session):

    def get_bind(self, mapper, clause=None):
        if mapper is not None and clause is None:
            c_mapper = sqlalchemy.orm.util._class_to_mapper(mapper)
            if hasattr(c_mapper, 'mapped_table'):
                clause = mapper.mapped_table
        return sqlalchemy.orm.session.Session.get_bind(self, mapper, clause)

27 апр. 2009 г.

Несколько баз данных в SQLAlchemy

Так уж получается, что во многих проектах мне приходится сталкиваться с ситуацией, когда часть хранится в обдной базе данных, а часть в другой. И несмотря на мою нелюбовь к SQLAlchemy, он позволяет организовать такую работу просто и прозрачно. В SQLAlchemy есть объекты класса MetaData, которые служат в качестве реестра имеющихся таблиц. При описании таблиц (явно или при декларативном описании модели) можно указывать разные объекты MetaData для данных, которые предполагается хранить в разных местах. Далее вы можете привязать метаданные каждый к своему соединению с базой данных, а можно научить средства ORM SQLAlchemy выбирать нужное соединение во время выполнения. Для этого при создании сессии передаётся параметр binds. Так как схемы данных для разных баз совершенного естественным образом располагаются в разных модулях (пакетах), мне оказалось удобным использовать в качестве ссылки на метаданные имена модулей, в которых они определены:
binds = {}
for module_name, connection_string in db_config.items():
    metadata = __import__(module_name, None, None, ['metadata']).metadata
    engine = sqlalchemy.create_engine(connection_string, pool_recycle=True)
    for table in metadata.sorted_tables:
        binds[table] = engine
db = sqlalchemy.orm.sessionmaker(binds=binds)()
После этого я работаю с сессией (db) не заботясь о том, где на самом деле хранятся данные. Более того, SQLAlchemy позволяет использовать двуфазные транзакции (twophase=True в sessionmaker) для обеспечения сохранности данных при такой работе.
Ситуация, когда разные данные хранятся в отдельных базах, это только одна из возможных задач. SQLAlchemy также имеет средства для распределённого хранения данных или позволяет относительно легко такие средства создавать. Но на самом деле картина не такая радужная, как я здесь нарисовал. При использование некоторых средств проявляются баги и недоработки в SQLAlchemy. Но об этом позже.

21 окт. 2008 г.

Сохранение в SQLAlchemy под контролем

Большинство задач, для которых используется SQLAlchemy — это веб-приложения, отличающиеся небольшим количеством действий, выполняемых на один запрос, короткими транзакциями. И для этих целей типовая схема работы, расписанная в документации, подходит очень хорошо. Но работа с базами данных нужна не только в веб-приложениях, и даже в веб-приложениях иногда есть отдельные процессы с более сложными операциями.
Типовая схемы работы предполагает накопление некоторого количества изменений и вызов метода flush() у сессии, который сохраняет все изменения в базе. А теперь представьте, что будет, если в ходе работы на одной из итераций мы получаем исключение, мы это исключение обрабатываем и продолжаем работу? Вполне резонно, что часть ("ошибочных") накопленных изменений должна пропасть, то есть не попасть в базу. Но ведь метод flush() предполагает сохранения именно всех изменений. Конечно, мы можем очистить сессию и произвести инициализацию заново — достаточно неудобно, да и зачем снова загружать данные, которые не могли измениться? Кто-то резонно заметит, что в метод flush() можно передать список объектов для сохранения. Да, это именно то, что нужно. Только следует понимать, что в этом случае сохраняться будут только эти объекты, но не объекты, которые от них зависят, то есть cascade rules перестают работать. В итоге мы не можем использовать autoflush=True и должны самостоятельно отслеживать каскадные правила при сохранении. Аналогично не стоит использовать transactional=True, так как в этом случае транзакция открывается сразу же после закрытия предыдущей, и при длительной работе без commit()-ов могут возникать значительные замедления в работе базы данных.
Ещё одна неприятная особенность есть у SessionTransaction. Используя другие библиотеки для работы с базами данных я привык, что можно определить метод с некоторой транзакцией, а затем вызывать его из другого метода, в котором к исходным действиям добавляются ещё какие-то, и всё это, конечно, в одной общей транзакции. Но дело в том, что сессионные транзакции в SQLAlchemy не могут быть вложенными. На самом деле всё гораздо хуже, они могут быть вложенными, но результат будет отличным от ожидаемого: транзакция будет закрыта уже при при вызове commit() внутренней транзакции. Проблема решается использованием объекта транзакции для соединения, который работает как нужно.
Подытожу всё сказанное в классе Storage (недостающие методы не представляют сложности в реализации):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker


class Storage(object):

    def __init__(self, dbURL):
        self._engine = create_engine(dbURL)
        self._conn = self._engine.connect()
        self._session = sessionmaker(bind=self._conn, autoflush=False,
                                     transactional=False)()

    def transaction(self):
        return self._conn.begin()

    def store(self, obj):
        with self.transaction():
            self._session.save_or_update(obj)
            from sqlalchemy.orm.session import _cascade_iterator
            cascaded = [o for o, m in _cascade_iterator('save-update', obj)]
            self._session.flush([obj]+cascaded)

25 сент. 2008 г.

Сохранение времени в базе данных

Очень часто бывает, что практически все знают, как надо делать правильно, но при этом всё равно постоянно делают неправильно. Один из таких случаев — сохрание времени в базе данных. Понятно, что на персональном блоге вполне можно обойтись наивных подходом, не учитывающим перевод времени. Но для круглосуточно работающих приложений строгой системы отчётности вроде биллинга это неприемлемо.
Я не буду здесь рассматривать все возможные варианты корректной работы со временем. Покажу лишь насколько просто можно реализовать самый распространённый вариант — хранение в базе в UTC — на примере SQLAlchemy. Для этого достаточно определить новый тип колонки:
from sqlalchemy import types
from dateutil.tz import tzutc
from datetime import datetime

class UTCDateTime(types.TypeDecorator):

    impl = types.DateTime

    def process_bind_param(self, value, engine):
        if value is not None:
            return value.astimezone(tzutc()).replace(tzinfo=None)

    def process_result_value(self, value, engine):
        if value is not None:
            return datetime(value.year, value.month, value.day,
                            value.hour, value.minute, value.second,
                            value.microsecond, tzinfo=tzutc())
Теперь вы можете сохранять время с произвольной зоной, все преобразования будут сделаны автоматически. Но сохранить время без зоны не получится — метод astimezone() выбросит исключение ValueError, что позволит избежать случайных ошибок.

19 июн. 2008 г.

Борьба с обNULLением в SQLAlchemy

SQLAlchemy, пожалуй, самый продвинутый ORM для питона. Но, к сожалению, он постоянно подбрасывает неприятные сюрпризы. В очередной раз натолкнувшись на одну из проблем и потратив время на повторный поиск её решения, я решил его задокументировать. Речь об установки в NULL поля с идентификатором при удалении объекта, на который он ссылается, если в маппере для связи используется relation. Для тех, кто привык работать с SQL, такое поведение по умолчанию в лучшем случае вызывает недоумение. Фактически оно означает использование на уровне кода по умолчанию правила ON DELETE SET NULL, вместо привычного (и логичного!) ON DELETE RESTRICT. Если бы не моя чрезмерная педантичность в проставлении nullable=False для полей с FOREIGN KEY, этот сюрприз мог бы привести к весьма печальным последствиям - потери данных. Упоминание об описанном поведении в документации к SQLAlchemy встречается только один раз - при описании ключа passive_deletes функции relation(). Собственно его установка в 'all' и решает проблему. Так как имя ключа ничего не говорит о его истинном назначении, то соответствующий комментарий явно не помешает.