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
1=== modified file 'lib/lp/services/configure.zcml'
2--- lib/lp/services/configure.zcml 2015-05-22 07:12:35 +0000
3+++ lib/lp/services/configure.zcml 2015-09-28 23:38:07 +0000
4@@ -34,4 +34,5 @@
5 <include package=".webhooks" />
6 <include package=".webservice" />
7 <include package=".worlddata" />
8+ <include package=".xref" />
9 </configure>
10
11=== added directory 'lib/lp/services/xref'
12=== added file 'lib/lp/services/xref/__init__.py'
13--- lib/lp/services/xref/__init__.py 1970-01-01 00:00:00 +0000
14+++ lib/lp/services/xref/__init__.py 2015-09-28 23:38:07 +0000
15@@ -0,0 +1,13 @@
16+# Copyright 2015 Canonical Ltd. This software is licensed under the
17+# GNU Affero General Public License version 3 (see the file LICENSE).
18+
19+"""Generic cross references between artifacts.
20+
21+Provides infrastructure for generic information references between
22+artifacts, easing weak coupling of apps.
23+"""
24+
25+from __future__ import absolute_import, print_function, unicode_literals
26+
27+__metaclass__ = type
28+__all__ = []
29
30=== added file 'lib/lp/services/xref/configure.zcml'
31--- lib/lp/services/xref/configure.zcml 1970-01-01 00:00:00 +0000
32+++ lib/lp/services/xref/configure.zcml 2015-09-28 23:38:07 +0000
33@@ -0,0 +1,13 @@
34+<!-- Copyright 2015 Canonical Ltd. This software is licensed under the
35+ GNU Affero General Public License version 3 (see the file LICENSE).
36+-->
37+
38+<configure xmlns="http://namespaces.zope.org/zope">
39+
40+ <securedutility
41+ class="lp.services.xref.model.XRefSet"
42+ provides="lp.services.xref.interfaces.IXRefSet">
43+ <allow interface="lp.services.xref.interfaces.IXRefSet"/>
44+ </securedutility>
45+
46+</configure>
47
48=== added file 'lib/lp/services/xref/interfaces.py'
49--- lib/lp/services/xref/interfaces.py 1970-01-01 00:00:00 +0000
50+++ lib/lp/services/xref/interfaces.py 2015-09-28 23:38:07 +0000
51@@ -0,0 +1,62 @@
52+# Copyright 2015 Canonical Ltd. This software is licensed under the
53+# GNU Affero General Public License version 3 (see the file LICENSE).
54+
55+from __future__ import absolute_import, print_function, unicode_literals
56+
57+__metaclass__ = type
58+__all__ = [
59+ 'IXRefSet',
60+ ]
61+
62+from zope.interface import Interface
63+
64+
65+class IXRefSet(Interface):
66+ """Manager of cross-references between objects.
67+
68+ Each participant in an xref has an "object ID": a tuple of
69+ (str type, str id).
70+
71+ All xrefs are currently between local objects, so links always exist
72+ in both directions, but this can't be assumed to hold in future.
73+ """
74+
75+ def create(xrefs):
76+ """Create cross-references.
77+
78+ Back-links are automatically created.
79+
80+ :param xrefs: A dict of
81+ {from_object_id: {to_object_id:
82+ {'creator': `IPerson`, 'metadata': value}}}.
83+ The creator and metadata keys are optional.
84+ """
85+
86+ def findFromMany(object_ids, types=None):
87+ """Find all cross-references from multiple objects.
88+
89+ :param object_ids: A collection of object IDs.
90+ :param types: An optional collection of the types to include.
91+ :return: A dict of
92+ {from_object_id: {to_object_id:
93+ {'creator': `IPerson`, 'metadata': value}}}.
94+ The creator and metadata keys are optional.
95+ """
96+
97+ def delete(xrefs):
98+ """Delete cross-references.
99+
100+ Back-links are automatically deleted.
101+
102+ :param xrefs: A dict of {from_object_id: [to_object_id]}.
103+ """
104+
105+ def findFrom(object_id, types=None):
106+ """Find all cross-references from an object.
107+
108+ :param object_id: An object ID.
109+ :param types: An optional collection of the types to include.
110+ :return: A dict of
111+ {to_object_id: {'creator': `IPerson`, 'metadata': value}}.
112+ The creator and metadata keys are optional.
113+ """
114
115=== added file 'lib/lp/services/xref/model.py'
116--- lib/lp/services/xref/model.py 1970-01-01 00:00:00 +0000
117+++ lib/lp/services/xref/model.py 2015-09-28 23:38:07 +0000
118@@ -0,0 +1,131 @@
119+# Copyright 2015 Canonical Ltd. This software is licensed under the
120+# GNU Affero General Public License version 3 (see the file LICENSE).
121+
122+from __future__ import absolute_import, print_function, unicode_literals
123+
124+__metaclass__ = type
125+__all__ = [
126+ "XRefSet",
127+ ]
128+
129+import pytz
130+from storm.expr import (
131+ And,
132+ Or,
133+ )
134+from storm.properties import (
135+ DateTime,
136+ Int,
137+ JSON,
138+ Unicode,
139+ )
140+from storm.references import Reference
141+from zope.interface import implementer
142+
143+from lp.services.database import bulk
144+from lp.services.database.constants import UTC_NOW
145+from lp.services.database.interfaces import IStore
146+from lp.services.database.stormbase import StormBase
147+from lp.services.xref.interfaces import IXRefSet
148+
149+
150+class XRef(StormBase):
151+ """Cross-reference between two objects.
152+
153+ For references to local objects (there is currently no other kind),
154+ another reference in the opposite direction exists.
155+
156+ The to_id_int and from_id_int columns exist for efficient SQL joins.
157+ They are set automatically when the ID looks like an integer.
158+ """
159+
160+ __storm_table__ = 'XRef'
161+ __storm_primary__ = "from_type", "from_id", "to_type", "to_id"
162+
163+ to_type = Unicode(allow_none=False)
164+ to_id = Unicode(allow_none=False)
165+ to_id_int = Int() # For efficient joins.
166+ from_type = Unicode(allow_none=False)
167+ from_id = Unicode(allow_none=False)
168+ from_id_int = Int() # For efficient joins.
169+ creator_id = Int(name="creator")
170+ creator = Reference(creator_id, "Person.id")
171+ date_created = DateTime(name='date_created', tzinfo=pytz.UTC)
172+ metadata = JSON()
173+
174+
175+def _int_or_none(s):
176+ if s.isdigit():
177+ return int(s)
178+ else:
179+ return None
180+
181+
182+@implementer(IXRefSet)
183+class XRefSet:
184+
185+ def create(self, xrefs):
186+ # All references are currently to local objects, so add
187+ # backlinks as well to keep queries in both directions quick.
188+ # The *_id_int columns are also set if the ID looks like an int.
189+ rows = []
190+ for from_, tos in xrefs.items():
191+ for to, props in tos.items():
192+ rows.append((
193+ from_[0], from_[1], _int_or_none(from_[1]),
194+ to[0], to[1], _int_or_none(to[1]),
195+ props.get('creator'), props.get('date_created', UTC_NOW),
196+ props.get('metadata')))
197+ rows.append((
198+ to[0], to[1], _int_or_none(to[1]),
199+ from_[0], from_[1], _int_or_none(from_[1]),
200+ props.get('creator'), props.get('date_created', UTC_NOW),
201+ props.get('metadata')))
202+ bulk.create(
203+ (XRef.from_type, XRef.from_id, XRef.from_id_int,
204+ XRef.to_type, XRef.to_id, XRef.to_id_int,
205+ XRef.creator, XRef.date_created, XRef.metadata), rows)
206+
207+ def delete(self, xrefs):
208+ # Delete both directions.
209+ pairs = []
210+ for from_, tos in xrefs.items():
211+ for to in tos:
212+ pairs.extend([(from_, to), (to, from_)])
213+
214+ IStore(XRef).find(
215+ XRef,
216+ Or(*[
217+ And(XRef.from_type == pair[0][0],
218+ XRef.from_id == pair[0][1],
219+ XRef.to_type == pair[1][0],
220+ XRef.to_id == pair[1][1])
221+ for pair in pairs])
222+ ).remove()
223+
224+ def findFromMany(self, object_ids, types=None):
225+ from lp.registry.model.person import Person
226+
227+ object_ids = list(object_ids)
228+ if not object_ids:
229+ return {}
230+
231+ store = IStore(XRef)
232+ rows = list(store.using(XRef).find(
233+ (XRef.from_type, XRef.from_id, XRef.to_type, XRef.to_id,
234+ XRef.creator_id, XRef.date_created, XRef.metadata),
235+ Or(*[
236+ And(XRef.from_type == id[0], XRef.from_id == id[1])
237+ for id in object_ids]),
238+ XRef.to_type.is_in(types) if types is not None else True))
239+ bulk.load(Person, [row[4] for row in rows])
240+ result = {}
241+ for row in rows:
242+ result.setdefault((row[0], row[1]), {})[(row[2], row[3])] = {
243+ "creator": store.get(Person, row[4]) if row[4] else None,
244+ "date_created": row[5],
245+ "metadata": row[6]}
246+ return result
247+
248+ def findFrom(self, object_id, types=None):
249+ return self.findFromMany([object_id], types=types).get(object_id, {})
250
251=== added directory 'lib/lp/services/xref/tests'
252=== added file 'lib/lp/services/xref/tests/__init__.py'
253--- lib/lp/services/xref/tests/__init__.py 1970-01-01 00:00:00 +0000
254+++ lib/lp/services/xref/tests/__init__.py 2015-09-28 23:38:07 +0000
255@@ -0,0 +1,7 @@
256+# Copyright 2015 Canonical Ltd. This software is licensed under the
257+# GNU Affero General Public License version 3 (see the file LICENSE).
258+
259+from __future__ import absolute_import, print_function, unicode_literals
260+
261+__metaclass__ = type
262+__all__ = []
263
264=== added file 'lib/lp/services/xref/tests/test_model.py'
265--- lib/lp/services/xref/tests/test_model.py 1970-01-01 00:00:00 +0000
266+++ lib/lp/services/xref/tests/test_model.py 2015-09-28 23:38:07 +0000
267@@ -0,0 +1,186 @@
268+# Copyright 2015 Canonical Ltd. This software is licensed under the
269+# GNU Affero General Public License version 3 (see the file LICENSE).
270+
271+from __future__ import absolute_import, print_function, unicode_literals
272+
273+__metaclass__ = type
274+
275+import datetime
276+
277+import pytz
278+from testtools.matchers import (
279+ ContainsDict,
280+ Equals,
281+ MatchesDict,
282+ )
283+from zope.component import getUtility
284+
285+from lp.services.database.interfaces import IStore
286+from lp.services.database.sqlbase import flush_database_caches
287+from lp.services.xref.interfaces import IXRefSet
288+from lp.services.xref.model import XRef
289+from lp.testing import (
290+ StormStatementRecorder,
291+ TestCaseWithFactory,
292+ )
293+from lp.testing.layers import DatabaseFunctionalLayer
294+from lp.testing.matchers import HasQueryCount
295+
296+
297+class TestXRefSet(TestCaseWithFactory):
298+
299+ layer = DatabaseFunctionalLayer
300+
301+ def test_create_sets_date_created(self):
302+ # date_created defaults to now, but can be overridden.
303+ old = datetime.datetime.strptime('2005-01-01', '%Y-%m-%d').replace(
304+ tzinfo=pytz.UTC)
305+ now = IStore(XRef).execute(
306+ "SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
307+ ).get_one()[0].replace(tzinfo=pytz.UTC)
308+ getUtility(IXRefSet).create({
309+ ('a', '1'): {('b', 'foo'): {}},
310+ ('a', '2'): {('b', 'bar'): {'date_created': old}}})
311+ rows = IStore(XRef).find(
312+ (XRef.from_id, XRef.to_id, XRef.date_created),
313+ XRef.from_type == 'a')
314+ self.assertContentEqual(
315+ [('1', 'foo', now), ('2', 'bar', old)], rows)
316+
317+ def test_create_sets_int_columns(self):
318+ # The string ID columns have integers equivalents for quick and
319+ # easy joins to integer PKs. They're set automatically when the
320+ # string ID looks like an integer.
321+ getUtility(IXRefSet).create({
322+ ('a', '1234'): {('b', 'foo'): {}, ('b', '2468'): {}},
323+ ('a', '12ab'): {('b', '1234'): {}, ('b', 'foo'): {}}})
324+ rows = IStore(XRef).find(
325+ (XRef.from_type, XRef.from_id, XRef.from_id_int, XRef.to_type,
326+ XRef.to_id, XRef.to_id_int),
327+ XRef.from_type == 'a')
328+ self.assertContentEqual(
329+ [('a', '1234', 1234, 'b', 'foo', None),
330+ ('a', '1234', 1234, 'b', '2468', 2468),
331+ ('a', '12ab', None, 'b', '1234', 1234),
332+ ('a', '12ab', None, 'b', 'foo', None)
333+ ],
334+ rows)
335+
336+ def test_findFrom(self):
337+ creator = self.factory.makePerson()
338+ now = IStore(XRef).execute(
339+ "SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
340+ ).get_one()[0].replace(tzinfo=pytz.UTC)
341+ getUtility(IXRefSet).create({
342+ ('a', 'bar'): {
343+ ('b', 'foo'): {'creator': creator, 'metadata': {'test': 1}}},
344+ ('b', 'foo'): {
345+ ('a', 'baz'): {'creator': creator, 'metadata': {'test': 2}}},
346+ })
347+
348+ with StormStatementRecorder() as recorder:
349+ bar_refs = getUtility(IXRefSet).findFrom(('a', 'bar'))
350+ self.assertThat(recorder, HasQueryCount(Equals(2)))
351+ self.assertEqual(
352+ {('b', 'foo'): {
353+ 'creator': creator, 'date_created': now,
354+ 'metadata': {'test': 1}}},
355+ bar_refs)
356+
357+ with StormStatementRecorder() as recorder:
358+ foo_refs = getUtility(IXRefSet).findFrom(('b', 'foo'))
359+ self.assertThat(recorder, HasQueryCount(Equals(2)))
360+ self.assertEqual(
361+ {('a', 'bar'): {
362+ 'creator': creator, 'date_created': now,
363+ 'metadata': {'test': 1}},
364+ ('a', 'baz'): {
365+ 'creator': creator, 'date_created': now,
366+ 'metadata': {'test': 2}}},
367+ foo_refs)
368+
369+ with StormStatementRecorder() as recorder:
370+ bar_refs = getUtility(IXRefSet).findFrom(('a', 'baz'))
371+ self.assertThat(recorder, HasQueryCount(Equals(2)))
372+ self.assertEqual(
373+ {('b', 'foo'): {
374+ 'creator': creator, 'date_created': now,
375+ 'metadata': {'test': 2}}},
376+ bar_refs)
377+
378+ with StormStatementRecorder() as recorder:
379+ bar_baz_refs = getUtility(IXRefSet).findFromMany(
380+ [('a', 'bar'), ('a', 'baz')])
381+ self.assertThat(recorder, HasQueryCount(Equals(2)))
382+ self.assertEqual(
383+ {('a', 'bar'): {
384+ ('b', 'foo'): {
385+ 'creator': creator, 'date_created': now,
386+ 'metadata': {'test': 1}}},
387+ ('a', 'baz'): {
388+ ('b', 'foo'): {
389+ 'creator': creator, 'date_created': now,
390+ 'metadata': {'test': 2}}}},
391+ bar_baz_refs)
392+
393+ def test_findFrom_creator(self):
394+ # findFrom issues a single query to get all of the people.
395+ people = [self.factory.makePerson() for i in range(3)]
396+ getUtility(IXRefSet).create({
397+ ('a', '0'): {
398+ ('b', '0'): {'creator': people[2]},
399+ ('b', '1'): {'creator': people[0]},
400+ ('b', '2'): {'creator': people[1]},
401+ },
402+ })
403+ flush_database_caches()
404+ with StormStatementRecorder() as recorder:
405+ xrefs = getUtility(IXRefSet).findFrom(('a', '0'))
406+ self.assertThat(
407+ xrefs,
408+ MatchesDict({
409+ ('b', '0'): ContainsDict({'creator': Equals(people[2])}),
410+ ('b', '1'): ContainsDict({'creator': Equals(people[0])}),
411+ ('b', '2'): ContainsDict({'creator': Equals(people[1])}),
412+ }))
413+ self.assertThat(recorder, HasQueryCount(Equals(2)))
414+
415+ def test_findFrom_types(self):
416+ # findFrom can look for only particular types of related
417+ # objects.
418+ getUtility(IXRefSet).create({
419+ ('a', '1'): {('a', '2'): {}, ('b', '3'): {}},
420+ ('b', '4'): {('a', '5'): {}, ('c', '6'): {}},
421+ })
422+ self.assertContentEqual(
423+ [('a', '2')],
424+ getUtility(IXRefSet).findFrom(('a', '1'), types=['a', 'c']).keys())
425+ self.assertContentEqual(
426+ [('a', '5'), ('c', '6')],
427+ getUtility(IXRefSet).findFrom(('b', '4'), types=['a', 'c']).keys())
428+
429+ # Asking for no types or types that don't exist finds nothing.
430+ self.assertContentEqual(
431+ [],
432+ getUtility(IXRefSet).findFrom(('b', '4'), types=[]).keys())
433+ self.assertContentEqual(
434+ [],
435+ getUtility(IXRefSet).findFrom(('b', '4'), types=['d']).keys())
436+
437+ def test_findFromMany_none(self):
438+ self.assertEqual({}, getUtility(IXRefSet).findFromMany([]))
439+
440+ def test_delete(self):
441+ getUtility(IXRefSet).create({
442+ ('a', 'bar'): {('b', 'foo'): {}},
443+ ('b', 'foo'): {('a', 'baz'): {}},
444+ })
445+ self.assertContentEqual(
446+ [('a', 'bar'), ('a', 'baz')],
447+ getUtility(IXRefSet).findFrom(('b', 'foo')).keys())
448+ with StormStatementRecorder() as recorder:
449+ getUtility(IXRefSet).delete({('b', 'foo'): [('a', 'bar')]})
450+ self.assertThat(recorder, HasQueryCount(Equals(1)))
451+ self.assertEqual(
452+ [('a', 'baz')],
453+ getUtility(IXRefSet).findFrom(('b', 'foo')).keys())
454
455=== modified file 'lib/lp/testing/tests/test_standard_test_template.py'
456--- lib/lp/testing/tests/test_standard_test_template.py 2015-01-30 10:13:51 +0000
457+++ lib/lp/testing/tests/test_standard_test_template.py 2015-09-28 23:38:07 +0000
458@@ -3,6 +3,8 @@
459
460 """XXX: Module docstring goes here."""
461
462+from __future__ import absolute_import, print_function, unicode_literals
463+
464 __metaclass__ = type
465
466 # or TestCaseWithFactory
467
468=== modified file 'lib/lp/testing/tests/test_standard_yuixhr_test_template.py'
469--- lib/lp/testing/tests/test_standard_yuixhr_test_template.py 2015-01-30 10:13:51 +0000
470+++ lib/lp/testing/tests/test_standard_yuixhr_test_template.py 2015-09-28 23:38:07 +0000
471@@ -4,6 +4,8 @@
472 """{Describe your test suite here}.
473 """
474
475+from __future__ import absolute_import, print_function, unicode_literals
476+
477 __metaclass__ = type
478 __all__ = []
479
480
481=== modified file 'standard_template.py'
482--- standard_template.py 2015-01-30 10:13:51 +0000
483+++ standard_template.py 2015-09-28 23:38:07 +0000
484@@ -3,5 +3,7 @@
485
486 """XXX: Module docstring goes here."""
487
488+from __future__ import absolute_import, print_function, unicode_literals
489+
490 __metaclass__ = type
491 __all__ = []