doc-versions

Django app for tracking changes in models


License
MIT
Install
pip install doc-versions==0.1.1

Documentation

Версионирование данных в реляционной БД

Обработка и хранение данных с временными параметрами

Авторы: В. А. Фёдоров, К. А. Лопухин
Аннотация: Предлагается модель представления и методы обработки временных данных в реляционных СУБД с примерами для Django.

Существует множество видов данных, содержащих в качестве одного из компонентов момент времени:

  • результат замера какой-нибудь величины (уровень воды по часам)
  • план работы
  • состояние документа (версии)
  • состояние процесса (workflow)

Простейший способ

drop table if exists data ;
create table data (
      vtime timestamp DEFAULT CURRENT_TIMESTAMP ,
      vid integer ,    -- идентификатор
      v text           -- значение
      ) ;

Предполагается что в таблице хранится множество измерений (v) от разных источников (vid) в разные моменты времени (vtime). Для примера поместим в таблицу несколько значений:

insert into data (vtime,vid,v) values ('2000-01',1,'2000 - 1') ;
insert into data (vtime,vid,v) values ('2001-01',1,'2001 - 1') ;
insert into data (vtime,vid,v) values ('2002-01',1,'2002 - 1') ;
insert into data (vtime,vid,v) values ('2000-01',2,'2000 - 2') ;
insert into data (vtime,vid,v) values ('2001-01',2,'2001 - 2') ;
insert into data (vtime,vid,v) values ('2001-01',3,'2001 - 3') ;
insert into data (vtime,vid,v) values ('2002-01',3,'2002 - 3') ;

Если точно знать момент времени, то легко найти значения:

select v from data where vtime='2001-01' ;

2001 - 1
2001 - 2
2001 - 3

Проблема возникает если момент времени задан не точно. Тогда имеет смысл запрос на ближайшее известное значение (например снизу). При этом, если нужно показание конкретного источника, то задачу можно решить с помощью сортировки и выбора первого значения:

select v from data where vid=2 and vtime<='2001-05'
  order by vtime DESC limit 1 ;

2001 - 2

В случае, когда нужны все источники, задача решается еще более сложным способом с использованием агрегирующих функций:

select vid,v from data
where vtime=(select max(d.vtime) from data as d where
d.vtime<='2001-05' and d.vid=data.vid) ;

1      2001 - 1
2      2001 - 2
3      2001 - 3

Или более эффективно (при большом наборе данных и индексировании):

select data.vid,v from data ,
( select max(vtime) as t,vid
  from data where vtime<='2001-05' group by vid) as d
where vtime=t and data.vid=d.vid ;

1      2001 - 1
2      2001 - 2
3      2001 - 3

Интервалы

Note

В последних постгресах есть with, который позволяет упростить решения!

Предложенные выше конструкции довольно громоздки и неэффективны (select встречается дважды), особенно если выбор значений должен рассматриваться как подзадача более сложной обработки полученных данных.

Упростить решение можно с помощью добавления избыточных данных, т.е. вместо даты регистрации хранить интервал действия данного значения:

drop table if exists ndata ;
create table ndata (
      vstart timestamp DEFAULT CURRENT_TIMESTAMP , -- начало действия (вкл.)
      vend timestamp DEFAULT '9999-01' ,      -- конец действия (искл.)
      vid integer ,    -- идентификатор
      v text           -- значение
      ) ;

Предыдущую таблицу можно преобразовать к новому виду:

insert into ndata (vstart,vid,v) select vtime,vid,v from data ;

update ndata set vend = ( -- минимум из всех начал после данного
 select min(d.vstart) from ndata as d
 where d.vstart>ndata.vstart and d.vid=ndata.vid )
where exists ( -- существуют данные после текущего
 select * from ndata as d
 where d.vstart>ndata.vstart and d.vid=ndata.vid ) ;

Результат:

vstart         vend    vid     v
2000-01        2001-01 1       2000 - 1
2001-01        2002-01 1       2001 - 1
2002-01        9999-01 1       2002 - 1
2000-01        2001-01 2       2000 - 2
2001-01        9999-01 2       2001 - 2
2001-01        2002-01 3       2001 - 3
2002-01        9999-01 3       2002 - 3

Интервалы не пересекаются (считаем нижнюю границу включительно, а верхнюю исключительно). Задачи из предыдущего пункта решаются легко и одинаково:

select v from ndata where vid=2 and vstart<='2001-05' and vend>'2001-05' ;

для поиска из конкретного источника (независимо от совпадения с границей) и:

select vid,v from ndata where vstart<='2001-05' and vend>'2001-05' ;

для поиска всех источников.

Усложняются процедуры вставки:

insert into ndata (vstart,vend,vid,v) values ('2000-05',
    -- значение для правого конца интервала:
    case when exists ( -- попали в имеющийся
           select * from ndata
           where vid=2 and ndata.vstart<'2000-05' and ndata.vend>'2000-05' )
         then ( select vend from ndata
                where vid=2 and ndata.vstart<'2000-05' and
                                ndata.vend>'2000-05' )
         when exists ( -- в начало
           select * from ndata where vid=2 )
         then ( select min(vstart) from ndata where vid=2 )
         else '9999-01' end,
 2,'2000-05 2') ;
-- исправляем конец интервала, в который попали:
update ndata set vend='2000-05'
where vid=2 and vstart<'2000-05' and vend>'2000-05' ;

и удаления:

-- изменяем правый конец предыдущего интервала
update ndata set vend=(select vend from ndata
                      where vid=2 and vstart='2000-05')
where vid=2 and vend='2000-05' ;
delete from ndata where vid=2 and vstart='2000-05' ;

Вставка может быть более простой если известен контекст:

-- первое значение для vid=4:
insert into ndata (vstart,vid,v) values ('2000-05',4,'2000-05 4') ;

 -- вставка в начало:
insert into ndata
select '1999-01',min(vstart),2,'1999-01 2' from ndata where vid=2 ;

-- вставка в конец или внутрь
insert into ndata
select '2004-05',vend,2,'2004-05 2' from ndata
where vid=2 and vstart<'2004-05' and vend>'2004-05' ;
update ndata set vend='2004-05'
where vid=2 and vstart<'2004-05' and vend=>'2004-05' ;

Массовую вставку значений можно сделать эффективно если отложить вычисление правых границ на потом, как при импорте из data.

Связи между таблицами

Идентификатор и момент времени однозначно определяют значение (если оно есть). То есть пару (момент времени,идентификатор) можно использовать в качестве внешнего ключа таблицы ndata. Пусть есть произвольный список моментов времени и идентификаторов:

drop table if exists idvtime ;
create table idvtime ( id integer , t timestamp ) ;
insert into idvtime values (3,'2000-01') ;
insert into idvtime values (2,'2000-05') ;
insert into idvtime values (1,'2001-01') ;
insert into idvtime values (3,'2001-05') ;
insert into idvtime values (2,'2002-01') ;

Тогда список значений из ndata можно присоединить запросом:

select id,t,v
from idvtime left join ndata on (id=vid and vstart<=t and vend>t) ;

id     t       v
3      2000-01 NULL
2      2000-05 2000 - 2
1      2001-01 2001 - 1
3      2001-05 2001 - 3
2      2002-01 2001 - 2

Документы и версии

Существует множество систем управления версиями: cvs, svn, git, darcs и т.д. Их основная функция - хранение версий документов, запоминание состояний документов в какие-то моменты времени с возможностью просмотра прошлых состояний или даже возврата в какое-то предыдущее состояние. Все системы работают с документами произвольного вида (в основном текстовыми) и предназначены в большинстве своем для сопровождения процесса разработки программ. Применим аналогичный подход к данным, хранящимся в базе данных. Цели:

  • возможность вернуться к предыдущему состоянию документа;
  • возможность проследить динамику изменения документа;
  • возможность исследовать состояние всей системы (получить отчеты) в произвольный момент в прошлом (ретроспекция).
Документ
связанный набор данных, возможно из нескольких таблиц, воспринимаемых пользователем как единое целое. Документ имеет тип, и уникальный (для данного типа) идентификатор экземпляра.

Документ может изменяться во времени (редактироваться пользователем или системой). Состояния, которые проходит документ со временем называются версиями документа.

Версии документов не изменяются. У каждой версии есть временной интервал ее действия. Последняя версия имеет правой границей плюс бесконечность - datetime.max. Документ считается удаленным из системы если в правую границу последней версии записать время удаления.

В базе храним версии документа. Одна из таблиц хранящих версию документа (возможно единственная) содержит интервал действия и идентификатор - главная таблица документа. Для каждой версии каждого документа в главной таблице одна строка.

Связи между таблицами документа

Главная таблица может быть связана с другими таблицами документа отношениями:

Один к одному

главная таблица содержит внешний ключ другой - ссылка на дополнительные данные, которые почему-то не хотим иметь в главной (например редко используются, а занимают много места).

Ссылка в обратную сторону имеет смысл если данные в другой таблице существуют для редко встречающихся версий (экономится место).

Многие к одному
поскольку версии не изменяются - при создании новой версии документа можно повторно использовать имеющиеся данные другой таблицы (если они не изменились) скопировав ссылку. Это еще одна причина хранения данных в другой таблице - редко изменяемые данные.
Один ко многим
другая таблица содержит ссылки на данную строку главной. Способ представления списка объектов относящихся к данной версии.
Многие ко многим
для такого отношения делается отдельная таблица содержащая ссылки в обе стороны, т. е. предыдущий вариант.

Другие таблицы могут быть связаны с третьими и т. д. Важно чтобы соблюдался принцип неизменяемости версии документа (и всех относящихся к документу данной версии компонент).

Для выполнения массовых операций (например для построения отчетов) важно простым способом получать временной срез произвольных частей документа, т.е. рассматривать только те записи, которые действуют на заданный момент времени. Сложность в том, что в таблице данных об интервале действия нет (она не главная). В большинстве случаев связи между компонентами документа можно представить в виде дерева, где главная таблица - его корень. Для каждого некорневого узла есть единственный путь наверх, к корню. Есть два вида связи между данным узлом и узлом верхнего уровня:

  1. Узел верхнего уровня содержит ссылку на данный.
  2. Данный узел содержит ссылку на узел верхнего уровня.

В обоих случаях можно автоматически вычислять префикс доступа к главной таблице и включать в запрос ограничения по времени.

Связи между документами

Документы могут быть связаны друг с другом, для чего в одном документе помещается ссылка на другой. Существует несколько вариантов связей по отношению к версиям связываемых документов:

  1. Ссылка на конкретную версию документа, заданную конкретным моментом времени в прошлом.
  2. Ссылка на версию документа, заданную моментом создания версии ссылающегося документа.
  3. Ссылка на последнюю версию документа.
  4. Ссылка на версию документа, заданную некоторым моментом в будущем (после создания версии ссылающегося документа).

Ссылка на документ общего вида задается парой (момент,идентификатор), и в общем случае необходимо отвести два поля в ссылающемся документе. В качестве момента времени для ссылки на последнюю версию можно взять произвольную дату, близкую к нашей бесконечности, например 9998-01-01.

В случаях, когда при построении модели заранее известно, что ссылка будет конкретного вида 2 или 3 - можно сэкономить, и хранить только идентификатор документа, на который ссылаются. Момент времени в случае 2 задается моментом создания версии ссылающегося документа, а в случае 3 - константа.

Разрешение ссылок

Если заранее известно, что ссылка будет вида 1 или 2, то ссылки можно разрешать, то есть вычислять первичный ключ таблицы, хранящей версии документа, на который ссылаемся, и хранить его значение. Идентификатор документа, на который ссылаемся можно найти пройдя по ссылке. В случае 1 момент времени определяется также проходом по ссылке, а во втором уже есть в ссылающемся документе.

Вариант 2 - наиболее часто встречающийся вид ссылок между документами, и разрешение ссылок может дать большой прирост производительности при выполнении массовых операций.

Ретроспекция

В процессе создания документов и установки связей участвует глобальное значение момента времени - сейчас. Обычно этот момент получается запросом к системным часам. Если это значение сделать параметром (который по умолчанию берется из системных часов), то все приложение приобретает новое качество - возможность просмотра состояния всей системы в произвольный момент времени в прошлом. Для этого необходимо во все процедуры выборки данных, построения отчетов и т.п. добавить фильтрацию документов - версии после сейчас не существуют. При этом ссылка на последнюю версию документа должна рассматриваться как ссылка в момент сейчас. Если параметр сейчас задан вручную, то приложение должно работать в режиме только для чтения.

Версии документов в Django

Для использования библиотеки необходимо включить в список приложений приложение documents: documents.models содержит абстрактные базовые классы для таблиц составляющих документ, а documents.fields тип поля для ссылки на документы.

API

documents.models

DocumentPart

– абстрактный базовый класс для всех таблиц документа. Задает:

now
– дополнительный менеджер, задающий фильтр "сейчас", где момент времени берется из глобальной переменной (локальной для треда). См. модуль retrospection, функции now и set_now. Глобальное (для треда) время устанавливается при первом обращении и остается фиксированным до изменения с помощью set_now.
ConfigurationError
– исключение, вызываемое в случае некорректной конфигурации документа (например, невозможность определить главную таблицу)
document_get(cls, datetime, **kwargs)
– метод класса возвращает объект (часть документа) определяемую ключевыми параметрами и моментом времени. Если объектов нет или несколько - вызывает исключения так же как и get для QuerySet.
at(cls, datetime, **kwargs)
– возвращает QuerySet для части документа определяемый ключевыми значениями и заданным моментом времени (определяемым по главной таблице документа). Путь к главной таблице определяется с помощью вызова метода класса to_master задающего префикс доступа к главной таблице.
history(self, **kwargs)
– QuerySet для истории части документа в обратном хронологическом порядке.
Document(DocumentPart)

– абстрактный базовый класс для главной таблицы документа. Задает:

document_start – DateTimeField
– начальный момент действия данной версии документа (включительно).
document_end – DateTimeField
– конечный момент действия (исключительно).
document_id – IntegerField
– уникальный идентификатор данного документа. Если не задан - вычисляется автоматически.
ChangedAlready – исключение
– вызывается методом document_save если заданный id не является ключом последней версии документа.
document_save(self, document_start=None)

– записывает в базу данных новую версию объекта главной таблицы документа. В последней версии документа (если она есть) устанавливается document_end (равный document_start для новой версии). Если задан document_start, то он становится началом действия новой версии, в противном случае берется datetime.now().

Если id не None, то предполагается что он задает ключ последней версии документа (находящейся в базе). Если это не так (не последняя), вызывается исключение (позволяет обнаружить параллельное изменение документа несколькими пользователями). Если не задан (нулевой) идентификатор документа, после записи в базу вызывается new_document_id для данного объекта и производится повторная запись в базу уже с новым идентификатором. Все действия выполняются в одной транзакции.

save_now(self)
– аналогично document_save, но в момент заданный глобально (для треда).
new_document_id(self)
– выдает новый идентификатор для данного документа.
document_get_or_404(cls, datetime, **kwargs)
– аналог get_object_or_404 с указанием момента времени в качестве первого параметра.
at(cls, datetime, **kwargs)
– переопределяет метод базового класса для главной таблицы.
history(self, **kwargs)
– QuerySet для истории документа в обратном хронологическом порядке.
document_restore(self, document_start=None)

– восстановить документ в активное состояние из текущей старой версии.

В составных документах надо переопределять, так как порядок сохранения частей зависит от вида связи между частями. Ссылки на другие части можно оставить как есть, а части ссылающиеся на этот документ надо скопировать и перепривязать к новой версии.

restore_now(self)
– аналогично document_restore, но в момент заданный глобально (для треда).
bulk_documents_save(cls, documents, document_start=None)
– сохранить новые версии документов (массовая загрузка).
bulk_save_now(cls, documents)
– аналогично bulk_documents_save, но в момент заданный глобально (для треда).
bulk_documents_delete(cls, documents, delete_time=None)
– массовое удаление документов, возвращает количество удаленных.
bulk_delete_now(cls, documents)
– аналогично bulk_documents_delete, но в момент заданный глобально (для треда).
DocumentPartF(DocumentPart)

– абстрактный базовый класс для части документа с версиями, на которую ссылаются из главной или более близкой к главной части документа. Определяет:

to_master(cls)
– выдает префикс для пути к главной таблице документа. Делается попытка найти модель унаследованную от DocumentPart ссылающуюся на данную. Если это главная таблица - то задача решена, в противном случае вызывается to_master для найденной модели. Если модель не найдена или найдена не одна, то вызывается исключение ConfigurationError. В этом случае метод надо переопределить.
DocumentPartB(DocumentPart)

– абстрактный базовый класс для части документа с версиями, которая ссылается на главную или более близкую к главной часть документа. Определяет:

to_master(cls)
– выдает префикс для пути к главной таблице документа. Делается попытка найти модель унаследованную от DocumentPart, на которую ссылаются из данной. Если это главная таблица - то задача решена, в противном случае вызывается to_master для найденной модели. Если модель не найдена или найдена не одна, то вызывается исключение ConfigurationError. В этом случае метод надо переопределить.
load_related_document_fk(datetime, object_list, field)
– аналог select_related для полей, ссылающихся на другой объект через DocumentForeignKey. Загружает связанные через поле {field} объекты в поле {field}_cached (ПОКА НЕ РЕАЛИЗОВАНО).

documents.fields

Ссылки на другие документы первых двух типов реализуются с помощью обычного поля типа ForeignKey. Ссылка третьего типа представляет интерес при создании составного документа, который всегда показывает на последние версии других документов. Для ее реализации нужно использовать:

DocumentForeignKey(models.ForeignKey)

– первый аргумент должен указывать на модель унаследованную от Document. Остальные значения не обязательны (такие же как для ForeignKey). Для доступа к объекту, на который сделана ссылка используются те же методы что и для ForeignKey, за исключением чтения, при котором нужно указывать момент времени:

class Source(...):
  ...
  link = DocumentForeignKey(SomeDocument)
  ...

t = datetime.now()
s = Source.objects.get(pk=123)
some_document = s.link(t)

В НАСТОЯЩИЙ МОМЕНТ ФУНКЦИОНАЛЬНОСТЬ НЕ РЕАЛИЗОВАНА. ИСПОЛЬЗУЕТСЯ ПРОСТО ЦЕЛОЕ ЗНАЧЕНИЕ.

documents.retrospection

now(request=None)
– возвращает время установленное в запросе (если есть). Если нет, или запрос не указан, возвращает текущее время треда. Если время треда не задано – устанавливает. Последующие вызовы будут возвращать одно и то же время до тех пор пока не закончится обработка запроса или не будет установлено новое фиксированное время с помощью set_now.
set_now(dt=None)
– устанавливает новое фиксированное время, локальное для треда, в dt или в datetime.now() если параметр не задан.
current_time(dt=None)
– контекст менеджер – временно установить время в заданное значение.
@with_real_time
– декоратор – устанавливает реальное время для выполнения функции.

documents.admin

Для того, чтобы с документами можно было работать так же, как с обычными моделями, нужно использовать DocumentModelAdmin. При его использовании мы видим только последнии версии документов, и можем просмотреть историю изменений (вместе со значениями всех полей). При каждом сохранении создается новая версия документа. Поддерживаются поля DocumentForeignKey (в виджете мы видим только последние версии документов). Но следует иметь ввиду, что совсем забыть о том, что мы работает с версионными документами, не получится. Эта часть проверялась на ограниченном наборе связей и моделей.

Middleware для ретроспекции

Реализовано в documents.retrospection.RetrospectionMiddleware.

Параметр сейчас удобно хранить в данных сессии. В этом случае middleware может выполнить две функции:

  1. Установку значения now в данных сессии (если оно не задано пользователем).
  2. Блокировку post-запросов в случае вручную заданного ограничения по времени (Режим только для чтения).

Hе все post-запросы надо блокировать, например запросы отменяющие режим ретроспекции. Для того, чтобы middleware "пропустило" запрос, необходимо включить в него поле post_in_retrospection.

Для включения нужно поместить documents.retrospection.RetrospectionMiddleware в список MIDDLEWARE_CLASSES после SessionMiddleware.

Пример

В качестве примера добавим поддержку версий документов в приложение из учебника Django.

В примере рассматриваются два объекта: Poll и Choice. Poll – вопрос для голосования, Choice - варианты ответов. Счетчик количества ответов объединен с ответом:

class Poll(models.Model):
   question = models.CharField(max_length=200)
   pub_date = models.DateTimeField('date published')

   def __unicode__(self):
       return self.question

class Choice(models.Model):
   poll = models.ForeignKey(Poll)
   choice = models.CharField(max_length=200)
   votes = models.IntegerField()

   def __unicode__(self):
       return self.choice

Варианты ответа привязаны к вопросу. Внесем следующие изменения:

  1. Сделаем вопрос и ответ документами с версиями.
  2. ответ - универсальный документ - может встречаться в нескольких вопросах.
  3. Результат опроса хранится в документе результат.

Модель становится такой:

class Choice(Document):
    choice = models.CharField(max_length=200)

    def __unicode__(self):
       return self.choice

class Poll(Document):
    question = models.CharField(max_length=200)

    def __unicode__(self):
       return self.question

class PollChoices(DocumentPartB):
    poll = models.ForeignKey(Poll)
    choice = models.ForeignKey(Choice)

    @classmethod
    def to_master(cls):
       return 'poll'

class PollResults(Document):
    poll = models.ForeignKey(Poll)
    choice = models.ForeignKey(Choice)
    votes = models.IntegerField()

    @staticmethod
    def vote(poll_document_id, choice_document_id):
        n = datetime.now()
        p = Poll.document_get(n, document_id=poll_document_id)
        c = Choice.document_get(n, document_id=choice_document_id)
        try:
            v = PollResults.document_get(
                n, poll__document_id=poll_document_id,
                choice__document_id=choice_document_id)
            v.votes += 1
        except PollResults.DoesNotExist:
            v = PollResults(poll=p, choice=c, votes=1)
        v.document_save()
        return v.document_id
Document
базовый абстрактный класс для всех документов.
Choice
вариант ответа. Документ.
Poll
вопрос для голосования. Документ.
PollChoices
часть документа Poll – содержит текущий список вариантов ответов на вопрос.
PollResults
текущее количество ответов заданного вида на вопрос. Документ.

Пример работы

>>> from documents_test.docs.models import *

# Пока нет голосований
>>> Poll.objects.all()
[]

# Создаем новое
>>> p = Poll(question='Who is who?')

# Сохраняем.
>>> p.document_save()

# Теперь есть id, document_id, document_start и document_end.
>>> p.id
1
>>> p.document_id
1

# Access database columns via Python attributes.
>>> print p.question
Who is who?

>>> p.document_start # doctest: +ELLIPSIS
datetime.datetime(...)

# Give the Poll a couple of Choices.
>>> now = datetime.now()
>>> p = Poll.document_get(now, document_id=1)

# Display any choices from the related object set -- none so far.
>>> PollChoices.at(now)
[]

# Create three choices.
>>> c1 = Choice(choice='President'); c1.document_save(); c1
<Choice: President>
>>> c2 = Choice(choice='Agent'); c2.document_save(); c2
<Choice: Agent>
>>> c3 = Choice(choice='Gena Crocodile'); c3.document_save(); c3
<Choice: Gena Crocodile>

# document_id назначен автоматически:
>>> for c in (c1, c2, c3): print c.document_id
1
2
3

# Добавим их:
>>> p.document_save() # новая версия
>>> p.pollchoices_set.add(*[PollChoices(choice=c) for c in (c1, c2, c3)])

# Голосование (результат - идентификатор документа в котором сделана отметка):
>>> PollResults.vote(1, 1)
1
>>> PollResults.vote(1, 1)
1
>>> PollResults.vote(1, 1)
1
>>> PollResults.vote(1, 2)
4

# Запомним момент в промежутке (t):
>>> from time import sleep
>>> sleep(0.1)
>>> t = datetime.now()
>>> sleep(0.1)

>>> PollResults.vote(1, 2)
4
>>> PollResults.vote(1, 3)
6

# Результаты голосования:
>>> for r in PollResults.at(datetime.now(), poll__document_id=1):
...     print r.votes, r.choice.choice
3 President
2 Agent
1 Gena Crocodile

# Ретроспекция (состояние на момент t):
>>> for r in PollResults.at(t, poll__document_id=1):
...     print r.votes, r.choice.choice
3 President
1 Agent

Редактор вопросов и ответов

Учебник Django предлагает создавать опросник через административный интерфейс. В нашем случае такой подход не годится. Административный интерфейс Django ничего не знает про документы и версии, поэтому его надо использовать для управления пользователями и, при необходимости, для редактирования данных в обход системы управления версиями. Существуют стационарные данные в системе (например некоторые справочники), которые не могут или не должны изменяться с течением времени (за исключением исправления ошибок), для управления которыми административный интерфейс будет пригоден.

Примечание: есть также documents.admin, но он позволяет редактировать в админке лишь простые документы (состоящие из одной модели).