launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #06448
[Merge] lp:~wgrant/launchpad/bulk-insert into lp:launchpad
William Grant has proposed merging lp:~wgrant/launchpad/bulk-insert into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~wgrant/launchpad/bulk-insert/+merge/93930
This branch adds lp.services.database.bulk.create(), a helper to insert multiple DB rows in a single statement.
It touches some of Storm's privates in inappropriate ways in order to handle References in a pretty (and typesafe) manner. Reference columns and values have to be flattened into (potentially compound) primary keys, and the methods to do that are private.
--
https://code.launchpad.net/~wgrant/launchpad/bulk-insert/+merge/93930
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/bulk-insert into lp:launchpad.
=== modified file 'lib/lp/services/database/bulk.py'
--- lib/lp/services/database/bulk.py 2012-02-20 07:11:22 +0000
+++ lib/lp/services/database/bulk.py 2012-02-21 08:14:20 +0000
@@ -5,6 +5,7 @@
__metaclass__ = type
__all__ = [
+ 'create',
'load',
'load_referencing',
'load_related',
@@ -13,14 +14,24 @@
from collections import defaultdict
+from itertools import chain
from functools import partial
-from operator import attrgetter
+from operator import (
+ attrgetter,
+ itemgetter,
+ )
+from storm.databases.postgres import Returning
from storm.expr import (
And,
+ Insert,
Or,
)
-from storm.info import get_cls_info
+from storm.info import (
+ get_cls_info,
+ get_obj_info,
+ )
+from storm.references import Reference
from storm.store import Store
from zope.security.proxy import removeSecurityProxy
@@ -153,3 +164,61 @@
for owning_object in owning_objects:
keys.update(map(partial(getattr, owning_object), foreign_keys))
return load(object_type, keys)
+
+
+def _dbify_value(col, val):
+ """Convert a value into a form that Storm can compile directly."""
+ if isinstance(col, Reference):
+ # References are mainly meant to be used as descriptors, so we
+ # have to perform a bit of evil here to turn the (potentially
+ # None) value into a sequence of primary key values.
+ if val is None:
+ return (None,) * len(col._relation._get_local_columns(col._cls))
+ else:
+ return col._relation.get_remote_variables(
+ get_obj_info(val).get_obj())
+ else:
+ return (col.variable_factory(value=val),)
+
+
+def _dbify_column(col):
+ """Convert a column into a form that Storm can compile directly."""
+ if isinstance(col, Reference):
+ # References are mainly meant to be used as descriptors, so we
+ # haver to perform a bit of evil here to turn the column into
+ # a sequence of primary key columns.
+ return col._relation._get_local_columns(col._cls)
+ else:
+ return (col,)
+
+
+def create(columns, values):
+ """Create a large number of objects efficiently.
+
+ :param cols: The Storm columns to insert values into. Must be from a
+ single class.
+ :param values: A list of lists of values for the columns.
+ :return: A list of the created objects.
+ """
+ # Flatten Reference faux-columns into their primary keys.
+ db_cols = list(chain.from_iterable(map(_dbify_column, columns)))
+ clses = set(col.cls for col in db_cols)
+ assert len(clses) == 1
+ [cls] = clses
+ primary_key = get_cls_info(cls).primary_key
+
+ # Mangle our value list into compilable values. Normal columns just
+ # get passed through the variable factory, while References get
+ # squashed into primary key variables.
+ db_values = [
+ list(chain.from_iterable(
+ _dbify_value(col, val) for col, val in zip(columns, value)))
+ for value in values]
+
+ result = IStore(cls).execute(
+ Returning(Insert(
+ db_cols, expr=db_values, primary_columns=primary_key)))
+ if len(primary_key) == 1:
+ return load(cls, map(itemgetter(0), result))
+ else:
+ return load(cls, result)
=== modified file 'lib/lp/services/database/tests/test_bulk.py'
--- lib/lp/services/database/tests/test_bulk.py 2012-02-20 07:11:22 +0000
+++ lib/lp/services/database/tests/test_bulk.py 2012-02-21 08:14:20 +0000
@@ -5,16 +5,26 @@
__metaclass__ = type
+import datetime
+
+from pytz import UTC
from storm.exceptions import ClassInfoError
from storm.info import get_obj_info
from storm.store import Store
+from testtools.matchers import Equals
import transaction
from zope.security import (
checker,
proxy,
)
+from lp.bugs.enum import BugNotificationLevel
from lp.bugs.model.bug import BugAffectsPerson
+from lp.bugs.model.bugsubscription import BugSubscription
+from lp.code.model.branchjob import (
+ BranchJob,
+ BranchJobType,
+ )
from lp.code.model.branchsubscription import BranchSubscription
from lp.registry.model.person import Person
from lp.services.database import bulk
@@ -27,12 +37,15 @@
FeatureFlag,
getFeatureStore,
)
+from lp.services.job.model.job import Job
from lp.soyuz.model.component import Component
from lp.testing import (
+ StormStatementRecorder,
TestCase,
TestCaseWithFactory,
)
from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.matchers import HasQueryCount
object_is_key = lambda thing: thing
@@ -219,9 +232,60 @@
self.factory.makeBranch(),
self.factory.makeBranch(),
]
- expected = set(list(owned_objects[0].subscriptions) +
+ expected = set(list(owned_objects[0].subscriptions) +
list(owned_objects[1].subscriptions))
self.assertNotEqual(0, len(expected))
self.assertEqual(expected,
set(bulk.load_referencing(BranchSubscription, owned_objects,
['branchID'])))
+
+
+class TestCreate(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_references_and_enums(self):
+ # create() correctly compiles plain types, enums and references.
+ bug = self.factory.makeBug()
+ people = [self.factory.makePerson() for i in range(5)]
+
+ wanted = [
+ (bug, person, person, datetime.datetime.now(UTC),
+ BugNotificationLevel.LIFECYCLE)
+ for person in people]
+
+ with StormStatementRecorder() as recorder:
+ subs = bulk.create(
+ (BugSubscription.bug, BugSubscription.person,
+ BugSubscription.subscribed_by, BugSubscription.date_created,
+ BugSubscription.bug_notification_level),
+ wanted)
+
+ self.assertThat(recorder, HasQueryCount(Equals(2)))
+ self.assertContentEqual(
+ wanted,
+ ((sub.bug, sub.person, sub.subscribed_by, sub.date_created,
+ sub.bug_notification_level) for sub in subs))
+
+ def test_null_reference(self):
+ # create() handles None as a Reference value.
+ job = Job()
+ IStore(Job).add(job)
+ wanted = [(None, job, BranchJobType.RECLAIM_BRANCH_SPACE)]
+ [branchjob] = bulk.create(
+ (BranchJob.branch, BranchJob.job, BranchJob.job_type),
+ wanted)
+ self.assertEqual(
+ wanted, [(branchjob.branch, branchjob.job, branchjob.job_type)])
+
+ def test_fails_on_multiple_classes(self):
+ # create() only inserts into columns on a single class.
+ self.assertRaises(
+ AssertionError,
+ bulk.create, (BugSubscription.bug, BranchSubscription.branch), [])
+
+ def test_fails_on_reference_mismatch(self):
+ # create() handles Reference columns in a typesafe manner.
+ self.assertRaisesWithContent(
+ RuntimeError, "Property used in an unknown class",
+ bulk.create, (BugSubscription.bug,), [[self.factory.makeBranch()]])