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
1diff --git a/lib/lp/services/database/sqlbase.py b/lib/lp/services/database/sqlbase.py
2index 57fd27c..3942ba7 100644
3--- a/lib/lp/services/database/sqlbase.py
4+++ b/lib/lp/services/database/sqlbase.py
5@@ -233,27 +233,51 @@ class SQLBase(storm.sqlobject.SQLObjectBase):
6 def __eq__(self, other):
7 """Equality operator.
8
9- Objects compare equal if:
10- - They are the same instance, or
11- - They have the same class and id, and the id is not None.
12-
13- These rules allows objects retrieved from different stores to
14- compare equal. The 'is' comparison is to support newly created
15- objects that don't yet have an id (and by definition only exist
16- in the Master store).
17+ Objects compare equal if they have the same class and id, and the id
18+ is not None.
19+
20+ This rule allows objects retrieved from different stores to compare
21+ equal. Newly-created objects may not yet have an id; in such cases
22+ we flush the store so that we can find out their id.
23 """
24 naked_self = removeSecurityProxy(self)
25 naked_other = removeSecurityProxy(other)
26- return (
27- (naked_self is naked_other)
28- or (naked_self.__class__ == naked_other.__class__
29- and naked_self.id is not None
30- and naked_self.id == naked_other.id))
31+ if naked_self.__class__ != naked_other.__class__:
32+ return False
33+ try:
34+ self_id = naked_self.id
35+ except KeyError:
36+ self.syncUpdate()
37+ self_id = naked_self.id
38+ if self_id is None:
39+ return False
40+ try:
41+ other_id = naked_other.id
42+ except KeyError:
43+ other.syncUpdate()
44+ other_id = naked_other.id
45+ return self_id == other_id
46
47 def __ne__(self, other):
48 """Inverse of __eq__."""
49 return not (self == other)
50
51+ def __hash__(self):
52+ """Hash operator.
53+
54+ We must define __hash__ since we define __eq__ (Python 3 requires
55+ this), but we need to take care to preserve the invariant that
56+ objects that compare equal have the same hash value. Newly-created
57+ objects may not yet have an id; in such cases we flush the store so
58+ that we can find out their id.
59+ """
60+ try:
61+ id = self.id
62+ except KeyError:
63+ self.syncUpdate()
64+ id = self.id
65+ return hash((self.__class__, id))
66+
67 def __storm_invalidated__(self):
68 """Flush cached properties."""
69 # XXX: RobertCollins 2010-08-16 bug=622648: Note this is not directly
70diff --git a/lib/lp/services/database/tests/test_bulk.py b/lib/lp/services/database/tests/test_bulk.py
71index 76b0471..1c93654 100644
72--- a/lib/lp/services/database/tests/test_bulk.py
73+++ b/lib/lp/services/database/tests/test_bulk.py
74@@ -135,19 +135,23 @@ class TestLoaders(TestCaseWithFactory):
75 # Commit so the database object is available in both master
76 # and slave stores.
77 transaction.commit()
78- db_objects = set(
79- (IMasterStore(db_object).get(db_object_type, db_object.id),
80- ISlaveStore(db_object).get(db_object_type, db_object.id)))
81+ # Use a list, since objects corresponding to the same DB row from
82+ # different stores compare equal.
83+ db_objects = [
84+ IMasterStore(db_object).get(db_object_type, db_object.id),
85+ ISlaveStore(db_object).get(db_object_type, db_object.id),
86+ ]
87+ db_object_ids = {id(obj) for obj in db_objects}
88 db_queries = list(bulk.gen_reload_queries(db_objects))
89 self.assertEqual(2, len(db_queries))
90- db_objects_loaded = set()
91+ db_object_ids_loaded = set()
92 for db_query in db_queries:
93- objects = set(db_query)
94+ object_ids = {id(obj) for obj in db_query}
95 # None of these objects should have been loaded before.
96 self.assertEqual(
97- set(), objects.intersection(db_objects_loaded))
98- db_objects_loaded.update(objects)
99- self.assertEqual(db_objects, db_objects_loaded)
100+ set(), object_ids.intersection(db_object_ids_loaded))
101+ db_object_ids_loaded.update(object_ids)
102+ self.assertEqual(db_object_ids, db_object_ids_loaded)
103
104 def test_gen_reload_queries_with_non_Storm_objects(self):
105 # gen_reload_queries() does not like non-Storm objects.

Subscribers

People subscribed via source and target branches

to status/vote changes: