Merge ~cjwatson/launchpad:py3-sqlbase-hash into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: c342b8e621b2101942a6df18c744e2872a18584a
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:py3-sqlbase-hash
Merge into: launchpad:master
Diff against target: 105 lines (+49/-21)
2 files modified
lib/lp/services/database/sqlbase.py (+37/-13)
lib/lp/services/database/tests/test_bulk.py (+12/-8)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+394941@code.launchpad.net

Commit message

Define SQLBase.__hash__

Description of the change

On Python 2, SQLBase objects were hashable without this, but they broke the contract for __hash__, because objects from different stores with the same class and id would compare equal but have different hash values.

Python 3 made it more difficult to make this kind of mistake, by requiring that objects that define __eq__ must also define __hash__ in order to be hashable, so now we do so. However, the case of newly-created objects where we haven't yet got a sequence value back from the database requires some care. We can no longer work around this using object identity as was done by SQLBase.__eq__, since that would lead to the object's hash value changing once its id is known. Instead, we flush the store if we don't yet know the object's id; this is perhaps slightly surprising in a special method, but it seems the least bad approach.

To minimise confusion, also rework SQLBase.__eq__ to use the same flush-the-store-if-necessary approach rather than checking object identity.

This should fix a large number of tests on Python 3, which were typically failing with something like "TypeError: unhashable type: 'Person'". I'd thought that it might only be possible to do this by converting everything to Storm, but recently worked out how to fix it directly. Having worked this out, it may also be possible to add similar __eq__ and __hash__ methods to StormBase in a later branch.

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/services/database/sqlbase.py b/lib/lp/services/database/sqlbase.py
index 57fd27c..3942ba7 100644
--- a/lib/lp/services/database/sqlbase.py
+++ b/lib/lp/services/database/sqlbase.py
@@ -233,27 +233,51 @@ class SQLBase(storm.sqlobject.SQLObjectBase):
233 def __eq__(self, other):233 def __eq__(self, other):
234 """Equality operator.234 """Equality operator.
235235
236 Objects compare equal if:236 Objects compare equal if they have the same class and id, and the id
237 - They are the same instance, or237 is not None.
238 - They have the same class and id, and the id is not None.238
239239 This rule allows objects retrieved from different stores to compare
240 These rules allows objects retrieved from different stores to240 equal. Newly-created objects may not yet have an id; in such cases
241 compare equal. The 'is' comparison is to support newly created241 we flush the store so that we can find out their id.
242 objects that don't yet have an id (and by definition only exist
243 in the Master store).
244 """242 """
245 naked_self = removeSecurityProxy(self)243 naked_self = removeSecurityProxy(self)
246 naked_other = removeSecurityProxy(other)244 naked_other = removeSecurityProxy(other)
247 return (245 if naked_self.__class__ != naked_other.__class__:
248 (naked_self is naked_other)246 return False
249 or (naked_self.__class__ == naked_other.__class__247 try:
250 and naked_self.id is not None248 self_id = naked_self.id
251 and naked_self.id == naked_other.id))249 except KeyError:
250 self.syncUpdate()
251 self_id = naked_self.id
252 if self_id is None:
253 return False
254 try:
255 other_id = naked_other.id
256 except KeyError:
257 other.syncUpdate()
258 other_id = naked_other.id
259 return self_id == other_id
252260
253 def __ne__(self, other):261 def __ne__(self, other):
254 """Inverse of __eq__."""262 """Inverse of __eq__."""
255 return not (self == other)263 return not (self == other)
256264
265 def __hash__(self):
266 """Hash operator.
267
268 We must define __hash__ since we define __eq__ (Python 3 requires
269 this), but we need to take care to preserve the invariant that
270 objects that compare equal have the same hash value. Newly-created
271 objects may not yet have an id; in such cases we flush the store so
272 that we can find out their id.
273 """
274 try:
275 id = self.id
276 except KeyError:
277 self.syncUpdate()
278 id = self.id
279 return hash((self.__class__, id))
280
257 def __storm_invalidated__(self):281 def __storm_invalidated__(self):
258 """Flush cached properties."""282 """Flush cached properties."""
259 # XXX: RobertCollins 2010-08-16 bug=622648: Note this is not directly283 # XXX: RobertCollins 2010-08-16 bug=622648: Note this is not directly
diff --git a/lib/lp/services/database/tests/test_bulk.py b/lib/lp/services/database/tests/test_bulk.py
index 76b0471..1c93654 100644
--- a/lib/lp/services/database/tests/test_bulk.py
+++ b/lib/lp/services/database/tests/test_bulk.py
@@ -135,19 +135,23 @@ class TestLoaders(TestCaseWithFactory):
135 # Commit so the database object is available in both master135 # Commit so the database object is available in both master
136 # and slave stores.136 # and slave stores.
137 transaction.commit()137 transaction.commit()
138 db_objects = set(138 # Use a list, since objects corresponding to the same DB row from
139 (IMasterStore(db_object).get(db_object_type, db_object.id),139 # different stores compare equal.
140 ISlaveStore(db_object).get(db_object_type, db_object.id)))140 db_objects = [
141 IMasterStore(db_object).get(db_object_type, db_object.id),
142 ISlaveStore(db_object).get(db_object_type, db_object.id),
143 ]
144 db_object_ids = {id(obj) for obj in db_objects}
141 db_queries = list(bulk.gen_reload_queries(db_objects))145 db_queries = list(bulk.gen_reload_queries(db_objects))
142 self.assertEqual(2, len(db_queries))146 self.assertEqual(2, len(db_queries))
143 db_objects_loaded = set()147 db_object_ids_loaded = set()
144 for db_query in db_queries:148 for db_query in db_queries:
145 objects = set(db_query)149 object_ids = {id(obj) for obj in db_query}
146 # None of these objects should have been loaded before.150 # None of these objects should have been loaded before.
147 self.assertEqual(151 self.assertEqual(
148 set(), objects.intersection(db_objects_loaded))152 set(), object_ids.intersection(db_object_ids_loaded))
149 db_objects_loaded.update(objects)153 db_object_ids_loaded.update(object_ids)
150 self.assertEqual(db_objects, db_objects_loaded)154 self.assertEqual(db_object_ids, db_object_ids_loaded)
151155
152 def test_gen_reload_queries_with_non_Storm_objects(self):156 def test_gen_reload_queries_with_non_Storm_objects(self):
153 # gen_reload_queries() does not like non-Storm objects.157 # gen_reload_queries() does not like non-Storm objects.

Subscribers

People subscribed via source and target branches

to status/vote changes: