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

Proposed by William Grant
Status: Merged
Merged at revision: 17779
Proposed branch: lp:~wgrant/launchpad/xref-model
Merge into: lp:launchpad
Prerequisite: lp:~wgrant/launchpad/xref-db
Diff against target: 491 lines (+419/-0)
10 files modified
lib/lp/services/configure.zcml (+1/-0)
lib/lp/services/xref/__init__.py (+13/-0)
lib/lp/services/xref/configure.zcml (+13/-0)
lib/lp/services/xref/interfaces.py (+62/-0)
lib/lp/services/xref/model.py (+131/-0)
lib/lp/services/xref/tests/__init__.py (+7/-0)
lib/lp/services/xref/tests/test_model.py (+186/-0)
lib/lp/testing/tests/test_standard_test_template.py (+2/-0)
lib/lp/testing/tests/test_standard_yuixhr_test_template.py (+2/-0)
standard_template.py (+2/-0)
To merge this branch: bzr merge lp:~wgrant/launchpad/xref-model
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+272588@code.launchpad.net

Commit message

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

Description of the change

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.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== 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 23:38:07 +0000
@@ -34,4 +34,5 @@
34 <include package=".webhooks" />34 <include package=".webhooks" />
35 <include package=".webservice" />35 <include package=".webservice" />
36 <include package=".worlddata" />36 <include package=".worlddata" />
37 <include package=".xref" />
37</configure>38</configure>
3839
=== 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 23:38:07 +0000
@@ -0,0 +1,13 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Generic cross references between artifacts.
5
6Provides infrastructure for generic information references between
7artifacts, easing weak coupling of apps.
8"""
9
10from __future__ import absolute_import, print_function, unicode_literals
11
12__metaclass__ = type
13__all__ = []
014
=== 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 23:38:07 +0000
@@ -0,0 +1,13 @@
1<!-- Copyright 2015 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->
4
5<configure xmlns="http://namespaces.zope.org/zope">
6
7 <securedutility
8 class="lp.services.xref.model.XRefSet"
9 provides="lp.services.xref.interfaces.IXRefSet">
10 <allow interface="lp.services.xref.interfaces.IXRefSet"/>
11 </securedutility>
12
13</configure>
014
=== 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 23:38:07 +0000
@@ -0,0 +1,62 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import absolute_import, print_function, unicode_literals
5
6__metaclass__ = type
7__all__ = [
8 'IXRefSet',
9 ]
10
11from zope.interface import Interface
12
13
14class IXRefSet(Interface):
15 """Manager of cross-references between objects.
16
17 Each participant in an xref has an "object ID": a tuple of
18 (str type, str id).
19
20 All xrefs are currently between local objects, so links always exist
21 in both directions, but this can't be assumed to hold in future.
22 """
23
24 def create(xrefs):
25 """Create cross-references.
26
27 Back-links are automatically created.
28
29 :param xrefs: A dict of
30 {from_object_id: {to_object_id:
31 {'creator': `IPerson`, 'metadata': value}}}.
32 The creator and metadata keys are optional.
33 """
34
35 def findFromMany(object_ids, types=None):
36 """Find all cross-references from multiple objects.
37
38 :param object_ids: A collection of object IDs.
39 :param types: An optional collection of the types to include.
40 :return: A dict of
41 {from_object_id: {to_object_id:
42 {'creator': `IPerson`, 'metadata': value}}}.
43 The creator and metadata keys are optional.
44 """
45
46 def delete(xrefs):
47 """Delete cross-references.
48
49 Back-links are automatically deleted.
50
51 :param xrefs: A dict of {from_object_id: [to_object_id]}.
52 """
53
54 def findFrom(object_id, types=None):
55 """Find all cross-references from an object.
56
57 :param object_id: An object ID.
58 :param types: An optional collection of the types to include.
59 :return: A dict of
60 {to_object_id: {'creator': `IPerson`, 'metadata': value}}.
61 The creator and metadata keys are optional.
62 """
063
=== 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 23:38:07 +0000
@@ -0,0 +1,131 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import absolute_import, print_function, unicode_literals
5
6__metaclass__ = type
7__all__ = [
8 "XRefSet",
9 ]
10
11import pytz
12from storm.expr import (
13 And,
14 Or,
15 )
16from storm.properties import (
17 DateTime,
18 Int,
19 JSON,
20 Unicode,
21 )
22from storm.references import Reference
23from zope.interface import implementer
24
25from lp.services.database import bulk
26from lp.services.database.constants import UTC_NOW
27from lp.services.database.interfaces import IStore
28from lp.services.database.stormbase import StormBase
29from lp.services.xref.interfaces import IXRefSet
30
31
32class XRef(StormBase):
33 """Cross-reference between two objects.
34
35 For references to local objects (there is currently no other kind),
36 another reference in the opposite direction exists.
37
38 The to_id_int and from_id_int columns exist for efficient SQL joins.
39 They are set automatically when the ID looks like an integer.
40 """
41
42 __storm_table__ = 'XRef'
43 __storm_primary__ = "from_type", "from_id", "to_type", "to_id"
44
45 to_type = Unicode(allow_none=False)
46 to_id = Unicode(allow_none=False)
47 to_id_int = Int() # For efficient joins.
48 from_type = Unicode(allow_none=False)
49 from_id = Unicode(allow_none=False)
50 from_id_int = Int() # For efficient joins.
51 creator_id = Int(name="creator")
52 creator = Reference(creator_id, "Person.id")
53 date_created = DateTime(name='date_created', tzinfo=pytz.UTC)
54 metadata = JSON()
55
56
57def _int_or_none(s):
58 if s.isdigit():
59 return int(s)
60 else:
61 return None
62
63
64@implementer(IXRefSet)
65class XRefSet:
66
67 def create(self, xrefs):
68 # All references are currently to local objects, so add
69 # backlinks as well to keep queries in both directions quick.
70 # The *_id_int columns are also set if the ID looks like an int.
71 rows = []
72 for from_, tos in xrefs.items():
73 for to, props in tos.items():
74 rows.append((
75 from_[0], from_[1], _int_or_none(from_[1]),
76 to[0], to[1], _int_or_none(to[1]),
77 props.get('creator'), props.get('date_created', UTC_NOW),
78 props.get('metadata')))
79 rows.append((
80 to[0], to[1], _int_or_none(to[1]),
81 from_[0], from_[1], _int_or_none(from_[1]),
82 props.get('creator'), props.get('date_created', UTC_NOW),
83 props.get('metadata')))
84 bulk.create(
85 (XRef.from_type, XRef.from_id, XRef.from_id_int,
86 XRef.to_type, XRef.to_id, XRef.to_id_int,
87 XRef.creator, XRef.date_created, XRef.metadata), rows)
88
89 def delete(self, xrefs):
90 # Delete both directions.
91 pairs = []
92 for from_, tos in xrefs.items():
93 for to in tos:
94 pairs.extend([(from_, to), (to, from_)])
95
96 IStore(XRef).find(
97 XRef,
98 Or(*[
99 And(XRef.from_type == pair[0][0],
100 XRef.from_id == pair[0][1],
101 XRef.to_type == pair[1][0],
102 XRef.to_id == pair[1][1])
103 for pair in pairs])
104 ).remove()
105
106 def findFromMany(self, object_ids, types=None):
107 from lp.registry.model.person import Person
108
109 object_ids = list(object_ids)
110 if not object_ids:
111 return {}
112
113 store = IStore(XRef)
114 rows = list(store.using(XRef).find(
115 (XRef.from_type, XRef.from_id, XRef.to_type, XRef.to_id,
116 XRef.creator_id, XRef.date_created, XRef.metadata),
117 Or(*[
118 And(XRef.from_type == id[0], XRef.from_id == id[1])
119 for id in object_ids]),
120 XRef.to_type.is_in(types) if types is not None else True))
121 bulk.load(Person, [row[4] for row in rows])
122 result = {}
123 for row in rows:
124 result.setdefault((row[0], row[1]), {})[(row[2], row[3])] = {
125 "creator": store.get(Person, row[4]) if row[4] else None,
126 "date_created": row[5],
127 "metadata": row[6]}
128 return result
129
130 def findFrom(self, object_id, types=None):
131 return self.findFromMany([object_id], types=types).get(object_id, {})
0132
=== 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 23:38:07 +0000
@@ -0,0 +1,7 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import absolute_import, print_function, unicode_literals
5
6__metaclass__ = type
7__all__ = []
08
=== 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 23:38:07 +0000
@@ -0,0 +1,186 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import absolute_import, print_function, unicode_literals
5
6__metaclass__ = type
7
8import datetime
9
10import pytz
11from testtools.matchers import (
12 ContainsDict,
13 Equals,
14 MatchesDict,
15 )
16from zope.component import getUtility
17
18from lp.services.database.interfaces import IStore
19from lp.services.database.sqlbase import flush_database_caches
20from lp.services.xref.interfaces import IXRefSet
21from lp.services.xref.model import XRef
22from lp.testing import (
23 StormStatementRecorder,
24 TestCaseWithFactory,
25 )
26from lp.testing.layers import DatabaseFunctionalLayer
27from lp.testing.matchers import HasQueryCount
28
29
30class TestXRefSet(TestCaseWithFactory):
31
32 layer = DatabaseFunctionalLayer
33
34 def test_create_sets_date_created(self):
35 # date_created defaults to now, but can be overridden.
36 old = datetime.datetime.strptime('2005-01-01', '%Y-%m-%d').replace(
37 tzinfo=pytz.UTC)
38 now = IStore(XRef).execute(
39 "SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
40 ).get_one()[0].replace(tzinfo=pytz.UTC)
41 getUtility(IXRefSet).create({
42 ('a', '1'): {('b', 'foo'): {}},
43 ('a', '2'): {('b', 'bar'): {'date_created': old}}})
44 rows = IStore(XRef).find(
45 (XRef.from_id, XRef.to_id, XRef.date_created),
46 XRef.from_type == 'a')
47 self.assertContentEqual(
48 [('1', 'foo', now), ('2', 'bar', old)], rows)
49
50 def test_create_sets_int_columns(self):
51 # The string ID columns have integers equivalents for quick and
52 # easy joins to integer PKs. They're set automatically when the
53 # string ID looks like an integer.
54 getUtility(IXRefSet).create({
55 ('a', '1234'): {('b', 'foo'): {}, ('b', '2468'): {}},
56 ('a', '12ab'): {('b', '1234'): {}, ('b', 'foo'): {}}})
57 rows = IStore(XRef).find(
58 (XRef.from_type, XRef.from_id, XRef.from_id_int, XRef.to_type,
59 XRef.to_id, XRef.to_id_int),
60 XRef.from_type == 'a')
61 self.assertContentEqual(
62 [('a', '1234', 1234, 'b', 'foo', None),
63 ('a', '1234', 1234, 'b', '2468', 2468),
64 ('a', '12ab', None, 'b', '1234', 1234),
65 ('a', '12ab', None, 'b', 'foo', None)
66 ],
67 rows)
68
69 def test_findFrom(self):
70 creator = self.factory.makePerson()
71 now = IStore(XRef).execute(
72 "SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
73 ).get_one()[0].replace(tzinfo=pytz.UTC)
74 getUtility(IXRefSet).create({
75 ('a', 'bar'): {
76 ('b', 'foo'): {'creator': creator, 'metadata': {'test': 1}}},
77 ('b', 'foo'): {
78 ('a', 'baz'): {'creator': creator, 'metadata': {'test': 2}}},
79 })
80
81 with StormStatementRecorder() as recorder:
82 bar_refs = getUtility(IXRefSet).findFrom(('a', 'bar'))
83 self.assertThat(recorder, HasQueryCount(Equals(2)))
84 self.assertEqual(
85 {('b', 'foo'): {
86 'creator': creator, 'date_created': now,
87 'metadata': {'test': 1}}},
88 bar_refs)
89
90 with StormStatementRecorder() as recorder:
91 foo_refs = getUtility(IXRefSet).findFrom(('b', 'foo'))
92 self.assertThat(recorder, HasQueryCount(Equals(2)))
93 self.assertEqual(
94 {('a', 'bar'): {
95 'creator': creator, 'date_created': now,
96 'metadata': {'test': 1}},
97 ('a', 'baz'): {
98 'creator': creator, 'date_created': now,
99 'metadata': {'test': 2}}},
100 foo_refs)
101
102 with StormStatementRecorder() as recorder:
103 bar_refs = getUtility(IXRefSet).findFrom(('a', 'baz'))
104 self.assertThat(recorder, HasQueryCount(Equals(2)))
105 self.assertEqual(
106 {('b', 'foo'): {
107 'creator': creator, 'date_created': now,
108 'metadata': {'test': 2}}},
109 bar_refs)
110
111 with StormStatementRecorder() as recorder:
112 bar_baz_refs = getUtility(IXRefSet).findFromMany(
113 [('a', 'bar'), ('a', 'baz')])
114 self.assertThat(recorder, HasQueryCount(Equals(2)))
115 self.assertEqual(
116 {('a', 'bar'): {
117 ('b', 'foo'): {
118 'creator': creator, 'date_created': now,
119 'metadata': {'test': 1}}},
120 ('a', 'baz'): {
121 ('b', 'foo'): {
122 'creator': creator, 'date_created': now,
123 'metadata': {'test': 2}}}},
124 bar_baz_refs)
125
126 def test_findFrom_creator(self):
127 # findFrom issues a single query to get all of the people.
128 people = [self.factory.makePerson() for i in range(3)]
129 getUtility(IXRefSet).create({
130 ('a', '0'): {
131 ('b', '0'): {'creator': people[2]},
132 ('b', '1'): {'creator': people[0]},
133 ('b', '2'): {'creator': people[1]},
134 },
135 })
136 flush_database_caches()
137 with StormStatementRecorder() as recorder:
138 xrefs = getUtility(IXRefSet).findFrom(('a', '0'))
139 self.assertThat(
140 xrefs,
141 MatchesDict({
142 ('b', '0'): ContainsDict({'creator': Equals(people[2])}),
143 ('b', '1'): ContainsDict({'creator': Equals(people[0])}),
144 ('b', '2'): ContainsDict({'creator': Equals(people[1])}),
145 }))
146 self.assertThat(recorder, HasQueryCount(Equals(2)))
147
148 def test_findFrom_types(self):
149 # findFrom can look for only particular types of related
150 # objects.
151 getUtility(IXRefSet).create({
152 ('a', '1'): {('a', '2'): {}, ('b', '3'): {}},
153 ('b', '4'): {('a', '5'): {}, ('c', '6'): {}},
154 })
155 self.assertContentEqual(
156 [('a', '2')],
157 getUtility(IXRefSet).findFrom(('a', '1'), types=['a', 'c']).keys())
158 self.assertContentEqual(
159 [('a', '5'), ('c', '6')],
160 getUtility(IXRefSet).findFrom(('b', '4'), types=['a', 'c']).keys())
161
162 # Asking for no types or types that don't exist finds nothing.
163 self.assertContentEqual(
164 [],
165 getUtility(IXRefSet).findFrom(('b', '4'), types=[]).keys())
166 self.assertContentEqual(
167 [],
168 getUtility(IXRefSet).findFrom(('b', '4'), types=['d']).keys())
169
170 def test_findFromMany_none(self):
171 self.assertEqual({}, getUtility(IXRefSet).findFromMany([]))
172
173 def test_delete(self):
174 getUtility(IXRefSet).create({
175 ('a', 'bar'): {('b', 'foo'): {}},
176 ('b', 'foo'): {('a', 'baz'): {}},
177 })
178 self.assertContentEqual(
179 [('a', 'bar'), ('a', 'baz')],
180 getUtility(IXRefSet).findFrom(('b', 'foo')).keys())
181 with StormStatementRecorder() as recorder:
182 getUtility(IXRefSet).delete({('b', 'foo'): [('a', 'bar')]})
183 self.assertThat(recorder, HasQueryCount(Equals(1)))
184 self.assertEqual(
185 [('a', 'baz')],
186 getUtility(IXRefSet).findFrom(('b', 'foo')).keys())
0187
=== 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 23:38:07 +0000
@@ -3,6 +3,8 @@
33
4"""XXX: Module docstring goes here."""4"""XXX: Module docstring goes here."""
55
6from __future__ import absolute_import, print_function, unicode_literals
7
6__metaclass__ = type8__metaclass__ = type
79
8# or TestCaseWithFactory10# or TestCaseWithFactory
911
=== 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 23:38:07 +0000
@@ -4,6 +4,8 @@
4"""{Describe your test suite here}.4"""{Describe your test suite here}.
5"""5"""
66
7from __future__ import absolute_import, print_function, unicode_literals
8
7__metaclass__ = type9__metaclass__ = type
8__all__ = []10__all__ = []
911
1012
=== modified file 'standard_template.py'
--- standard_template.py 2015-01-30 10:13:51 +0000
+++ standard_template.py 2015-09-28 23:38:07 +0000
@@ -3,5 +3,7 @@
33
4"""XXX: Module docstring goes here."""4"""XXX: Module docstring goes here."""
55
6from __future__ import absolute_import, print_function, unicode_literals
7
6__metaclass__ = type8__metaclass__ = type
7__all__ = []9__all__ = []