← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wgrant/launchpad/xref-model into lp:launchpad

 

William Grant has proposed merging lp:~wgrant/launchpad/xref-model into lp:launchpad with lp:~wgrant/launchpad/xref-db as a prerequisite.

Commit message:
Add lp.services.xref for generic cross-references between artifacts.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wgrant/launchpad/xref-model/+merge/272588

Add lp.services.xref for generic cross-references between artifacts.

Schema is at <https://code.launchpad.net/~wgrant/launchpad/xref-db/+merge/272587>.

It's likely that we'll end up with wrappers that automatically resolve known objects to/from their tuple representations, but for now the by-ID API is surprisingly unonerous.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/xref-model into lp:launchpad.
=== modified file 'lib/lp/services/configure.zcml'
--- lib/lp/services/configure.zcml	2015-05-22 07:12:35 +0000
+++ lib/lp/services/configure.zcml	2015-09-28 12:44:55 +0000
@@ -34,4 +34,5 @@
   <include package=".webhooks" />
   <include package=".webservice" />
   <include package=".worlddata" />
+  <include package=".xref" />
 </configure>

=== added directory 'lib/lp/services/xref'
=== added file 'lib/lp/services/xref/__init__.py'
--- lib/lp/services/xref/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/xref/__init__.py	2015-09-28 12:44:55 +0000
@@ -0,0 +1,13 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Generic cross references between artifacts.
+
+Provides infrastructure for generic information references between
+artifacts, easing weak coupling of apps.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = []

=== added file 'lib/lp/services/xref/configure.zcml'
--- lib/lp/services/xref/configure.zcml	1970-01-01 00:00:00 +0000
+++ lib/lp/services/xref/configure.zcml	2015-09-28 12:44:55 +0000
@@ -0,0 +1,13 @@
+<!-- Copyright 2015 Canonical Ltd.  This software is licensed under the
+     GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure xmlns="http://namespaces.zope.org/zope";>
+
+    <securedutility
+        class="lp.services.xref.model.XRefSet"
+        provides="lp.services.xref.interfaces.IXRefSet">
+        <allow interface="lp.services.xref.interfaces.IXRefSet"/>
+    </securedutility>
+
+</configure>

=== added file 'lib/lp/services/xref/interfaces.py'
--- lib/lp/services/xref/interfaces.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/xref/interfaces.py	2015-09-28 12:44:55 +0000
@@ -0,0 +1,62 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IXRefSet',
+    ]
+
+from zope.interface import Interface
+
+
+class IXRefSet(Interface):
+    """Manager of cross-references between objects.
+
+    Each participant in an xref has an "object ID": a tuple of
+    (str type, str id).
+
+    All xrefs are currently between local objects, so links always exist
+    in both directions, but this can't be assumed to hold in future.
+    """
+
+    def create(xrefs):
+        """Create cross-references.
+
+        Back-links are automatically created.
+
+        :param xrefs: A dict of
+            {from_object_id: {to_object_id:
+                {'creator': `IPerson`, 'metadata': value}}}.
+            The creator and metadata keys are optional.
+        """
+
+    def findFromMany(object_ids, types=None):
+        """Find all cross-references from multiple objects.
+
+        :param object_ids: A collection of object IDs.
+        :param types: An optional collection of the types to include.
+        :return: A dict of
+            {from_object_id: {to_object_id:
+                {'creator': `IPerson`, 'metadata': value}}}.
+            The creator and metadata keys are optional.
+        """
+
+    def delete(xrefs):
+        """Delete cross-references.
+
+        Back-links are automatically deleted.
+
+        :param xrefs: A dict of {from_object_id: [to_object_id]}.
+        """
+
+    def findFrom(object_id, types=None):
+        """Find all cross-references from an object.
+
+        :param object_id: An object ID.
+        :param types: An optional collection of the types to include.
+        :return: A dict of
+            {to_object_id: {'creator': `IPerson`, 'metadata': value}}.
+            The creator and metadata keys are optional.
+        """

=== added file 'lib/lp/services/xref/model.py'
--- lib/lp/services/xref/model.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/xref/model.py	2015-09-28 12:44:55 +0000
@@ -0,0 +1,131 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "XRefSet",
+    ]
+
+import pytz
+from storm.expr import (
+    And,
+    Or,
+    )
+from storm.properties import (
+    DateTime,
+    Int,
+    JSON,
+    Unicode,
+    )
+from storm.references import Reference
+from zope.interface import implementer
+
+from lp.services.database import bulk
+from lp.services.database.constants import UTC_NOW
+from lp.services.database.interfaces import IStore
+from lp.services.database.stormbase import StormBase
+from lp.services.xref.interfaces import IXRefSet
+
+
+class XRef(StormBase):
+    """Cross-reference between two objects.
+
+    For references to local objects (there is currently no other kind),
+    another reference in the opposite direction exists.
+
+    The to_id_int and from_id_int columns exist for efficient SQL joins.
+    They are set automatically when the ID looks like an integer.
+    """
+
+    __storm_table__ = 'XRef'
+    __storm_primary__ = "to_type", "to_id", "from_type", "from_id"
+
+    to_type = Unicode(allow_none=False)
+    to_id = Unicode(allow_none=False)
+    to_id_int = Int()  # For efficient joins.
+    from_type = Unicode(allow_none=False)
+    from_id = Unicode(allow_none=False)
+    from_id_int = Int()  # For efficient joins.
+    creator_id = Int(name="creator")
+    creator = Reference(creator_id, "Person.id")
+    date_created = DateTime(name='date_created', tzinfo=pytz.UTC)
+    metadata = JSON()
+
+
+def _int_or_none(s):
+    if s.isdigit():
+        return int(s)
+    else:
+        return None
+
+
+@implementer(IXRefSet)
+class XRefSet:
+
+    def create(self, xrefs):
+        # All references are currently to local objects, so add
+        # backlinks as well to keep queries in both directions quick.
+        # The *_id_int columns are also set if the ID looks like an int.
+        rows = []
+        for from_, tos in xrefs.items():
+            for to, props in tos.items():
+                rows.append((
+                    from_[0], from_[1], _int_or_none(from_[1]),
+                    to[0], to[1], _int_or_none(to[1]),
+                    props.get('creator'), props.get('date_created', UTC_NOW),
+                    props.get('metadata')))
+                rows.append((
+                    to[0], to[1], _int_or_none(to[1]),
+                    from_[0], from_[1], _int_or_none(from_[1]),
+                    props.get('creator'), props.get('date_created', UTC_NOW),
+                    props.get('metadata')))
+        bulk.create(
+            (XRef.from_type, XRef.from_id, XRef.from_id_int,
+             XRef.to_type, XRef.to_id, XRef.to_id_int,
+             XRef.creator, XRef.date_created, XRef.metadata), rows)
+
+    def delete(self, xrefs):
+        # Delete both directions.
+        pairs = []
+        for from_, tos in xrefs.items():
+            for to in tos:
+                pairs.extend([(from_, to), (to, from_)])
+
+        IStore(XRef).find(
+            XRef,
+            Or(*[
+                And(XRef.from_type == pair[0][0],
+                    XRef.from_id == pair[0][1],
+                    XRef.to_type == pair[1][0],
+                    XRef.to_id == pair[1][1])
+                for pair in pairs])
+            ).remove()
+
+    def findFromMany(self, object_ids, types=None):
+        from lp.registry.model.person import Person
+
+        object_ids = list(object_ids)
+        if not object_ids:
+            return {}
+
+        store = IStore(XRef)
+        rows = list(store.using(XRef).find(
+            (XRef.from_type, XRef.from_id, XRef.to_type, XRef.to_id,
+             XRef.creator_id, XRef.date_created, XRef.metadata),
+            Or(*[
+                And(XRef.from_type == id[0], XRef.from_id == id[1])
+                for id in object_ids]),
+            XRef.to_type.is_in(types) if types is not None else True))
+        bulk.load(Person, [row[4] for row in rows])
+        result = {}
+        for row in rows:
+            result.setdefault((row[0], row[1]), {})[(row[2], row[3])] = {
+                "creator": store.get(Person, row[4]) if row[4] else None,
+                "date_created": row[5],
+                "metadata": row[6]}
+        return result
+
+    def findFrom(self, object_id, types=None):
+        return self.findFromMany([object_id], types=types).get(object_id, {})

=== added directory 'lib/lp/services/xref/tests'
=== added file 'lib/lp/services/xref/tests/__init__.py'
--- lib/lp/services/xref/tests/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/xref/tests/__init__.py	2015-09-28 12:44:55 +0000
@@ -0,0 +1,7 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = []

=== added file 'lib/lp/services/xref/tests/test_model.py'
--- lib/lp/services/xref/tests/test_model.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/xref/tests/test_model.py	2015-09-28 12:44:55 +0000
@@ -0,0 +1,159 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+import datetime
+
+import pytz
+from testtools.matchers import Equals
+from zope.component import getUtility
+
+from lp.services.database.interfaces import IStore
+from lp.services.xref.interfaces import IXRefSet
+from lp.services.xref.model import XRef
+from lp.testing import (
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.matchers import HasQueryCount
+
+
+class TestXRefSet(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_create_sets_date_created(self):
+        # date_created defaults to now, but can be overridden.
+        old = datetime.datetime.strptime('2005-01-01', '%Y-%m-%d').replace(
+            tzinfo=pytz.UTC)
+        now = IStore(XRef).execute(
+            "SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
+            ).get_one()[0].replace(tzinfo=pytz.UTC)
+        getUtility(IXRefSet).create({
+            ('a', '1'): {('b', 'foo'): {}},
+            ('a', '2'): {('b', 'bar'): {'date_created': old}}})
+        rows = IStore(XRef).find(
+            (XRef.from_id, XRef.to_id, XRef.date_created),
+            XRef.from_type == 'a')
+        self.assertContentEqual(
+            [('1', 'foo', now), ('2', 'bar', old)], rows)
+
+    def test_create_sets_int_columns(self):
+        # The string ID columns have integers equivalents for quick and
+        # easy joins to integer PKs. They're set automatically when the
+        # string ID looks like an integer.
+        getUtility(IXRefSet).create({
+            ('a', '1234'): {('b', 'foo'): {}, ('b', '2468'): {}},
+            ('a', '12ab'): {('b', '1234'): {}, ('b', 'foo'): {}}})
+        rows = IStore(XRef).find(
+            (XRef.from_type, XRef.from_id, XRef.from_id_int, XRef.to_type,
+             XRef.to_id, XRef.to_id_int),
+            XRef.from_type == 'a')
+        self.assertContentEqual(
+            [('a', '1234', 1234, 'b', 'foo', None),
+             ('a', '1234', 1234, 'b', '2468', 2468),
+             ('a', '12ab', None, 'b', '1234', 1234),
+             ('a', '12ab', None, 'b', 'foo', None)
+             ],
+            rows)
+
+    def test_findFrom(self):
+        creator = self.factory.makePerson()
+        now = IStore(XRef).execute(
+            "SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
+            ).get_one()[0].replace(tzinfo=pytz.UTC)
+        getUtility(IXRefSet).create({
+            ('a', 'bar'): {
+                ('b', 'foo'): {'creator': creator, 'metadata': {'test': 1}}},
+            ('b', 'foo'): {
+                ('a', 'baz'): {'creator': creator, 'metadata': {'test': 2}}},
+            })
+
+        with StormStatementRecorder() as recorder:
+            bar_refs = getUtility(IXRefSet).findFrom(('a', 'bar'))
+        self.assertThat(recorder, HasQueryCount(Equals(2)))
+        self.assertEqual(
+            {('b', 'foo'): {
+                'creator': creator, 'date_created': now,
+                'metadata': {'test': 1}}},
+            bar_refs)
+
+        with StormStatementRecorder() as recorder:
+            foo_refs = getUtility(IXRefSet).findFrom(('b', 'foo'))
+        self.assertThat(recorder, HasQueryCount(Equals(2)))
+        self.assertEqual(
+            {('a', 'bar'): {
+                'creator': creator, 'date_created': now,
+                'metadata': {'test': 1}},
+             ('a', 'baz'): {
+                 'creator': creator, 'date_created': now,
+                 'metadata': {'test': 2}}},
+            foo_refs)
+
+        with StormStatementRecorder() as recorder:
+            bar_refs = getUtility(IXRefSet).findFrom(('a', 'baz'))
+        self.assertThat(recorder, HasQueryCount(Equals(2)))
+        self.assertEqual(
+            {('b', 'foo'): {
+                'creator': creator, 'date_created': now,
+                'metadata': {'test': 2}}},
+            bar_refs)
+
+        with StormStatementRecorder() as recorder:
+            bar_baz_refs = getUtility(IXRefSet).findFromMany(
+                [('a', 'bar'), ('a', 'baz')])
+        self.assertThat(recorder, HasQueryCount(Equals(2)))
+        self.assertEqual(
+            {('a', 'bar'): {
+                ('b', 'foo'): {
+                    'creator': creator, 'date_created': now,
+                    'metadata': {'test': 1}}},
+             ('a', 'baz'): {
+                ('b', 'foo'): {
+                    'creator': creator, 'date_created': now,
+                    'metadata': {'test': 2}}}},
+             bar_baz_refs)
+
+    def test_findFrom_types(self):
+        # findFrom can look for only particular types of related
+        # objects.
+        getUtility(IXRefSet).create({
+            ('a', '1'): {('a', '2'): {}, ('b', '3'): {}},
+            ('b', '4'): {('a', '5'): {}, ('c', '6'): {}},
+            })
+        self.assertContentEqual(
+            [('a', '2')],
+            getUtility(IXRefSet).findFrom(('a', '1'), types=['a', 'c']).keys())
+        self.assertContentEqual(
+            [('a', '5'), ('c', '6')],
+            getUtility(IXRefSet).findFrom(('b', '4'), types=['a', 'c']).keys())
+
+        # Asking for no types or types that don't exist finds nothing.
+        self.assertContentEqual(
+            [],
+            getUtility(IXRefSet).findFrom(('b', '4'), types=[]).keys())
+        self.assertContentEqual(
+            [],
+            getUtility(IXRefSet).findFrom(('b', '4'), types=['d']).keys())
+
+    def test_findFromMany_none(self):
+        self.assertEqual({}, getUtility(IXRefSet).findFromMany([]))
+
+    def test_delete(self):
+        getUtility(IXRefSet).create({
+            ('a', 'bar'): {('b', 'foo'): {}},
+            ('b', 'foo'): {('a', 'baz'): {}},
+            })
+        self.assertContentEqual(
+            [('a', 'bar'), ('a', 'baz')],
+            getUtility(IXRefSet).findFrom(('b', 'foo')).keys())
+        with StormStatementRecorder() as recorder:
+            getUtility(IXRefSet).delete({('b', 'foo'): [('a', 'bar')]})
+        self.assertThat(recorder, HasQueryCount(Equals(1)))
+        self.assertEqual(
+            [('a', 'baz')],
+            getUtility(IXRefSet).findFrom(('b', 'foo')).keys())

=== modified file 'lib/lp/testing/tests/test_standard_test_template.py'
--- lib/lp/testing/tests/test_standard_test_template.py	2015-01-30 10:13:51 +0000
+++ lib/lp/testing/tests/test_standard_test_template.py	2015-09-28 12:44:55 +0000
@@ -3,6 +3,8 @@
 
 """XXX: Module docstring goes here."""
 
+from __future__ import absolute_import, print_function, unicode_literals
+
 __metaclass__ = type
 
 # or TestCaseWithFactory

=== modified file 'lib/lp/testing/tests/test_standard_yuixhr_test_template.py'
--- lib/lp/testing/tests/test_standard_yuixhr_test_template.py	2015-01-30 10:13:51 +0000
+++ lib/lp/testing/tests/test_standard_yuixhr_test_template.py	2015-09-28 12:44:55 +0000
@@ -4,6 +4,8 @@
 """{Describe your test suite here}.
 """
 
+from __future__ import absolute_import, print_function, unicode_literals
+
 __metaclass__ = type
 __all__ = []
 

=== modified file 'standard_template.py'
--- standard_template.py	2015-01-30 10:13:51 +0000
+++ standard_template.py	2015-09-28 12:44:55 +0000
@@ -3,5 +3,7 @@
 
 """XXX: Module docstring goes here."""
 
+from __future__ import absolute_import, print_function, unicode_literals
+
 __metaclass__ = type
 __all__ = []


Follow ups