Merge lp:~wgrant/launchpad/xref-model into lp:launchpad
- xref-model
- Merge into devel
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 |
Related bugs: |
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:/
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__ = [] |