← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/invisible-artifacts-warning2-1026126 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/invisible-artifacts-warning2-1026126 into lp:launchpad.

Requested reviews:
  Curtis Hovey (sinzui)
Related bugs:
  Bug #1026126 in Launchpad itself: "warn maintainers when no-one has access to certain artifacts"
  https://bugs.launchpad.net/launchpad/+bug/1026126

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/invisible-artifacts-warning2-1026126/+merge/115652

== Implementation ==

On the sharing page, the information types for which there are no access policy grants are displayed.

As a drive by, I also fixed some remaining 'Embargoed Security' text I found.

A new json request cache item 'invisible_information_types' is used to hold the titles of the information types for which there is no access policy grant. This is populated when the view is rendered and also when XHR calls are made to update or delete pillar grantees.

The SQL used to perform the query to find the 'invisible' information types is:

select
  type,
  (select count(*) from accesspolicygrant where
      accesspolicygrant.policy=accesspolicy.id)
from accesspolicy
where accesspolicy.id in (1,2)

This is a rather simple query, but there is no Storm construct create a column expression using a sub-select. So I created a new Storm expression ColumnSelect which does the job.

== Demo and QA ==

Here's what the warning looks like. I tried to have the warning centered but it looked terrible in my view. Having everything aligned left looks better to me.

http://people.canonical.com/~ianb/sharing-warning.png

When QAing, add and delete pillar sharees against different policies to make the warning appear and go away.

== Tests ==

Update yui tests for pillarsharingview to test for the new return data.
Add tests to TestSharingService for the new sharing APIs.
Add new test to PillarSharingViewTestMixin

I also had to add 2 to a pillar sharing view query count test since the sharing view uses 2 extra queries to load the invisible information type information.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/canonical/launchpad/icing/css/components/sharing.css
  lib/lp/bugs/doc/bugnotification-sending.txt
  lib/lp/bugs/javascript/tests/test_filebug.js
  lib/lp/registry/browser/pillar.py
  lib/lp/registry/browser/tests/test_pillar_sharing.py
  lib/lp/registry/help/sharing.html
  lib/lp/registry/interfaces/accesspolicy.py
  lib/lp/registry/interfaces/sharingservice.py
  lib/lp/registry/javascript/sharing/pillarsharingview.js
  lib/lp/registry/javascript/sharing/tests/test_pillarsharingview.html
  lib/lp/registry/javascript/sharing/tests/test_pillarsharingview.js
  lib/lp/registry/services/sharingservice.py
  lib/lp/registry/services/tests/test_sharingservice.py
  lib/lp/registry/templates/pillar-sharing.pt
  lib/lp/services/database/stormexpr.py
-- 
https://code.launchpad.net/~wallyworld/launchpad/invisible-artifacts-warning2-1026126/+merge/115652
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.
=== modified file 'lib/canonical/launchpad/icing/css/components/sharing.css'
--- lib/canonical/launchpad/icing/css/components/sharing.css	2012-04-12 09:14:58 +0000
+++ lib/canonical/launchpad/icing/css/components/sharing.css	2012-07-19 07:57:21 +0000
@@ -26,3 +26,7 @@
 table.sharing tr.spacebefore > td {
     padding-top: 1em;
     }
+
+.warning.message {
+    margin: 0;
+    }

=== modified file 'lib/lp/bugs/doc/bugnotification-sending.txt'
--- lib/lp/bugs/doc/bugnotification-sending.txt	2012-07-17 14:13:55 +0000
+++ lib/lp/bugs/doc/bugnotification-sending.txt	2012-07-19 07:57:21 +0000
@@ -764,7 +764,7 @@
     mark@xxxxxxxxxxx ['yes'] ['no']  ['Private']
     test@xxxxxxxxxxxxx ['yes'] ['no']  ['Private']
 
-Now transition the bug to embargoed security:
+Now transition the bug to private security:
 
     >>> with lp_dbuser():
     ...     bug_three.transitionToInformationType(

=== modified file 'lib/lp/bugs/javascript/tests/test_filebug.js'
--- lib/lp/bugs/javascript/tests/test_filebug.js	2012-07-17 14:13:55 +0000
+++ lib/lp/bugs/javascript/tests/test_filebug.js	2012-07-19 07:57:21 +0000
@@ -19,7 +19,7 @@
                             description: 'Public bug'},
                         {name: 'Private Security',
                             value: 'PRIVATESECURITY',
-                            description: 'Embargoed security bug'},
+                            description: 'private security bug'},
                         {name: 'Private', value: 'USERDATA',
                             description: 'Private bug'}
                     ],

=== modified file 'lib/lp/registry/browser/pillar.py'
--- lib/lp/registry/browser/pillar.py	2012-07-02 06:42:52 +0000
+++ lib/lp/registry/browser/pillar.py	2012-07-19 07:57:21 +0000
@@ -366,6 +366,12 @@
         cache.objects['sharee_data'] = (
             self._getSharingService().jsonShareeData(batch_navigator.batch))
 
+        grant_counts = (
+            self._getSharingService().getAccessPolicyGrantCounts(self.context))
+        cache.objects['invisible_information_types'] = [
+            count_info[0].title for count_info in grant_counts
+            if count_info[1] == 0]
+
         def _getBatchInfo(batch):
             if batch is None:
                 return None

=== modified file 'lib/lp/registry/browser/tests/test_pillar_sharing.py'
--- lib/lp/registry/browser/tests/test_pillar_sharing.py	2012-07-12 07:16:25 +0000
+++ lib/lp/registry/browser/tests/test_pillar_sharing.py	2012-07-19 07:57:21 +0000
@@ -373,7 +373,7 @@
             view = create_view(self.pillar, name='+sharing')
             with StormStatementRecorder() as recorder:
                 view.initialize()
-            self.assertThat(recorder, HasQueryCount(LessThan(7)))
+            self.assertThat(recorder, HasQueryCount(LessThan(9)))
 
     def test_view_write_enabled_without_feature_flag(self):
         # Test that sharing_write_enabled is not set without the feature flag.
@@ -394,6 +394,19 @@
             cache = IJSONRequestCache(view.request)
             self.assertTrue(cache.objects.get('sharing_write_enabled'))
 
+    def test_view_invisible_information_types(self):
+        # Test the expected invisible information type  data is in the
+        # json request cache.
+        with FeatureFixture(WRITE_FLAG):
+            with person_logged_in(self.pillar.owner):
+                getUtility(IService, 'sharing').deletePillarSharee(
+                    self.pillar, self.pillar.owner, self.pillar.owner)
+            view = create_initialized_view(self.pillar, name='+sharing')
+            cache = IJSONRequestCache(view.request)
+            self.assertContentEqual(
+                ['Private Security', 'Private'],
+                cache.objects.get('invisible_information_types'))
+
 
 class TestProductSharingView(PillarSharingViewTestMixin,
                                  SharingBaseTestCase):

=== modified file 'lib/lp/registry/help/sharing.html'
--- lib/lp/registry/help/sharing.html	2012-07-17 06:34:59 +0000
+++ lib/lp/registry/help/sharing.html	2012-07-19 07:57:21 +0000
@@ -28,7 +28,7 @@
     <p>
         If you are a project maintainer, you may need to share a single bug or
         branch with a user or team to address an issue. You can share
-        proprietary, embargoed security, or user-data bugs and branches by
+        proprietary, private security, or private bugs and branches by
         subscribing users and teams to the bug or branch. You can then view
         the exceptional bugs and branches that you've shared.
     </p>
@@ -66,19 +66,19 @@
     <dl>
         <dt>All</dt>
         <dd>
-            All proprietary, embargoed security, or user-data project
+            All proprietary, private security, or private project
             information is shared.
         </dd>
 
         <dt>Some</dt>
         <dd>
-            Some proprietary, embargoed security, or user-data project
+            Some proprietary, private security, or private project
             information is shared via a bug or branch subscription.
         </dd>
 
         <dt>Nothing</dt>
         <dd>
-            No proprietary, embargoed security, or user-data project
+            No proprietary, private security, or private project
             information is shared.
         </dd>
     </dl>

=== modified file 'lib/lp/registry/interfaces/accesspolicy.py'
--- lib/lp/registry/interfaces/accesspolicy.py	2012-06-18 03:18:36 +0000
+++ lib/lp/registry/interfaces/accesspolicy.py	2012-07-19 07:57:21 +0000
@@ -40,7 +40,7 @@
 class IAccessArtifactGrant(Interface):
     """A grant for a person or team to access an artifact.
 
-    For example, the reporter of an embargoed security bug has a grant for
+    For example, the reporter of an private security bug has a grant for
     that bug.
     """
 
@@ -79,7 +79,7 @@
     """A grant for a person or team to access all of a policy's artifacts.
 
     For example, the Canonical security team has a grant for Ubuntu's
-    security policy so they can see embargoed security bugs.
+    security policy so they can see private security bugs.
     """
 
     grantee = Attribute("Grantee")

=== modified file 'lib/lp/registry/interfaces/sharingservice.py'
--- lib/lp/registry/interfaces/sharingservice.py	2012-07-17 21:58:06 +0000
+++ lib/lp/registry/interfaces/sharingservice.py	2012-07-19 07:57:21 +0000
@@ -52,6 +52,14 @@
             of that type, False otherwise
         """
 
+    def getAccessPolicyGrantCounts(pillar):
+        """Return the number of grantees who have policy grants of each type.
+
+        Returns a resultset of (InformationType, count) tuples, where count is
+        the number of grantees who have an access policy grant for the
+        information type.
+        """
+
     def getSharedArtifacts(pillar, person, user):
         """Return the artifacts shared between the pillar and person.
 

=== modified file 'lib/lp/registry/javascript/sharing/pillarsharingview.js'
--- lib/lp/registry/javascript/sharing/pillarsharingview.js	2012-07-07 14:00:30 +0000
+++ lib/lp/registry/javascript/sharing/pillarsharingview.js	2012-07-19 07:57:21 +0000
@@ -162,6 +162,38 @@
     syncUI: function() {
         var sharee_table = this.get('sharee_table');
         sharee_table.syncUI();
+        var invisible_info_types = LP.cache.invisible_information_types;
+        var exiting_warning = Y.one('#sharing-warning');
+        if (Y.Lang.isObject(exiting_warning)) {
+            exiting_warning.remove(true);
+        }
+        if (Y.Lang.isArray(invisible_info_types)
+            && invisible_info_types.length > 0) {
+            var warning_node = Y.Node.create(
+                this._make_invisible_artifacts_warning(invisible_info_types));
+            var sharing_header = Y.one('#sharing-header');
+            sharing_header.insert(warning_node, 'after');
+        }
+    },
+
+    _make_invisible_artifacts_warning: function(information_types) {
+        return Y.lp.mustache.to_html([
+        "<div id='sharing-warning' class='warning message' ",
+        "style='margin-top: 2em'>",
+        "<p>",
+        "These information types are not shared with anyone:</p>",
+        "<ul class='bulleted'>",
+        "    {{#information_types}}",
+        "        <li>{{.}}</li>",
+        "    {{/information_types}}",
+        "</ul>",
+        "<p>There should be at least one person or exclusive team granted ",
+        "access to all bugs and branches for each information type to ",
+        "ensure there can be no invisible bugs or branches.</p>",
+        "</div>"
+        ].join(''), {
+            information_types: information_types
+        });
     },
 
     // A common error handler for XHR operations.
@@ -266,7 +298,9 @@
                 start: Y.bind(
                     self._show_delete_spinner, namespace, delete_link),
                 end: Y.bind(self._hide_delete_spinner, namespace, delete_link),
-                success: function() {
+                success: function(invisible_information_types) {
+                    LP.cache.invisible_information_types =
+                        invisible_information_types;
                     self.remove_sharee_success(person_uri);
                 },
                 failure: error_handler.getFailureHandler()
@@ -353,7 +387,10 @@
             on: {
                 start: Y.bind(self._show_sharing_spinner, namespace),
                 end: Y.bind(self._hide_hiding_spinner, namespace),
-                success: function(sharee_entry) {
+                success: function(result_data) {
+                    LP.cache.invisible_information_types =
+                        result_data.invisible_information_types;
+                    var sharee_entry = result_data.sharee_entry;
                     if (!Y.Lang.isValue(sharee_entry)) {
                         self.remove_sharee_success(person_uri);
                     } else {

=== modified file 'lib/lp/registry/javascript/sharing/tests/test_pillarsharingview.html'
--- lib/lp/registry/javascript/sharing/tests/test_pillarsharingview.html	2012-03-21 05:31:39 +0000
+++ lib/lp/registry/javascript/sharing/tests/test_pillarsharingview.html	2012-07-19 07:57:21 +0000
@@ -77,6 +77,7 @@
       <script type="text/javascript" src="test_pillarsharingview.js"></script>
 
       <script id="test-fixture" type="text/x-template">
+          <div id="sharing-header"></div>
           <table id='sharee-table'></table>
           <a id='add-sharee-link' class='sprite add js-action' href="#">Share</a>
       </script>

=== modified file 'lib/lp/registry/javascript/sharing/tests/test_pillarsharingview.js'
--- lib/lp/registry/javascript/sharing/tests/test_pillarsharingview.js	2012-07-07 14:00:30 +0000
+++ lib/lp/registry/javascript/sharing/tests/test_pillarsharingview.js	2012-07-19 07:57:21 +0000
@@ -250,8 +250,10 @@
                 'ws.op=deletePillarSharee&pillar=~pillar' +
                     '&sharee=~fred',
                 mockio.last_request.config.data);
-            mockio.last_request.successJSON({});
+            mockio.last_request.successJSON(['Invisible']);
             Y.Assert.isTrue(remove_sharee_success_called);
+            Y.ArrayAssert.itemsAreEqual(
+                ['Invisible'], LP.cache.invisible_information_types);
         },
 
         // The removeSharee callback updates the model and syncs the UI.
@@ -368,9 +370,13 @@
                 expected_url, 'permissions', 'Policy 3,Nothing');
             Y.Assert.areEqual(expected_url, mockio.last_request.config.data);
             mockio.last_request.successJSON({
-                'name': 'joe',
-                'self_link': '~joe'});
+                sharee_entry: {
+                    'name': 'joe',
+                    'self_link': '~joe'},
+                invisible_information_types: ['Invisible']});
             Y.Assert.isTrue(save_sharing_selection_success_called);
+            Y.ArrayAssert.itemsAreEqual(
+                ['Invisible'], LP.cache.invisible_information_types);
         },
 
         // When a permission is updated, the expected XHR calls are made.
@@ -414,9 +420,13 @@
                 expected_url, 'permissions', 'Policy 1,Some');
             Y.Assert.areEqual(expected_url, mockio.last_request.config.data);
             mockio.last_request.successJSON({
-                'name': 'fred',
-                'self_link': '~fred'});
+                sharee_entry: {
+                    'name': 'fred',
+                    'self_link': '~fred'},
+                invisible_information_types: ['Invisible']});
             Y.Assert.isTrue(save_sharing_selection_success_called);
+            Y.ArrayAssert.itemsAreEqual(
+                ['Invisible'], LP.cache.invisible_information_types);
         },
 
         // The save_sharing_selection_success callback updates the model and
@@ -468,7 +478,9 @@
                 remove_sharee_success_called = true;
             };
             this.view.save_sharing_selection("~fred", ["P1,All"]);
-            mockio.last_request.successJSON(null);
+            mockio.last_request.successJSON({
+                invisible_information_types: [],
+                sharee_entry: null});
             Y.Assert.isTrue(remove_sharee_success_called);
         },
 
@@ -483,6 +495,23 @@
             };
             this.view.syncUI();
             Y.Assert.isTrue(table_syncUI_called);
+        },
+
+        // A warning is rendered when there are invisible access policies.
+        test_invisible_access_policy: function() {
+            window.LP.cache.invisible_information_types = ['Private'];
+            this.view = this._create_Widget();
+            this.view.render();
+            Y.Assert.isNotNull(Y.one('.warning.message ul.bulleted'));
+            Y.Assert.areEqual('Private',
+                Y.one('.warning.message ul.bulleted li').get('text'));
+        },
+
+        // There is no warning when there are no invisible access policies.
+        test_no_invisible_grantees: function() {
+            this.view = this._create_Widget();
+            this.view.render();
+            Y.Assert.isNull(Y.one('.warning.message ul.bulleted'));
         }
     }));
 

=== modified file 'lib/lp/registry/services/sharingservice.py'
--- lib/lp/registry/services/sharingservice.py	2012-07-17 21:58:06 +0000
+++ lib/lp/registry/services/sharingservice.py	2012-07-19 07:57:21 +0000
@@ -14,6 +14,7 @@
 from lazr.restful.utils import get_current_web_service_request
 from storm.expr import (
     And,
+    Count,
     In,
     Join,
     Or,
@@ -51,11 +52,13 @@
 from lp.registry.model.accesspolicy import (
     AccessArtifactGrant,
     AccessPolicyArtifact,
+    AccessPolicy,
     AccessPolicyGrant,
     )
 from lp.registry.model.person import Person
 from lp.registry.model.teammembership import TeamParticipation
 from lp.services.database.lpstorm import IStore
+from lp.services.database.stormexpr import ColumnSelect
 from lp.services.features import getFeatureFlag
 from lp.services.searchbuilder import any
 from lp.services.webapp.authorization import (
@@ -103,6 +106,19 @@
             TeamParticipation.personID == person.id)
         return not result.is_empty()
 
+    def getAccessPolicyGrantCounts(self, pillar):
+        """See `ISharingService`."""
+        policies = getUtility(IAccessPolicySource).findByPillar([pillar])
+        ids = [policy.id for policy in policies]
+        store = IStore(AccessPolicyGrant)
+        count_select = Select((Count(),), tables=(AccessPolicyGrant,),
+            where=AccessPolicyGrant.policy == AccessPolicy.id)
+        return store.find(
+            (AccessPolicy.type,
+            ColumnSelect(count_select)),
+            AccessPolicy.id.is_in(ids)
+        )
+
     def getSharedArtifacts(self, pillar, person, user):
         """See `ISharingService`."""
         policies = getUtility(IAccessPolicySource).findByPillar([pillar])
@@ -392,10 +408,18 @@
         ap_grant_flat = getUtility(IAccessPolicyGrantFlatSource)
         grant_permissions = list(ap_grant_flat.findGranteePermissionsByPolicy(
             all_pillar_policies, [sharee]))
-        if not grant_permissions:
-            return None
-        [sharee] = self.jsonShareeData(grant_permissions)
-        return sharee
+
+        grant_counts = list(self.getAccessPolicyGrantCounts(pillar))
+        invisible_types = [
+            count_info[0].title for count_info in grant_counts
+            if count_info[1] == 0]
+        sharee_entry = None
+        if grant_permissions:
+            [sharee_entry] = self.jsonShareeData(grant_permissions)
+        result = {
+            'sharee_entry': sharee_entry,
+            'invisible_information_types': invisible_types}
+        return result
 
     @available_with_permission('launchpad.Edit', 'pillar')
     def deletePillarSharee(self, pillar, sharee, user,
@@ -440,6 +464,12 @@
             user, artifacts=None, grantee=sharee, pillar=pillar,
             information_types=information_types)
 
+        grant_counts = list(self.getAccessPolicyGrantCounts(pillar))
+        invisible_types = [
+            count_info[0].title for count_info in grant_counts
+            if count_info[1] == 0]
+        return invisible_types
+
     @available_with_permission('launchpad.Edit', 'pillar')
     def revokeAccessGrants(self, pillar, sharee, user, branches=None,
                            bugs=None):

=== modified file 'lib/lp/registry/services/tests/test_sharingservice.py'
--- lib/lp/registry/services/tests/test_sharingservice.py	2012-07-17 21:58:06 +0000
+++ lib/lp/registry/services/tests/test_sharingservice.py	2012-07-19 07:57:21 +0000
@@ -484,7 +484,8 @@
         expected_sharee_data = self._makeShareeData(
             sharee, expected_permissions,
             [InformationType.PRIVATESECURITY, InformationType.USERDATA])
-        self.assertContentEqual(expected_sharee_data, sharee_data)
+        self.assertContentEqual(
+            expected_sharee_data, sharee_data['sharee_entry'])
         # Check that getPillarSharees returns what we expect.
         if pillar_type == 'product':
             expected_sharee_grants = [
@@ -547,7 +548,25 @@
         with FeatureFixture(WRITE_FLAG):
             sharee_data = self.service.sharePillarInformation(
                 pillar, sharee, self.factory.makePerson(), permissions)
-        self.assertIsNone(sharee_data)
+        self.assertIsNone(sharee_data['sharee_entry'])
+
+    def test_shareePillarInformationInvisibleInformationTypes(self):
+        # Sharing with a user returns data containing the resulting invisible
+        # information types.
+        product = self.factory.makeProduct()
+        grantee = self.factory.makePerson()
+        with FeatureFixture(WRITE_FLAG):
+            with admin_logged_in():
+                self.service.deletePillarSharee(
+                    product, product.owner, product.owner)
+                result_data = self.service.sharePillarInformation(
+                    product, grantee, product.owner,
+                    {InformationType.USERDATA: SharingPermission.ALL})
+        # The owner is granted access on product creation. So we need to allow
+        # for that in the check below.
+        self.assertContentEqual(
+            ['Private Security'],
+            result_data['invisible_information_types'])
 
     def _assert_sharePillarInformationUnauthorized(self, pillar):
         # sharePillarInformation raises an Unauthorized exception if the user
@@ -672,6 +691,16 @@
         login_person(owner)
         self._assert_deletePillarSharee(distro, [InformationType.USERDATA])
 
+    def test_deletePillarShareeInvisibleInformationTypes(self):
+        # Deleting a pillar sharee returns the resulting invisible info types.
+        product = self.factory.makeProduct()
+        with FeatureFixture(WRITE_FLAG):
+            with admin_logged_in():
+                invisible_information_types = self.service.deletePillarSharee(
+                    product, product.owner, product.owner)
+        self.assertContentEqual(
+            ['Private', 'Private Security'], invisible_information_types)
+
     def _assert_deletePillarShareeUnauthorized(self, pillar):
         # deletePillarSharee raises an Unauthorized exception if the user
         # is not permitted to do so.
@@ -1289,6 +1318,36 @@
                 self.factory.makeProduct(), InformationType.PUBLIC,
                 self.factory.makePerson()))
 
+    def test_getAccessPolicyGrantCounts(self):
+        # checkPillarAccess checks whether the user has full access to
+        # an information type.
+        product = self.factory.makeProduct()
+        grantee = self.factory.makePerson()
+        with FeatureFixture(WRITE_FLAG):
+            with admin_logged_in():
+                self.service.sharePillarInformation(
+                    product, grantee, product.owner,
+                    {InformationType.USERDATA: SharingPermission.ALL})
+        # The owner is granted access on product creation. So we need to allow
+        # for that in the check below.
+        self.assertContentEqual(
+            [(InformationType.PRIVATESECURITY, 1),
+             (InformationType.USERDATA, 2)],
+            self.service.getAccessPolicyGrantCounts(product))
+
+    def test_getAccessPolicyGrantCountsZero(self):
+        # checkPillarAccess checks whether the user has full access to
+        # an information type.
+        product = self.factory.makeProduct()
+        with FeatureFixture(WRITE_FLAG):
+            with admin_logged_in():
+                self.service.deletePillarSharee(
+                    product, product.owner, product.owner)
+        self.assertContentEqual(
+            [(InformationType.PRIVATESECURITY, 0),
+             (InformationType.USERDATA, 0)],
+            self.service.getAccessPolicyGrantCounts(product))
+
 
 class ApiTestMixin:
     """Common tests for launchpadlib and webservice."""

=== modified file 'lib/lp/registry/templates/pillar-sharing.pt'
--- lib/lp/registry/templates/pillar-sharing.pt	2012-04-11 02:31:10 +0000
+++ lib/lp/registry/templates/pillar-sharing.pt	2012-07-19 07:57:21 +0000
@@ -24,10 +24,12 @@
 </head>
 <body>
   <div metal:fill-slot="main">
-    <p>
-      Proprietary, embargoed security, or user-data information is
-      shared with these users and teams.
-    </p>
+    <div id="sharing-header">
+        <p>
+          Proprietary, private security, or private information is
+          shared with these users and teams.
+        </p>
+    </div>
     <ul class="horizontal">
       <li><a id='add-sharee-link' class='sprite add js-action' href="#">Share
         with someone</a></li>

=== modified file 'lib/lp/services/database/stormexpr.py'
--- lib/lp/services/database/stormexpr.py	2012-06-14 05:18:22 +0000
+++ lib/lp/services/database/stormexpr.py	2012-07-19 07:57:21 +0000
@@ -8,6 +8,7 @@
     'ArrayAgg',
     'ArrayContains',
     'ArrayIntersects',
+    'ColumnSelect',
     'Concatenate',
     'CountDistinct',
     'Greatest',
@@ -31,6 +32,23 @@
 from storm.info import get_obj_info
 
 
+class ColumnSelect(Expr):
+    # Wrap a select statement in braces so that it can be used as a column
+    # expression in another query.
+    __slots__ = ("select")
+
+    def __init__(self, select):
+        self.select = select
+
+
+@compile.when(ColumnSelect)
+def compile_columnselect(compile, expr, state):
+    state.push("context", EXPR)
+    select = compile(expr.select)
+    state.pop()
+    return "(%s)" % select
+
+
 class Greatest(NamedFunc):
     # XXX wallyworld 2011-01-31 bug=710466:
     # We need to use a Postgres greatest() function call but Storm


Follow ups