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