]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- Fixed bug which caused "row switch" logic, that is an
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 16 Mar 2010 16:58:13 +0000 (12:58 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 16 Mar 2010 16:58:13 +0000 (12:58 -0400)
INSERT and DELETE replaced by an UPDATE, to fail when
version_id_col was in use. [ticket:1692]

- Added "version_id_generator" argument to Mapper, this is a
callable that, given the current value of the "version_id_col",
returns the next version number.  Can be used for alternate
versioning schemes such as uuid, timestamps.  [ticket:1692]

.hgignore
CHANGES
lib/sqlalchemy/orm/__init__.py
lib/sqlalchemy/orm/mapper.py
test/orm/test_relationships.py
test/orm/test_unitofwork.py
test/orm/test_versioning.py [new file with mode: 0644]

index d8aed38730fb4e974c8a3517985600442decb398..6ecede1f3701488fb17c25a9a08e99f767d1aac8 100755 (executable)
--- a/.hgignore
+++ b/.hgignore
@@ -2,3 +2,4 @@ syntax:regexp
 ^build/
 ^doc/build/output
 .pyc$
+.orig$
diff --git a/CHANGES b/CHANGES
index d2ed4672e11d5b83a1401ee2f20df79960c3f187..327ed33b2fac527a932bd1982ed078d5e0643ef8 100644 (file)
--- a/CHANGES
+++ b/CHANGES
@@ -126,7 +126,16 @@ CHANGES
   - query.scalar() now raises an exception if more than one 
     row is returned.  All other behavior remains the same.
     [ticket:1735]
+
+  - Fixed bug which caused "row switch" logic, that is an 
+    INSERT and DELETE replaced by an UPDATE, to fail when 
+    version_id_col was in use. [ticket:1692]
     
+  - Added "version_id_generator" argument to Mapper, this is a
+    callable that, given the current value of the "version_id_col",
+    returns the next version number.  Can be used for alternate
+    versioning schemes such as uuid, timestamps.  [ticket:1692]
+
 - sql
   - The most common result processors conversion function were
     moved to the new "processors" module.  Dialect authors are
index 37baa03befe0eba59c57fc245ad912ffe3c4ec8e..6c19a12e71308fd1242c78e84ef1cd5c8cd528f0 100644 (file)
@@ -610,178 +610,169 @@ def deferred(*columns, **kwargs):
 def mapper(class_, local_table=None, *args, **params):
     """Return a new :class:`~sqlalchemy.orm.Mapper` object.
 
-      class\_
-        The class to be mapped.
-
-      local_table
-        The table to which the class is mapped, or None if this mapper
-        inherits from another mapper using concrete table inheritance.
-
-      always_refresh
-        If True, all query operations for this mapped class will overwrite all
-        data within object instances that already exist within the session,
-        erasing any in-memory changes with whatever information was loaded
-        from the database.  Usage of this flag is highly discouraged; as an
-        alternative, see the method `populate_existing()` on
-        :class:`~sqlalchemy.orm.query.Query`.
-
-      allow_null_pks
-        This flag is deprecated - this is stated as allow_partial_pks
-        which defaults to True.
-    
-      allow_partial_pks
-        Defaults to True.  Indicates that a composite primary key with
-        some NULL values should be considered as possibly existing
-        within the database.   This affects whether a mapper will assign
-        an incoming row to an existing identity, as well as if 
-        session.merge() will check the database first for a particular
-        primary key value.  A "partial primary key" can occur if one 
-        has mapped to an OUTER JOIN, for example.
-
-      batch
-        Indicates that save operations of multiple entities can be batched
-        together for efficiency.  setting to False indicates that an instance
-        will be fully saved before saving the next instance, which includes
-        inserting/updating all table rows corresponding to the entity as well
-        as calling all ``MapperExtension`` methods corresponding to the save
-        operation.
-
-      column_prefix
-        A string which will be prepended to the `key` name of all Columns when
-        creating column-based properties from the given Table.  Does not
-        affect explicitly specified column-based properties
-
-      concrete
-        If True, indicates this mapper should use concrete table inheritance
-        with its parent mapper.
-
-      extension
-        A :class:`~sqlalchemy.orm.MapperExtension` instance or list of
-        ``MapperExtension`` instances which will be applied to all
-        operations by this ``Mapper``.
-
-      inherits
-        Another ``Mapper`` for which this ``Mapper`` will have an inheritance
-        relationship with.
-
-      inherit_condition
-        For joined table inheritance, a SQL expression (constructed
-        ``ClauseElement``) which will define how the two tables are joined;
-        defaults to a natural join between the two tables.
-
-      inherit_foreign_keys
-        when inherit_condition is used and the condition contains no
-        ForeignKey columns, specify the "foreign" columns of the join
-        condition in this list.  else leave as None.
-
-      order_by
-        A single ``Column`` or list of ``Columns`` for which
-        selection operations should use as the default ordering for entities.
-        Defaults to the OID/ROWID of the table if any, or the first primary
-        key column of the table.
-
-      non_primary
-        Construct a ``Mapper`` that will define only the selection of
-        instances, not their persistence.  Any number of non_primary mappers
-        may be created for a particular class.
-
-      passive_updates
-        Indicates UPDATE behavior of foreign keys when a primary key changes 
-        on a joined-table inheritance or other joined table mapping.
-
-        When True, it is assumed that ON UPDATE CASCADE is configured on
-        the foreign key in the database, and that the database will
-        handle propagation of an UPDATE from a source column to
-        dependent rows.  Note that with databases which enforce
-        referential integrity (i.e. PostgreSQL, MySQL with InnoDB tables),
-        ON UPDATE CASCADE is required for this operation.  The
-        relation() will update the value of the attribute on related
-        items which are locally present in the session during a flush.
-
-        When False, it is assumed that the database does not enforce
-        referential integrity and will not be issuing its own CASCADE
-        operation for an update.  The relation() will issue the
-        appropriate UPDATE statements to the database in response to the
-        change of a referenced key, and items locally present in the
-        session during a flush will also be refreshed.
-
-        This flag should probably be set to False if primary key changes
-        are expected and the database in use doesn't support CASCADE
-        (i.e. SQLite, MySQL MyISAM tables).
-        
-        Also see the passive_updates flag on ``relation()``.
-
-        A future SQLAlchemy release will provide a "detect" feature for
-        this flag.
-
-      polymorphic_on
-        Used with mappers in an inheritance relationship, a ``Column`` which
-        will identify the class/mapper combination to be used with a
-        particular row.  Requires the ``polymorphic_identity`` value to be set
-        for all mappers in the inheritance hierarchy.  The column specified by
-        ``polymorphic_on`` is usually a column that resides directly within
-        the base mapper's mapped table; alternatively, it may be a column that
-        is only present within the <selectable> portion of the
-        ``with_polymorphic`` argument.
-
-      _polymorphic_map
-        Used internally to propagate the full map of polymorphic identifiers
-        to surrogate mappers.
-
-      polymorphic_identity
-        A value which will be stored in the Column denoted by polymorphic_on,
-        corresponding to the *class identity* of this mapper.
-
-      properties
-        A dictionary mapping the string names of object attributes to
-        ``MapperProperty`` instances, which define the persistence behavior of
-        that attribute.  Note that the columns in the mapped table are
-        automatically converted into ``ColumnProperty`` instances based on the
-        `key` property of each ``Column`` (although they can be overridden
-        using this dictionary).
-
-      include_properties
-        An inclusive list of properties to map.  Columns present in the mapped
-        table but not present in this list will not be automatically converted
-        into properties.
-
-      exclude_properties
-        A list of properties not to map.  Columns present in the mapped table
-        and present in this list will not be automatically converted into
-        properties.  Note that neither this option nor include_properties will
-        allow an end-run around Python inheritance.  If mapped class ``B``
-        inherits from mapped class ``A``, no combination of includes or
-        excludes will allow ``B`` to have fewer properties than its
-        superclass, ``A``.
-
-      primary_key
-        A list of ``Column`` objects which define the *primary key* to be used
-        against this mapper's selectable unit.  This is normally simply the
-        primary key of the `local_table`, but can be overridden here.
-
-      with_polymorphic
-        A tuple in the form ``(<classes>, <selectable>)`` indicating the
-        default style of "polymorphic" loading, that is, which tables are
-        queried at once. <classes> is any single or list of mappers and/or
-        classes indicating the inherited classes that should be loaded at
-        once. The special value ``'*'`` may be used to indicate all descending
-        classes should be loaded immediately. The second tuple argument
-        <selectable> indicates a selectable that will be used to query for
-        multiple classes. Normally, it is left as None, in which case this
-        mapper will form an outer join from the base mapper's table to that of
-        all desired sub-mappers.  When specified, it provides the selectable
-        to be used for polymorphic loading. When with_polymorphic includes
-        mappers which load from a "concrete" inheriting table, the
-        <selectable> argument is required, since it usually requires more
-        complex UNION queries.
-
-      version_id_col
-        A ``Column`` which must have an integer type that will be used to keep
-        a running *version id* of mapped entities in the database.  this is
-        used during save operations to ensure that no other thread or process
-        has updated the instance during the lifetime of the entity, else a
-        ``ConcurrentModificationError`` exception is thrown.
-
+        :param class\_: The class to be mapped.
+
+        :param local_table: The table to which the class is mapped, or None if this mapper
+            inherits from another mapper using concrete table inheritance.
+
+        :param always_refresh: If True, all query operations for this mapped class will overwrite all
+            data within object instances that already exist within the session,
+            erasing any in-memory changes with whatever information was loaded
+            from the database.  Usage of this flag is highly discouraged; as an
+            alternative, see the method `populate_existing()` on
+            :class:`~sqlalchemy.orm.query.Query`.
+
+        :param allow_null_pks: This flag is deprecated - this is stated as allow_partial_pks
+            which defaults to True.
+
+        :param allow_partial_pks: Defaults to True.  Indicates that a composite primary key with
+            some NULL values should be considered as possibly existing
+            within the database.   This affects whether a mapper will assign
+            an incoming row to an existing identity, as well as if 
+            session.merge() will check the database first for a particular
+            primary key value.  A "partial primary key" can occur if one 
+            has mapped to an OUTER JOIN, for example.
+
+        :param batch: Indicates that save operations of multiple entities can be batched
+            together for efficiency.  setting to False indicates that an instance
+            will be fully saved before saving the next instance, which includes
+            inserting/updating all table rows corresponding to the entity as well
+            as calling all ``MapperExtension`` methods corresponding to the save
+            operation.
+
+        :param column_prefix: A string which will be prepended to the `key` name of all Columns when
+            creating column-based properties from the given Table.  Does not
+            affect explicitly specified column-based properties
+
+        :param concrete: If True, indicates this mapper should use concrete table inheritance
+            with its parent mapper.
+
+        :param exclude_properties: A list of properties not to map.  Columns present in the mapped table
+            and present in this list will not be automatically converted into
+            properties.  Note that neither this option nor include_properties will
+            allow an end-run around Python inheritance.  If mapped class ``B``
+            inherits from mapped class ``A``, no combination of includes or
+            excludes will allow ``B`` to have fewer properties than its
+            superclass, ``A``.
+
+
+        :param extension: A :class:`~sqlalchemy.orm.interfaces.MapperExtension` instance or list of
+            :class:`~sqlalchemy.orm.interfaces.MapperExtension` instances which will be applied to all
+            operations by this :class:`~sqlalchemy.orm.mapper.Mapper`.
+
+        :param include_properties: An inclusive list of properties to map.  Columns present in the mapped
+            table but not present in this list will not be automatically converted
+            into properties.
+
+        :param inherits: Another :class:`~sqlalchemy.orm.Mapper` for which 
+            this :class:`~sqlalchemy.orm.Mapper` will have an inheritance
+            relationship with.
+
+
+        :param inherit_condition: For joined table inheritance, a SQL expression (constructed
+            ``ClauseElement``) which will define how the two tables are joined;
+            defaults to a natural join between the two tables.
+
+        :param inherit_foreign_keys: When inherit_condition is used and the condition contains no
+            ForeignKey columns, specify the "foreign" columns of the join
+            condition in this list.  else leave as None.
+
+        :param non_primary: Construct a ``Mapper`` that will define only the selection of
+            instances, not their persistence.  Any number of non_primary mappers
+            may be created for a particular class.
+
+        :param order_by: A single ``Column`` or list of ``Columns`` for which
+            selection operations should use as the default ordering for entities.
+            Defaults to the OID/ROWID of the table if any, or the first primary
+            key column of the table.
+
+        :param passive_updates: Indicates UPDATE behavior of foreign keys when a primary key changes 
+            on a joined-table inheritance or other joined table mapping.
+
+            When True, it is assumed that ON UPDATE CASCADE is configured on
+            the foreign key in the database, and that the database will
+            handle propagation of an UPDATE from a source column to
+            dependent rows.  Note that with databases which enforce
+            referential integrity (i.e. PostgreSQL, MySQL with InnoDB tables),
+            ON UPDATE CASCADE is required for this operation.  The
+            relation() will update the value of the attribute on related
+            items which are locally present in the session during a flush.
+
+            When False, it is assumed that the database does not enforce
+            referential integrity and will not be issuing its own CASCADE
+            operation for an update.  The relation() will issue the
+            appropriate UPDATE statements to the database in response to the
+            change of a referenced key, and items locally present in the
+            session during a flush will also be refreshed.
+
+            This flag should probably be set to False if primary key changes
+            are expected and the database in use doesn't support CASCADE
+            (i.e. SQLite, MySQL MyISAM tables).
+
+            Also see the passive_updates flag on :func:`relation()`.
+
+            A future SQLAlchemy release will provide a "detect" feature for
+            this flag.
+
+        :param polymorphic_on: Used with mappers in an inheritance relationship, a ``Column`` which
+            will identify the class/mapper combination to be used with a
+            particular row.  Requires the ``polymorphic_identity`` value to be set
+            for all mappers in the inheritance hierarchy.  The column specified by
+            ``polymorphic_on`` is usually a column that resides directly within
+            the base mapper's mapped table; alternatively, it may be a column that
+            is only present within the <selectable> portion of the
+            ``with_polymorphic`` argument.
+
+        :param polymorphic_identity: A value which will be stored in the Column denoted by polymorphic_on,
+            corresponding to the *class identity* of this mapper.
+
+        :param properties: A dictionary mapping the string names of object attributes to
+            ``MapperProperty`` instances, which define the persistence behavior of
+            that attribute.  Note that the columns in the mapped table are
+            automatically converted into ``ColumnProperty`` instances based on the
+            `key` property of each ``Column`` (although they can be overridden
+            using this dictionary).
+
+        :param primary_key: A list of ``Column`` objects which define the *primary key* to be used
+            against this mapper's selectable unit.  This is normally simply the
+            primary key of the `local_table`, but can be overridden here.
+
+        :param version_id_col: A ``Column`` which must have an integer type that will be used to keep
+            a running *version id* of mapped entities in the database.  this is
+            used during save operations to ensure that no other thread or process
+            has updated the instance during the lifetime of the entity, else a
+            ``ConcurrentModificationError`` exception is thrown.
+
+        :param version_id_generator: A callable which defines the algorithm used to generate new version 
+            ids.  Defaults to an integer generator.  Can be replaced with one that
+            generates timestamps, uuids, etc.  e.g.::
+
+                import uuid
+
+                mapper(Cls, table, 
+                version_id_col=table.c.version_uuid,
+                version_id_generator=lambda version:uuid.uuid4().hex
+                )
+
+            The callable receives the current version identifier as its 
+            single argument.
+
+        :param with_polymorphic: A tuple in the form ``(<classes>, <selectable>)`` indicating the
+            default style of "polymorphic" loading, that is, which tables are
+            queried at once. <classes> is any single or list of mappers and/or
+            classes indicating the inherited classes that should be loaded at
+            once. The special value ``'*'`` may be used to indicate all descending
+            classes should be loaded immediately. The second tuple argument
+            <selectable> indicates a selectable that will be used to query for
+            multiple classes. Normally, it is left as None, in which case this
+            mapper will form an outer join from the base mapper's table to that of
+            all desired sub-mappers.  When specified, it provides the selectable
+            to be used for polymorphic loading. When with_polymorphic includes
+            mappers which load from a "concrete" inheriting table, the
+            <selectable> argument is required, since it usually requires more
+            complex UNION queries.
+
+          
     """
     return Mapper(class_, local_table, *args, **params)
 
index d868892958cf4875101cef2d5ab249c7e65c0a6f..30b6dd070aa44024567f48ad93798ef181fdb0e2 100644 (file)
@@ -84,6 +84,7 @@ class Mapper(object):
                  order_by = False,
                  always_refresh = False,
                  version_id_col = None,
+                 version_id_generator = None,
                  polymorphic_on=None,
                  _polymorphic_map=None,
                  polymorphic_identity=None,
@@ -118,6 +119,7 @@ class Mapper(object):
         
         self.always_refresh = always_refresh
         self.version_id_col = version_id_col
+        self.version_id_generator = version_id_generator or (lambda x:(x or 0) + 1)
         self.concrete = concrete
         self.single = False
         self.inherits = inherits
@@ -252,6 +254,7 @@ class Mapper(object):
 
             if self.version_id_col is None:
                 self.version_id_col = self.inherits.version_id_col
+                self.version_id_generator = self.inherits.version_id_generator
 
             for mapper in self.iterate_to_root():
                 util.reset_memoized(mapper, '_equivalent_columns')
@@ -1303,7 +1306,7 @@ class Mapper(object):
                     if 'before_update' in mapper.extension:
                         mapper.extension.before_update(mapper, connection, state.obj())
 
-        row_switches = set()
+        row_switches = {}
         if not postupdate:
             for state, mapper, connection, has_identity, instance_key in tups:
                 # detect if we have a "pending" instance (i.e. has no instance_key attached to it),
@@ -1324,7 +1327,7 @@ class Mapper(object):
                             
                     # remove the "delete" flag from the existing element
                     uowtransaction.set_row_switch(existing)
-                    row_switches.add(state)
+                    row_switches[state] = existing
         
         table_to_mapper = self._sorted_tables
 
@@ -1347,7 +1350,7 @@ class Mapper(object):
                 if isinsert:
                     for col in mapper._cols_by_table[table]:
                         if col is mapper.version_id_col:
-                            params[col.key] = 1
+                            params[col.key] = mapper.version_id_generator(None)
                         elif mapper.polymorphic_on is not None and \
                                 mapper.polymorphic_on.shares_lineage(col):
                             value = mapper.polymorphic_identity
@@ -1372,8 +1375,8 @@ class Mapper(object):
                 else:
                     for col in mapper._cols_by_table[table]:
                         if col is mapper.version_id_col:
-                            params[col._label] = mapper._get_state_attr_by_column(state, col)
-                            params[col.key] = params[col._label] + 1
+                            params[col._label] = mapper._get_state_attr_by_column(row_switches.get(state, state), col)
+                            params[col.key] = mapper.version_id_generator(params[col._label])
                             for prop in mapper._columntoproperty.itervalues():
                                 history = attributes.get_state_history(state, prop.key, passive=True)
                                 if history.added:
index 5223c692cc132bb8d69389d9a9249459f4bcdb63..1d2869291d1204f837c0eecd0f5884a8f9a498d4 100644 (file)
@@ -596,12 +596,12 @@ class RelationToUniqueTest(_base.MappedTest):
     def define_tables(cls, metadata):
         Table("table_a", metadata,
                         Column("id", Integer, primary_key=True, test_needs_autoincrement=True),
-                        Column("ident", String, nullable=False, unique=True),
+                        Column("ident", String(10), nullable=False, unique=True),
                         )
 
         Table("table_b", metadata,
                         Column("id", Integer, primary_key=True, test_needs_autoincrement=True),
-                        Column("a_ident", String, ForeignKey('table_a.ident'), nullable=False),
+                        Column("a_ident", String(10), ForeignKey('table_a.ident'), nullable=False),
                         )
     
     @classmethod
index b961e2d25a89c514bb1b6d1a674c4ab82df10179..e1150a6205608b2c8e4539e926e087b9d37c959d 100644 (file)
@@ -52,141 +52,6 @@ class HistoryTest(_fixtures.FixtureTest):
         assert u.addresses[0].user == u
         session.close()
 
-
-class VersioningTest(_base.MappedTest):
-    @classmethod
-    def define_tables(cls, metadata):
-        Table('version_table', metadata,
-              Column('id', Integer, primary_key=True,
-                     test_needs_autoincrement=True),
-              Column('version_id', Integer, nullable=False),
-              Column('value', String(40), nullable=False))
-
-    @classmethod
-    def setup_classes(cls):
-        class Foo(_base.ComparableEntity):
-            pass
-
-    @engines.close_open_connections
-    @testing.resolve_artifact_names
-    def test_notsane_warning(self):
-        # clear the warning module's ignores to force the SAWarning this
-        # test relies on to be emitted (it may have already been ignored
-        # forever by other VersioningTests)
-        try:
-            del __warningregistry__
-        except NameError:
-            pass
-
-        save = testing.db.dialect.supports_sane_rowcount
-        testing.db.dialect.supports_sane_rowcount = False
-        try:
-            mapper(Foo, version_table, 
-                    version_id_col=version_table.c.version_id)
-
-            s1 = create_session(autocommit=False)
-            f1 = Foo(value='f1')
-            f2 = Foo(value='f2')
-            s1.add_all((f1, f2))
-            s1.commit()
-
-            f1.value='f1rev2'
-            assert_raises(sa.exc.SAWarning, s1.commit)
-        finally:
-            testing.db.dialect.supports_sane_rowcount = save
-
-    @testing.emits_warning(r'.*does not support updated rowcount')
-    @engines.close_open_connections
-    @testing.resolve_artifact_names
-    def test_basic(self):
-        mapper(Foo, version_table, 
-                version_id_col=version_table.c.version_id)
-
-        s1 = create_session(autocommit=False)
-        f1 = Foo(value='f1')
-        f2 = Foo(value='f2')
-        s1.add_all((f1, f2))
-        s1.commit()
-
-        f1.value='f1rev2'
-        s1.commit()
-
-        s2 = create_session(autocommit=False)
-        f1_s = s2.query(Foo).get(f1.id)
-        f1_s.value='f1rev3'
-        s2.commit()
-
-        f1.value='f1rev3mine'
-
-        # Only dialects with a sane rowcount can detect the
-        # ConcurrentModificationError
-        if testing.db.dialect.supports_sane_rowcount:
-            assert_raises(sa.orm.exc.ConcurrentModificationError, s1.commit)
-            s1.rollback()
-        else:
-            s1.commit()
-
-        # new in 0.5 !  dont need to close the session
-        f1 = s1.query(Foo).get(f1.id)
-        f2 = s1.query(Foo).get(f2.id)
-
-        f1_s.value='f1rev4'
-        s2.commit()
-
-        s1.delete(f1)
-        s1.delete(f2)
-
-        if testing.db.dialect.supports_sane_multi_rowcount:
-            assert_raises(sa.orm.exc.ConcurrentModificationError, s1.commit)
-        else:
-            s1.commit()
-
-    @testing.emits_warning(r'.*does not support updated rowcount')
-    @engines.close_open_connections
-    @testing.resolve_artifact_names
-    def test_versioncheck(self):
-        """query.with_lockmode performs a 'version check' on an already loaded instance"""
-
-        s1 = create_session(autocommit=False)
-
-        mapper(Foo, version_table, version_id_col=version_table.c.version_id)
-        f1s1 = Foo(value='f1 value')
-        s1.add(f1s1)
-        s1.commit()
-
-        s2 = create_session(autocommit=False)
-        f1s2 = s2.query(Foo).get(f1s1.id)
-        f1s2.value='f1 new value'
-        s2.commit()
-
-        # load, version is wrong
-        assert_raises(sa.orm.exc.ConcurrentModificationError, s1.query(Foo).with_lockmode('read').get, f1s1.id)
-
-        # reload it
-        s1.query(Foo).populate_existing().get(f1s1.id)
-        # now assert version OK
-        s1.query(Foo).with_lockmode('read').get(f1s1.id)
-
-        # assert brand new load is OK too
-        s1.close()
-        s1.query(Foo).with_lockmode('read').get(f1s1.id)
-
-    @testing.emits_warning(r'.*does not support updated rowcount')
-    @engines.close_open_connections
-    @testing.resolve_artifact_names
-    def test_noversioncheck(self):
-        """test query.with_lockmode works when the mapper has no version id col"""
-        s1 = create_session(autocommit=False)
-        mapper(Foo, version_table)
-        f1s1 = Foo(value="foo", version_id=0)
-        s1.add(f1s1)
-        s1.commit()
-
-        s2 = create_session(autocommit=False)
-        f1s2 = s2.query(Foo).with_lockmode('read').get(f1s1.id)
-        assert f1s2.id == f1s1.id
-        assert f1s2.value == f1s1.value
-
 class UnicodeTest(_base.MappedTest):
     __requires__ = ('unicode_connections',)
 
diff --git a/test/orm/test_versioning.py b/test/orm/test_versioning.py
new file mode 100644 (file)
index 0000000..e97a3ad
--- /dev/null
@@ -0,0 +1,307 @@
+import sqlalchemy as sa
+from sqlalchemy.test import engines, testing
+from sqlalchemy import Integer, String, ForeignKey, literal_column, orm
+from sqlalchemy.test.schema import Table, Column
+from sqlalchemy.orm import mapper, relation, create_session, column_property, sessionmaker
+from sqlalchemy.test.testing import eq_, ne_, assert_raises, assert_raises_message
+from test.orm import _base, _fixtures
+from test.engine import _base as engine_base
+
+
+_uuids =['1fc614acbb904742a2990f86af6ded95', '23e253786f4d491b9f9d6189dc33de9b', 'fc44910db37e43fd93e9ec8165b885cf', 
+    '0187a1832b4249e6b48911821d86de58', '778af6ea2fb74a009d8d2f5abe5dc29a', '51a6ce031aff47e4b5f2895c4161f120',
+    '7434097cd319401fb9f15fa443ccbbbb', '9bc548a8128e4a85ac18060bc3f4b7d3', '59548715e3c440b7bcb96417d06f7930', 
+    'd7647c7004734de196885ca2bd73adf8', '70cef121d3ff48d39906b6d1ac77f41a', 'ee37a8a6430c466aa322b8a215a0dd70', 
+    '782a5f04b4364a53a6fce762f48921c1', 'bef510f2420f4476a7629013ead237f5']
+    
+def make_uuid():
+    """generate uuids even on Python 2.4 which has no 'uuid'"""
+    return _uuids.pop(0)
+
+class VersioningTest(_base.MappedTest):
+    @classmethod
+    def define_tables(cls, metadata):
+        Table('version_table', metadata,
+              Column('id', Integer, primary_key=True,
+                     test_needs_autoincrement=True),
+              Column('version_id', Integer, nullable=False),
+              Column('value', String(40), nullable=False))
+
+    @classmethod
+    def setup_classes(cls):
+        class Foo(_base.ComparableEntity):
+            pass
+
+    @engines.close_open_connections
+    @testing.resolve_artifact_names
+    def test_notsane_warning(self):
+        # clear the warning module's ignores to force the SAWarning this
+        # test relies on to be emitted (it may have already been ignored
+        # forever by other VersioningTests)
+        try:
+            del __warningregistry__
+        except NameError:
+            pass
+
+        save = testing.db.dialect.supports_sane_rowcount
+        testing.db.dialect.supports_sane_rowcount = False
+        try:
+            mapper(Foo, version_table, 
+                    version_id_col=version_table.c.version_id)
+
+            s1 = create_session(autocommit=False)
+            f1 = Foo(value='f1')
+            f2 = Foo(value='f2')
+            s1.add_all((f1, f2))
+            s1.commit()
+
+            f1.value='f1rev2'
+            assert_raises(sa.exc.SAWarning, s1.commit)
+        finally:
+            testing.db.dialect.supports_sane_rowcount = save
+
+    @testing.emits_warning(r'.*does not support updated rowcount')
+    @engines.close_open_connections
+    @testing.resolve_artifact_names
+    def test_basic(self):
+        mapper(Foo, version_table, 
+                version_id_col=version_table.c.version_id)
+
+        s1 = create_session(autocommit=False)
+        f1 = Foo(value='f1')
+        f2 = Foo(value='f2')
+        s1.add_all((f1, f2))
+        s1.commit()
+
+        f1.value='f1rev2'
+        s1.commit()
+
+        s2 = create_session(autocommit=False)
+        f1_s = s2.query(Foo).get(f1.id)
+        f1_s.value='f1rev3'
+        s2.commit()
+
+        f1.value='f1rev3mine'
+
+        # Only dialects with a sane rowcount can detect the
+        # ConcurrentModificationError
+        if testing.db.dialect.supports_sane_rowcount:
+            assert_raises(sa.orm.exc.ConcurrentModificationError, s1.commit)
+            s1.rollback()
+        else:
+            s1.commit()
+
+        # new in 0.5 !  dont need to close the session
+        f1 = s1.query(Foo).get(f1.id)
+        f2 = s1.query(Foo).get(f2.id)
+
+        f1_s.value='f1rev4'
+        s2.commit()
+
+        s1.delete(f1)
+        s1.delete(f2)
+
+        if testing.db.dialect.supports_sane_multi_rowcount:
+            assert_raises(sa.orm.exc.ConcurrentModificationError, s1.commit)
+        else:
+            s1.commit()
+
+    @testing.emits_warning(r'.*does not support updated rowcount')
+    @engines.close_open_connections
+    @testing.resolve_artifact_names
+    def test_versioncheck(self):
+        """query.with_lockmode performs a 'version check' on an already loaded instance"""
+
+        s1 = create_session(autocommit=False)
+
+        mapper(Foo, version_table, version_id_col=version_table.c.version_id)
+        f1s1 = Foo(value='f1 value')
+        s1.add(f1s1)
+        s1.commit()
+
+        s2 = create_session(autocommit=False)
+        f1s2 = s2.query(Foo).get(f1s1.id)
+        f1s2.value='f1 new value'
+        s2.commit()
+
+        # load, version is wrong
+        assert_raises(sa.orm.exc.ConcurrentModificationError, s1.query(Foo).with_lockmode('read').get, f1s1.id)
+
+        # reload it
+        s1.query(Foo).populate_existing().get(f1s1.id)
+        # now assert version OK
+        s1.query(Foo).with_lockmode('read').get(f1s1.id)
+
+        # assert brand new load is OK too
+        s1.close()
+        s1.query(Foo).with_lockmode('read').get(f1s1.id)
+
+    @testing.emits_warning(r'.*does not support updated rowcount')
+    @engines.close_open_connections
+    @testing.resolve_artifact_names
+    def test_noversioncheck(self):
+        """test query.with_lockmode works when the mapper has no version id col"""
+        s1 = create_session(autocommit=False)
+        mapper(Foo, version_table)
+        f1s1 = Foo(value="foo", version_id=0)
+        s1.add(f1s1)
+        s1.commit()
+
+        s2 = create_session(autocommit=False)
+        f1s2 = s2.query(Foo).with_lockmode('read').get(f1s1.id)
+        assert f1s2.id == f1s1.id
+        assert f1s2.value == f1s1.value
+
+
+
+class RowSwitchTest(_base.MappedTest):
+    @classmethod
+    def define_tables(cls, metadata):
+        Table('p', metadata,
+            Column('id', String(10), primary_key=True),
+            Column('version_id', Integer, default=1, nullable=False),
+            Column('data', String(50))
+        )
+        Table('c', metadata,
+            Column('id', String(10), ForeignKey('p.id'), primary_key=True),
+            Column('version_id', Integer, default=1, nullable=False),
+            Column('data', String(50))
+        )
+
+    @classmethod
+    def setup_classes(cls):
+        class P(_base.ComparableEntity):
+            pass
+        class C(_base.ComparableEntity):
+            pass
+
+    @classmethod
+    @testing.resolve_artifact_names
+    def setup_mappers(cls):
+        mapper(P, p, version_id_col=p.c.version_id, 
+            properties={
+            'c':relation(C, uselist=False, cascade='all, delete-orphan')
+        })
+        mapper(C, c, version_id_col=c.c.version_id)
+
+    @testing.resolve_artifact_names
+    def test_row_switch(self):
+        session = sessionmaker()()
+        session.add(P(id='P1', data='P version 1'))
+        session.commit()
+        session.close()
+        
+        p = session.query(P).first()
+        session.delete(p)
+        session.add(P(id='P1', data="really a row-switch"))
+        session.commit()
+    
+    @testing.resolve_artifact_names
+    def test_child_row_switch(self):
+        assert P.c.property.strategy.use_get
+        
+        session = sessionmaker()()
+        session.add(P(id=1, data='P version 1'))
+        session.commit()
+        session.close()
+
+        p = session.query(P).first()
+        p.c = C(data='child version 1')
+        session.commit()
+
+        p = session.query(P).first()
+        p.c = C(data='child row-switch')
+        session.commit()
+
+class AlternateGeneratorTest(_base.MappedTest):
+    @classmethod
+    def define_tables(cls, metadata):
+        Table('p', metadata,
+            Column('id', String(10), primary_key=True),
+            Column('version_id', String(32), nullable=False),
+            Column('data', String(50))
+        )
+        Table('c', metadata,
+            Column('id', String(10), ForeignKey('p.id'), primary_key=True),
+            Column('version_id', String(32), nullable=False),
+            Column('data', String(50))
+        )
+
+    @classmethod
+    def setup_classes(cls):
+        class P(_base.ComparableEntity):
+            pass
+        class C(_base.ComparableEntity):
+            pass
+
+    @classmethod
+    @testing.resolve_artifact_names
+    def setup_mappers(cls):
+        mapper(P, p, version_id_col=p.c.version_id, 
+            version_id_generator=lambda x:make_uuid(),
+            properties={
+            'c':relation(C, uselist=False, cascade='all, delete-orphan')
+        })
+        mapper(C, c, version_id_col=c.c.version_id,
+                    version_id_generator=lambda x:make_uuid(),
+        )
+
+    @testing.resolve_artifact_names
+    def test_row_switch(self):
+        session = sessionmaker()()
+        session.add(P(id='P1', data='P version 1'))
+        session.commit()
+        session.close()
+
+        p = session.query(P).first()
+        session.delete(p)
+        session.add(P(id='P1', data="really a row-switch"))
+        session.commit()
+
+    @testing.resolve_artifact_names
+    def test_child_row_switch_one(self):
+        assert P.c.property.strategy.use_get
+
+        session = sessionmaker()()
+        session.add(P(id=1, data='P version 1'))
+        session.commit()
+        session.close()
+
+        p = session.query(P).first()
+        p.c = C(data='child version 1')
+        session.commit()
+
+        p = session.query(P).first()
+        p.c = C(data='child row-switch')
+        session.commit()
+
+    @testing.resolve_artifact_names
+    def test_child_row_switch_two(self):
+        Session = sessionmaker()
+        
+        sess1 = Session()
+        sess1.add(P(id=1, data='P version 1'))
+        sess1.commit()
+        sess1.close()
+        
+        p1 = sess1.query(P).first()
+
+        sess2 = Session()
+        p2 = sess2.query(P).first()
+        
+        sess1.delete(p1)
+        sess1.commit()
+        
+        sess1.add(P(id='P1', data='P version 2'))
+        sess1.commit()
+        
+        p2.data = 'P overwritten by concurrent tx'
+        assert_raises(
+            orm.exc.ConcurrentModificationError,
+            sess2.commit
+        )
+        
+        
+        
+        
+        
\ No newline at end of file