]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
forgot to add this, oopsie
authorMike Bayer <mike_mp@zzzcomputing.com>
Sat, 30 Nov 2013 15:34:09 +0000 (10:34 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 30 Nov 2013 15:34:09 +0000 (10:34 -0500)
examples/versioned_rows/__init__.py [new file with mode: 0644]
examples/versioned_rows/versioned_map.py [new file with mode: 0644]
examples/versioned_rows/versioned_rows.py [new file with mode: 0644]

diff --git a/examples/versioned_rows/__init__.py b/examples/versioned_rows/__init__.py
new file mode 100644 (file)
index 0000000..7a4e89c
--- /dev/null
@@ -0,0 +1,7 @@
+"""
+Illustrates an extension which versions data by storing new rows for each change;
+that is, what would normally be an UPDATE becomes an INSERT.
+
+.. autosource::
+
+"""
\ No newline at end of file
diff --git a/examples/versioned_rows/versioned_map.py b/examples/versioned_rows/versioned_map.py
new file mode 100644 (file)
index 0000000..774bfbe
--- /dev/null
@@ -0,0 +1,284 @@
+"""A variant of the versioned_rows example. Here
+we store a dictionary of key/value pairs, storing the k/v's in a
+"vertical" fashion where each key gets a row. The value is split out
+into two separate datatypes, string and int - the range of datatype
+storage can be adjusted for individual needs.
+
+Changes to the "data" attribute of a ConfigData object result in the
+ConfigData object being copied into a new one, and new associations to
+its data are created. Values which aren't changed between versions are
+referenced by both the former and the newer ConfigData object.
+Overall, only INSERT statements are emitted - no rows are UPDATed or
+DELETEd.
+
+An optional feature is also illustrated which associates individual
+key/value pairs with the ConfigData object in which it first
+originated. Since a new row is only persisted when a new value is
+created for a particular key, the recipe provides a way to query among
+the full series of changes which occurred for any particular key in
+the dictionary.
+
+The set of all ConfigData in a particular table represents a single
+series of versions. By adding additional columns to ConfigData, the
+system can be made to store multiple version streams distinguished by
+those additional values.
+
+"""
+
+from sqlalchemy import Column, String, Integer, ForeignKey, \
+    create_engine
+from sqlalchemy.orm.interfaces import SessionExtension
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import attributes, relationship, backref, \
+    sessionmaker, make_transient, validates
+from sqlalchemy.ext.associationproxy import association_proxy
+from sqlalchemy.orm.collections import attribute_mapped_collection
+
+class VersionExtension(SessionExtension):
+    """Apply the new_version() method of objects which are
+    marked as dirty during a flush.
+
+    See http://www.sqlalchemy.org/trac/wiki/UsageRecipes/VersionedRows
+
+    """
+    def before_flush(self, session, flush_context, instances):
+        for instance in session.dirty:
+            if hasattr(instance, 'new_version') and \
+                session.is_modified(instance, passive=True):
+
+                # make it transient
+                instance.new_version(session)
+
+                # re-add
+                session.add(instance)
+
+Base = declarative_base()
+
+class ConfigData(Base):
+    """Represent a series of key/value pairs.
+
+    ConfigData will generate a new version of itself
+    upon change.
+
+    The "data" dictionary provides access via
+    string name mapped to a string/int value.
+
+    """
+    __tablename__ = 'config'
+
+    id = Column(Integer, primary_key=True)
+    """Primary key column of this ConfigData."""
+
+    elements = relationship("ConfigValueAssociation",
+                    collection_class=attribute_mapped_collection("name"),
+                    backref=backref("config_data"),
+                    lazy="subquery"
+                )
+    """Dictionary-backed collection of ConfigValueAssociation objects,
+    keyed to the name of the associated ConfigValue.
+
+    Note there's no "cascade" here.  ConfigValueAssociation objects
+    are never deleted or changed.
+    """
+
+    def _new_value(name, value):
+        """Create a new entry for usage in the 'elements' dictionary."""
+        return ConfigValueAssociation(ConfigValue(name, value))
+
+    data = association_proxy("elements", "value", creator=_new_value)
+    """Proxy to the 'value' elements of each related ConfigValue,
+    via the 'elements' dictionary.
+    """
+
+    def __init__(self, data):
+        self.data = data
+
+    @validates('elements')
+    def _associate_with_element(self, key, element):
+        """Associate incoming ConfigValues with this
+        ConfigData, if not already associated.
+
+        This is an optional feature which allows
+        more comprehensive history tracking.
+
+        """
+        if element.config_value.originating_config is None:
+            element.config_value.originating_config = self
+        return element
+
+    def new_version(self, session):
+        # convert to an INSERT
+        make_transient(self)
+        self.id = None
+
+        # history of the 'elements' collecton.
+        # this is a tuple of groups: (added, unchanged, deleted)
+        hist = attributes.get_history(self, 'elements')
+
+        # rewrite the 'elements' collection
+        # from scratch, removing all history
+        attributes.set_committed_value(self, 'elements', {})
+
+        # new elements in the "added" group
+        # are moved to our new collection.
+        for elem in hist.added:
+            self.elements[elem.name] = elem
+
+        # copy elements in the 'unchanged' group.
+        # the new ones associate with the new ConfigData,
+        # the old ones stay associated with the old ConfigData
+        for elem in hist.unchanged:
+            self.elements[elem.name] = ConfigValueAssociation(elem.config_value)
+
+        # we also need to expire changes on each ConfigValueAssociation
+        # that is to remain associated with the old ConfigData.
+        # Here, each one takes care of that in its new_version()
+        # method, though we could do that here as well.
+
+
+class ConfigValueAssociation(Base):
+    """Relate ConfigData objects to associated ConfigValue objects."""
+
+    __tablename__ = 'config_value_association'
+
+    config_id = Column(ForeignKey('config.id'), primary_key=True)
+    """Reference the primary key of the ConfigData object."""
+
+
+    config_value_id = Column(ForeignKey('config_value.id'), primary_key=True)
+    """Reference the primary key of hte ConfigValue object."""
+
+    config_value = relationship("ConfigValue", lazy="joined", innerjoin=True)
+    """Reference the related ConfigValue object."""
+
+    def __init__(self, config_value):
+        self.config_value = config_value
+
+    def new_version(self, session):
+        """Expire all pending state, as ConfigValueAssociation is immutable."""
+
+        session.expire(self)
+
+    @property
+    def name(self):
+        return self.config_value.name
+
+    @property
+    def value(self):
+        return self.config_value.value
+
+    @value.setter
+    def value(self, value):
+        """Intercept set events.
+
+        Create a new ConfigValueAssociation upon change,
+        replacing this one in the parent ConfigData's dictionary.
+
+        If no net change, do nothing.
+
+        """
+        if value != self.config_value.value:
+            self.config_data.elements[self.name] = \
+                    ConfigValueAssociation(
+                        ConfigValue(self.config_value.name, value)
+                    )
+
+class ConfigValue(Base):
+    """Represent an individual key/value pair at a given point in time.
+
+    ConfigValue is immutable.
+
+    """
+    __tablename__ = 'config_value'
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String(50), nullable=False)
+    originating_config_id = Column(Integer, ForeignKey('config.id'),
+                            nullable=False)
+    int_value = Column(Integer)
+    string_value = Column(String(255))
+
+    def __init__(self, name, value):
+        self.name = name
+        self.value = value
+
+    originating_config = relationship("ConfigData")
+    """Reference to the originating ConfigData.
+
+    This is optional, and allows history tracking of
+    individual values.
+
+    """
+
+    def new_version(self, session):
+        raise NotImplementedError("ConfigValue is immutable.")
+
+    @property
+    def value(self):
+        for k in ('int_value', 'string_value'):
+            v = getattr(self, k)
+            if v is not None:
+                return v
+        else:
+            return None
+
+    @value.setter
+    def value(self, value):
+        if isinstance(value, int):
+            self.int_value = value
+            self.string_value = None
+        else:
+            self.string_value = str(value)
+            self.int_value = None
+
+if __name__ == '__main__':
+    engine = create_engine('sqlite://', echo=True)
+    Base.metadata.create_all(engine)
+    Session = sessionmaker(bind=engine, extension=VersionExtension())
+
+    sess = Session()
+
+    config = ConfigData({
+        'user_name':'twitter',
+        'hash_id':'4fedffca37eaf',
+        'x':27,
+        'y':450
+        })
+
+    sess.add(config)
+    sess.commit()
+    version_one = config.id
+
+    config.data['user_name'] = 'yahoo'
+    sess.commit()
+
+    version_two = config.id
+
+    assert version_one != version_two
+
+    # two versions have been created.
+
+    assert config.data == {
+        'user_name':'yahoo',
+        'hash_id':'4fedffca37eaf',
+        'x':27,
+        'y':450
+    }
+
+    old_config = sess.query(ConfigData).get(version_one)
+    assert old_config.data == {
+        'user_name':'twitter',
+        'hash_id':'4fedffca37eaf',
+        'x':27,
+        'y':450
+    }
+
+    # the history of any key can be acquired using
+    # the originating_config_id attribute
+    history = sess.query(ConfigValue).\
+            filter(ConfigValue.name=='user_name').\
+            order_by(ConfigValue.originating_config_id).\
+            all()
+
+    assert [(h.value, h.originating_config_id) for h in history] == \
+            [('twitter', version_one), ('yahoo', version_two)]
diff --git a/examples/versioned_rows/versioned_rows.py b/examples/versioned_rows/versioned_rows.py
new file mode 100644 (file)
index 0000000..30acf4e
--- /dev/null
@@ -0,0 +1,105 @@
+"""Illustrates a method to intercept changes on objects, turning
+an UPDATE statement on a single row into an INSERT statement, so that a new
+row is inserted with the new data, keeping the old row intact.
+
+"""
+from sqlalchemy.orm import *
+from sqlalchemy import *
+from sqlalchemy.orm.interfaces import SessionExtension
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import attributes
+
+class Versioned(object):
+    def new_version(self, session):
+        # if on SQLA 0.6.1 or earlier,
+        # make sure 'id' isn't expired.
+        # self.id
+
+        # make us transient (removes persistent
+        # identity).
+        make_transient(self)
+
+        # set 'id' to None.
+        # a new PK will be generated on INSERT.
+        self.id = None
+
+class VersionExtension(SessionExtension):
+    def before_flush(self, session, flush_context, instances):
+        for instance in session.dirty:
+            if not isinstance(instance, Versioned):
+                continue
+            if not session.is_modified(instance, passive=True):
+                continue
+
+            if not attributes.instance_state(instance).has_identity:
+                continue
+
+            # make it transient
+            instance.new_version(session)
+            # re-add
+            session.add(instance)
+
+Base = declarative_base()
+
+engine = create_engine('sqlite://', echo=True)
+
+Session = sessionmaker(engine, extension=[VersionExtension()])
+
+# example 1, simple versioning
+
+class Example(Versioned, Base):
+    __tablename__ = 'example'
+    id = Column(Integer, primary_key=True)
+    data = Column(String)
+
+Base.metadata.create_all(engine)
+
+session = Session()
+e1 = Example(data='e1')
+session.add(e1)
+session.commit()
+
+e1.data = 'e2'
+session.commit()
+
+assert session.query(Example.id, Example.data).order_by(Example.id).all() == \
+        [(1, 'e1'), (2, 'e2')]
+
+# example 2, versioning with a parent
+
+class Parent(Base):
+    __tablename__ = 'parent'
+    id = Column(Integer, primary_key=True)
+    child_id = Column(Integer, ForeignKey('child.id'))
+    child = relationship("Child", backref=backref('parent', uselist=False))
+
+class Child(Versioned, Base):
+    __tablename__ = 'child'
+
+    id = Column(Integer, primary_key=True)
+    data = Column(String)
+
+    def new_version(self, session):
+        # expire parent's reference to us
+        session.expire(self.parent, ['child'])
+
+        # create new version
+        Versioned.new_version(self, session)
+
+        # re-add ourselves to the parent
+        self.parent.child = self
+
+Base.metadata.create_all(engine)
+
+session = Session()
+
+p1 = Parent(child=Child(data='c1'))
+session.add(p1)
+session.commit()
+
+p1.child.data = 'c2'
+session.commit()
+
+assert p1.child_id == 2
+assert session.query(Child.id, Child.data).order_by(Child.id).all() == \
+    [(1, 'c1'), (2, 'c2')]
\ No newline at end of file