Repository: fedora-infra/bodhi

Issue:
Split the Update model into two
We need to split the Update model into two models, a superclass (probably still called Update) and a new RPM-specific subclass, perhaps simply called RPMUpdate. All of Update's relationships should be analyzed to determine which ones should be adjusted to reference RPM vs. continuing to reference Update. Also, a database migration will be needed so that Bodhi's existing data can be preserved in the new split Update design. All code will need to be altered to use the new RPM model rather than the Update model when the data involved is RPM-specific.

Once this is done, all of Bodhi's functionality should continue to work exactly as it does today.


Discussion:
So I've taken a survey of the Update model (and written docs in #1445), and I think we can get away without having a special update type for each content type. An Update can know its type(s) based on the builds it's composed of, since builds have types.

There's a single catch I know of which is a problem we have to handle whether or not we have type-specific Update models. At the moment, bodhi has [a utility](https://github.com/fedora-infra/bodhi/blob/develop/bodhi/server/util.py#L199) that gets all the packages marked critical path, but this only applies to RPM (I think? The [pkgdb2-cli](https://pagure.io/pkgdb-cli/blob/master/f/pkgdb2client/__init__.py#_348) seems to hit [the critpath API](https://admin.fedoraproject.org/pkgdb/api/#critical_path_packages) and that makes no mention of namespaces) so that's probably something we need to address first).
Your conclusion sounds reasonable to me. Should we use this ticket to add code to enforce that an ```Update``` only has one type of ```Build``` associated with it through a validator (or any other way that makes sense)? If we did something like that, would we need to also add a validator to the other end of the relationship (i.e., the ```Build```) so that the other end can't link to an ```Update``` that has other types? I presume that sqlalchemy validators don't validate from both ends if only defined on one end, but I could be wrong.
As for the critical path packages, I think it would be reasonable for Bodhi to assume for now that only RpmPackages can be critpath. We can watch pkgdb's progress and when its API gains the ability to make other types be critical path we can revisit our code.

It would be good to file a Bodhi issue about this, so I'll do that now.
I filed #1448 to track the critical path problem you mentioned here.

Resolving patch:
diff --git a/bodhi/server/models.py b/bodhi/server/models.py
--- a/bodhi/server/models.py
+++ b/bodhi/server/models.py
@@ -33,7 +33,7 @@
 from sqlalchemy import (and_, Boolean, Column, DateTime, ForeignKey, Integer, or_, Table, Unicode,
                         UnicodeText)
 from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.orm import class_mapper, relationship, backref
+from sqlalchemy.orm import class_mapper, relationship, backref, validates
 from sqlalchemy.orm.exc import NoResultFound
 from sqlalchemy.orm.properties import RelationshipProperty
 from sqlalchemy.sql import text
@@ -961,6 +961,24 @@ class Update(Base):
 
     user_id = Column(Integer, ForeignKey('users.id'))
 
+    @validates('builds')
+    def validate_builds(self, key, build):
+        """
+        Validates builds being appended to ensure they are all the same type.
+
+        Args:
+            key (str): The field's key, which is un-used in this validator.
+            build (Build): The build object which was appended to the list
+                of builds.
+
+        Raises:
+            ValueError: If the build being appended is not the same type as the
+                existing builds.
+        """
+        if not all([type(b) is type(build) for b in self.builds]):
+            raise ValueError(u'An update must contain builds of the same type.')
+        return build
+
     @property
     def karma(self):
         """


Test patch:
diff --git a/bodhi/tests/server/test_models.py b/bodhi/tests/server/test_models.py
--- a/bodhi/tests/server/test_models.py
+++ b/bodhi/tests/server/test_models.py
@@ -209,6 +209,106 @@ def test_url(self):
         eq_(self.obj.get_url(), u'/TurboGears-1.0.8-3.fc11')
 
 
+class TestUpdateValidateBuilds(BaseTestCase):
+    """Tests for the :class:`Update` validator for builds."""
+
+    def setUp(self):
+        super(TestUpdateValidateBuilds, self).setUp()
+        self.package = model.Package(name='the-greatest-package')
+        self.update = model.Update(
+            title='The best update of all time.',
+            user=model.User.query.filter_by(name=u'guest').one(),
+            request=model.UpdateRequest.testing,
+            notes=u'Useless details!',
+            release=model.Release.query.filter_by(name=u'F17').one(),
+            date_submitted=datetime(1984, 11, 02),
+            requirements=u'rpmlint',
+            stable_karma=3,
+            unstable_karma=-3,
+            type=UpdateType.bugfix
+        )
+
+    def test_no_builds(self):
+        """Assert when the first build is appended, the validator passes."""
+        build = model.Build(
+            nvr='the-greatest-package-1.0.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        self.update.builds.append(build)
+
+    def test_same_build_types(self):
+        """Assert when all builds are the same type, validation passes."""
+        build1 = model.RpmBuild(
+            nvr='the-greatest-package-1.0.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        build2 = model.RpmBuild(
+            nvr='the-greatest-package-1.1.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        self.update.builds += [build1, build2]
+
+    def test_different_build_types(self):
+        """Assert when all builds are a different type, validation fails."""
+        build1 = model.Build(
+            nvr='the-greatest-package-1.0.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        build2 = model.RpmBuild(
+            nvr='the-greatest-package-1.1.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        self.update.builds.append(build1)
+        self.assertRaises(ValueError, self.update.builds.append, build2)
+
+    def test_backref_no_builds(self):
+        """Assert when the first build is appended via a backref, the validator passes."""
+        build = model.Build(
+            nvr='the-greatest-package-1.0.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        build.update = self.update
+
+    def test_backref_same_build_types(self):
+        """Assert when all builds are the same type and set via backref validation passes."""
+        build1 = model.RpmBuild(
+            nvr='the-greatest-package-1.0.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        build2 = model.RpmBuild(
+            nvr='the-greatest-package-1.1.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        build1.update = self.update
+        build2.update = self.update
+
+    def test_backref_different_build_types(self):
+        """Assert when builds differ in type and are set via backref validation passes."""
+        build1 = model.Build(
+            nvr='the-greatest-package-1.0.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        build2 = model.RpmBuild(
+            nvr='the-greatest-package-1.1.0-fc17.1',
+            package_id=self.package.id,
+            release_id=self.update.release.id,
+        )
+        build1.update = self.update
+        with self.assertRaises(ValueError) as cm:
+            build2.update = self.update
+
+        self.assertEqual(str(cm.exception), u'An update must contain builds of the same type.')
+
+
 class TestUpdate(ModelTest):
     """Unit test case for the ``Update`` model."""
     klass = model.Update