================ Batch Navigation ================ Most of the test for this behavior is in the lazr.batchnavigation package. This documents and tests the Launchpad-specific elements of its usage. Note that our use of the batching code relies on the registration of canonical.launchpad.webapp.batching.FiniteSequenceAdapter for storm.zope.interfaces.IResultSet and storm.zope.interfaces.ISQLObjectResultSet. Batch navigation provides a way to navigate batch results in a web page by providing URL links to the next, previous and numbered pages of results. It uses two arguments to control the batching: - start: The first item we should show in current batch. - batch: Controls the amount of items we are showing per batch. It will only appear if it's different from the default value set when the batch is created. Imports: >>> from canonical.launchpad.webapp.batching import BatchNavigator >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest >>> def build_request(query_string_args=None, method='GET'): ... if query_string_args is None: ... query_string = '' ... else: ... query_string = "&".join( ... ["%s=%s" % (k,v) for k,v in query_string_args.items()]) ... request = LaunchpadTestRequest(SERVER_URL='http://www.example.com/foo', ... method=method, ... environ={'QUERY_STRING': query_string}) ... request.processInputs() ... return request A dummy request object: Some sample data. >>> reindeer = ['Dasher', 'Dancer', 'Prancer', 'Vixen', 'Comet', ... 'Cupid', 'Donner', 'Blitzen', 'Rudolph'] Performance with SQLObject ========================== This section demonstrates that batching generates sensible SQL queries when used with SQLObject, i.e. that it puts appropriate LIMIT clauses on queries. Imports and initialization: >>> from lp.testing.pgsql import CursorWrapper >>> from canonical.launchpad.database.emailaddress import EmailAddress >>> from canonical.launchpad.interfaces.lpstorm import IStore >>> ignore = IStore(EmailAddress) # Prime the database connection. ... # (Priming is important if this test is run in isolation). >>> CursorWrapper.record_sql = True Prepare a query, and create a batch of the results: >>> select_results = EmailAddress.select(orderBy='id') >>> batch_nav = BatchNavigator(select_results, build_request(), size=10) >>> email_batch = batch_nav.currentBatch() >>> batch_items = list(email_batch) Because we're only looking at the first batch, the database is only asked for the first 11 rows. (lazr.batchnavigator asks for 11 instead of 10 so that it can reliably detect the end of the dataset). >>> len(CursorWrapper.last_executed_sql) 1 >>> print CursorWrapper.last_executed_sql[0] SELECT ... FROM EmailAddress ... LIMIT 11... Get the next 10. The database is only asked for the next 11 rows: >>> CursorWrapper.last_executed_sql = [] >>> email_batch2 = email_batch.nextBatch() >>> batch_items = list(email_batch2) >>> len(CursorWrapper.last_executed_sql) 1 >>> CursorWrapper.last_executed_sql[0].endswith('LIMIT 11 OFFSET 10') True As seen above, simply accessing the batch doesn't trigger a SQL query asking for the length of the entire resultset. But explicitly asking for the length will trigger a SQL query in most circumstances. >>> CursorWrapper.last_executed_sql = [] >>> ignored = email_batch.total() >>> print CursorWrapper.last_executed_sql[0] SELECT COUNT(*) FROM EmailAddress There are exceptions. When the current batch is the last one in the list, it's possible to get the length of the entire resultset without triggering a COUNT query. >>> CursorWrapper.last_executed_sql = [] >>> batch_nav = BatchNavigator(select_results, build_request(), size=50) >>> final_batch = batch_nav.currentBatch().nextBatch() >>> batch_items = list(final_batch) >>> ignored = final_batch.total() >>> print "\n".join(CursorWrapper.last_executed_sql) SELECT ... FROM EmailAddress ... OFFSET 0 SELECT ... FROM EmailAddress ... OFFSET 50 When the current batch contains the entire resultset, it's possible to get the length of the resultset without triggering a COUNT query. >>> CursorWrapper.last_executed_sql = [] >>> one_page_nav = BatchNavigator(select_results, build_request(), size=200) >>> only_batch = one_page_nav.currentBatch() >>> batch_items = list(only_batch) >>> ignored = only_batch.total() >>> print "\n".join(CursorWrapper.last_executed_sql) SELECT ... FROM EmailAddress ... OFFSET 0 Multiple pages ============== The batch navigator tells us whether multiple pages will be used. >>> batch_nav.has_multiple_pages True >>> one_page_nav.has_multiple_pages False Maximum batch size ================== Since the batch size is exposed in the URL, it's possible for users to tweak the batch parameter to retrieve more results. Since that may potentially exhaust server resources, an upper limit is put on the batch size. If the requested batch parameter is higher than this, an InvalidBatchSizeError is raised. >>> from canonical.config import config >>> from textwrap import dedent >>> config.push('max-batch-size', dedent("""\ ... [launchpad] ... max_batch_size: 5 ... """)) >>> request = build_request({"start": "0", "batch": "20"}) >>> BatchNavigator(reindeer, request=request ) Traceback (most recent call last): ... InvalidBatchSizeError: Maximum for "batch" parameter is 5. >>> ignored = config.pop('max-batch-size') Batch views =========== A view is often used with a BatchNavigator to determine when to display the current batch. If the current batch is empty, nothing is rendered for the upper and lower navigation link views. >>> from zope.component import getMultiAdapter >>> request = build_request({"start": "0", "batch": "10"}) >>> navigator = BatchNavigator([], request=request) >>> upper_view = getMultiAdapter( ... (navigator, request), name='+navigation-links-upper') >>> upper_view.render() u'' >>> lower_view = getMultiAdapter( ... (navigator, request), name='+navigation-links-lower') >>> lower_view.render() u'' When there is a current batch, but there are no previous or next batches, only the upper navigation links view will render. >>> navigator = BatchNavigator(reindeer, request=request) >>> upper_view = getMultiAdapter( ... (navigator, request), name='+navigation-links-upper') >>> print upper_view.render() 1...→...9...of 9 results... ......First... ......Previous... ......Next... ......Last... >>> lower_view = getMultiAdapter( ... (navigator, request), name='+navigation-links-lower') >>> lower_view.render() u''