← Back to team overview

launchpad-reviewers team mailing list archive

Re: [Merge] lp:~wgrant/launchpad/xref-model into lp:launchpad

 

Review: Approve



Diff comments:

> 
> === added file 'lib/lp/services/xref/model.py'
> --- lib/lp/services/xref/model.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/services/xref/model.py	2015-09-28 12:44:55 +0000
> @@ -0,0 +1,131 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +from __future__ import absolute_import, print_function, unicode_literals
> +
> +__metaclass__ = type
> +__all__ = [
> +    "XRefSet",
> +    ]
> +
> +import pytz
> +from storm.expr import (
> +    And,
> +    Or,
> +    )
> +from storm.properties import (
> +    DateTime,
> +    Int,
> +    JSON,
> +    Unicode,
> +    )
> +from storm.references import Reference
> +from zope.interface import implementer
> +
> +from lp.services.database import bulk
> +from lp.services.database.constants import UTC_NOW
> +from lp.services.database.interfaces import IStore
> +from lp.services.database.stormbase import StormBase
> +from lp.services.xref.interfaces import IXRefSet
> +
> +
> +class XRef(StormBase):
> +    """Cross-reference between two objects.
> +
> +    For references to local objects (there is currently no other kind),
> +    another reference in the opposite direction exists.
> +
> +    The to_id_int and from_id_int columns exist for efficient SQL joins.
> +    They are set automatically when the ID looks like an integer.
> +    """
> +
> +    __storm_table__ = 'XRef'
> +    __storm_primary__ = "to_type", "to_id", "from_type", "from_id"

Is it significant that this is in a different order from the PRIMARY KEY declaration in the DB patch?  I'm guessing not, but I'd probably make them match.

> +
> +    to_type = Unicode(allow_none=False)
> +    to_id = Unicode(allow_none=False)
> +    to_id_int = Int()  # For efficient joins.
> +    from_type = Unicode(allow_none=False)
> +    from_id = Unicode(allow_none=False)
> +    from_id_int = Int()  # For efficient joins.
> +    creator_id = Int(name="creator")
> +    creator = Reference(creator_id, "Person.id")
> +    date_created = DateTime(name='date_created', tzinfo=pytz.UTC)
> +    metadata = JSON()
> +
> +
> +def _int_or_none(s):
> +    if s.isdigit():
> +        return int(s)
> +    else:
> +        return None
> +
> +
> +@implementer(IXRefSet)
> +class XRefSet:
> +
> +    def create(self, xrefs):
> +        # All references are currently to local objects, so add
> +        # backlinks as well to keep queries in both directions quick.
> +        # The *_id_int columns are also set if the ID looks like an int.
> +        rows = []
> +        for from_, tos in xrefs.items():
> +            for to, props in tos.items():
> +                rows.append((
> +                    from_[0], from_[1], _int_or_none(from_[1]),
> +                    to[0], to[1], _int_or_none(to[1]),
> +                    props.get('creator'), props.get('date_created', UTC_NOW),
> +                    props.get('metadata')))
> +                rows.append((
> +                    to[0], to[1], _int_or_none(to[1]),
> +                    from_[0], from_[1], _int_or_none(from_[1]),
> +                    props.get('creator'), props.get('date_created', UTC_NOW),
> +                    props.get('metadata')))
> +        bulk.create(
> +            (XRef.from_type, XRef.from_id, XRef.from_id_int,
> +             XRef.to_type, XRef.to_id, XRef.to_id_int,
> +             XRef.creator, XRef.date_created, XRef.metadata), rows)
> +
> +    def delete(self, xrefs):
> +        # Delete both directions.
> +        pairs = []
> +        for from_, tos in xrefs.items():
> +            for to in tos:
> +                pairs.extend([(from_, to), (to, from_)])
> +
> +        IStore(XRef).find(
> +            XRef,
> +            Or(*[
> +                And(XRef.from_type == pair[0][0],
> +                    XRef.from_id == pair[0][1],
> +                    XRef.to_type == pair[1][0],
> +                    XRef.to_id == pair[1][1])
> +                for pair in pairs])
> +            ).remove()
> +
> +    def findFromMany(self, object_ids, types=None):
> +        from lp.registry.model.person import Person
> +
> +        object_ids = list(object_ids)
> +        if not object_ids:
> +            return {}
> +
> +        store = IStore(XRef)
> +        rows = list(store.using(XRef).find(
> +            (XRef.from_type, XRef.from_id, XRef.to_type, XRef.to_id,
> +             XRef.creator_id, XRef.date_created, XRef.metadata),
> +            Or(*[
> +                And(XRef.from_type == id[0], XRef.from_id == id[1])
> +                for id in object_ids]),

Shouldn't this check XRef.from_id_int instead if id.isdigit() is true?  Otherwise you seem to have nothing that actually uses the *_int columns.

> +            XRef.to_type.is_in(types) if types is not None else True))
> +        bulk.load(Person, [row[4] for row in rows])
> +        result = {}
> +        for row in rows:
> +            result.setdefault((row[0], row[1]), {})[(row[2], row[3])] = {

collections.defaultdict would make this a bit more readable.

> +                "creator": store.get(Person, row[4]) if row[4] else None,
> +                "date_created": row[5],
> +                "metadata": row[6]}
> +        return result
> +
> +    def findFrom(self, object_id, types=None):
> +        return self.findFromMany([object_id], types=types).get(object_id, {})
> 
> === added file 'lib/lp/services/xref/tests/test_model.py'
> --- lib/lp/services/xref/tests/test_model.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/services/xref/tests/test_model.py	2015-09-28 12:44:55 +0000
> @@ -0,0 +1,159 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +from __future__ import absolute_import, print_function, unicode_literals
> +
> +__metaclass__ = type
> +
> +import datetime
> +
> +import pytz
> +from testtools.matchers import Equals
> +from zope.component import getUtility
> +
> +from lp.services.database.interfaces import IStore
> +from lp.services.xref.interfaces import IXRefSet
> +from lp.services.xref.model import XRef
> +from lp.testing import (
> +    StormStatementRecorder,
> +    TestCaseWithFactory,
> +    )
> +from lp.testing.layers import DatabaseFunctionalLayer
> +from lp.testing.matchers import HasQueryCount
> +
> +
> +class TestXRefSet(TestCaseWithFactory):
> +
> +    layer = DatabaseFunctionalLayer
> +
> +    def test_create_sets_date_created(self):
> +        # date_created defaults to now, but can be overridden.
> +        old = datetime.datetime.strptime('2005-01-01', '%Y-%m-%d').replace(
> +            tzinfo=pytz.UTC)
> +        now = IStore(XRef).execute(
> +            "SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
> +            ).get_one()[0].replace(tzinfo=pytz.UTC)
> +        getUtility(IXRefSet).create({
> +            ('a', '1'): {('b', 'foo'): {}},
> +            ('a', '2'): {('b', 'bar'): {'date_created': old}}})
> +        rows = IStore(XRef).find(
> +            (XRef.from_id, XRef.to_id, XRef.date_created),
> +            XRef.from_type == 'a')
> +        self.assertContentEqual(
> +            [('1', 'foo', now), ('2', 'bar', old)], rows)
> +
> +    def test_create_sets_int_columns(self):
> +        # The string ID columns have integers equivalents for quick and
> +        # easy joins to integer PKs. They're set automatically when the
> +        # string ID looks like an integer.
> +        getUtility(IXRefSet).create({
> +            ('a', '1234'): {('b', 'foo'): {}, ('b', '2468'): {}},
> +            ('a', '12ab'): {('b', '1234'): {}, ('b', 'foo'): {}}})
> +        rows = IStore(XRef).find(
> +            (XRef.from_type, XRef.from_id, XRef.from_id_int, XRef.to_type,
> +             XRef.to_id, XRef.to_id_int),
> +            XRef.from_type == 'a')
> +        self.assertContentEqual(
> +            [('a', '1234', 1234, 'b', 'foo', None),
> +             ('a', '1234', 1234, 'b', '2468', 2468),
> +             ('a', '12ab', None, 'b', '1234', 1234),
> +             ('a', '12ab', None, 'b', 'foo', None)
> +             ],
> +            rows)
> +
> +    def test_findFrom(self):
> +        creator = self.factory.makePerson()
> +        now = IStore(XRef).execute(
> +            "SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
> +            ).get_one()[0].replace(tzinfo=pytz.UTC)
> +        getUtility(IXRefSet).create({
> +            ('a', 'bar'): {
> +                ('b', 'foo'): {'creator': creator, 'metadata': {'test': 1}}},
> +            ('b', 'foo'): {
> +                ('a', 'baz'): {'creator': creator, 'metadata': {'test': 2}}},
> +            })

I'd explicitly test with different creators somewhere around here to ensure that the bulk-loading of Person rows works.

> +
> +        with StormStatementRecorder() as recorder:
> +            bar_refs = getUtility(IXRefSet).findFrom(('a', 'bar'))
> +        self.assertThat(recorder, HasQueryCount(Equals(2)))
> +        self.assertEqual(
> +            {('b', 'foo'): {
> +                'creator': creator, 'date_created': now,
> +                'metadata': {'test': 1}}},
> +            bar_refs)
> +
> +        with StormStatementRecorder() as recorder:
> +            foo_refs = getUtility(IXRefSet).findFrom(('b', 'foo'))
> +        self.assertThat(recorder, HasQueryCount(Equals(2)))
> +        self.assertEqual(
> +            {('a', 'bar'): {
> +                'creator': creator, 'date_created': now,
> +                'metadata': {'test': 1}},
> +             ('a', 'baz'): {
> +                 'creator': creator, 'date_created': now,
> +                 'metadata': {'test': 2}}},
> +            foo_refs)
> +
> +        with StormStatementRecorder() as recorder:
> +            bar_refs = getUtility(IXRefSet).findFrom(('a', 'baz'))
> +        self.assertThat(recorder, HasQueryCount(Equals(2)))
> +        self.assertEqual(
> +            {('b', 'foo'): {
> +                'creator': creator, 'date_created': now,
> +                'metadata': {'test': 2}}},
> +            bar_refs)
> +
> +        with StormStatementRecorder() as recorder:
> +            bar_baz_refs = getUtility(IXRefSet).findFromMany(
> +                [('a', 'bar'), ('a', 'baz')])
> +        self.assertThat(recorder, HasQueryCount(Equals(2)))
> +        self.assertEqual(
> +            {('a', 'bar'): {
> +                ('b', 'foo'): {
> +                    'creator': creator, 'date_created': now,
> +                    'metadata': {'test': 1}}},
> +             ('a', 'baz'): {
> +                ('b', 'foo'): {
> +                    'creator': creator, 'date_created': now,
> +                    'metadata': {'test': 2}}}},
> +             bar_baz_refs)
> +
> +    def test_findFrom_types(self):
> +        # findFrom can look for only particular types of related
> +        # objects.
> +        getUtility(IXRefSet).create({
> +            ('a', '1'): {('a', '2'): {}, ('b', '3'): {}},
> +            ('b', '4'): {('a', '5'): {}, ('c', '6'): {}},
> +            })
> +        self.assertContentEqual(
> +            [('a', '2')],
> +            getUtility(IXRefSet).findFrom(('a', '1'), types=['a', 'c']).keys())
> +        self.assertContentEqual(
> +            [('a', '5'), ('c', '6')],
> +            getUtility(IXRefSet).findFrom(('b', '4'), types=['a', 'c']).keys())
> +
> +        # Asking for no types or types that don't exist finds nothing.
> +        self.assertContentEqual(
> +            [],
> +            getUtility(IXRefSet).findFrom(('b', '4'), types=[]).keys())
> +        self.assertContentEqual(
> +            [],
> +            getUtility(IXRefSet).findFrom(('b', '4'), types=['d']).keys())
> +
> +    def test_findFromMany_none(self):
> +        self.assertEqual({}, getUtility(IXRefSet).findFromMany([]))
> +
> +    def test_delete(self):
> +        getUtility(IXRefSet).create({
> +            ('a', 'bar'): {('b', 'foo'): {}},
> +            ('b', 'foo'): {('a', 'baz'): {}},
> +            })
> +        self.assertContentEqual(
> +            [('a', 'bar'), ('a', 'baz')],
> +            getUtility(IXRefSet).findFrom(('b', 'foo')).keys())
> +        with StormStatementRecorder() as recorder:
> +            getUtility(IXRefSet).delete({('b', 'foo'): [('a', 'bar')]})
> +        self.assertThat(recorder, HasQueryCount(Equals(1)))
> +        self.assertEqual(
> +            [('a', 'baz')],
> +            getUtility(IXRefSet).findFrom(('b', 'foo')).keys())


-- 
https://code.launchpad.net/~wgrant/launchpad/xref-model/+merge/272588
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.


References