Add afterquery.exec(), which calculates a grid but doesn't render it.

This is like render() without the render() step.  We also add optional
'startdata' and 'done' parameters to both render() and exec().  If
startdata is provided, you don't need an url= as part of the query;
startdata is assumed to be the grid you want to use.  If done is provided,
we call it (with the latest grid) after the queue finishes running.

Now that we have exec(), it's much easier to unit test more of the
end-to-end transform and parsing code, so make a start at that.
diff --git a/render.js b/render.js
index 17b8a4d..49843de 100644
--- a/render.js
+++ b/render.js
@@ -61,7 +61,17 @@
 
 
   function parseArgs(query) {
-    var kvlist = query.substr(1).split('&');
+    var kvlist;
+    if (query.join) {
+      // user provided an array of 'key=value' strings
+      kvlist = query;
+    } else {
+      // assume user provided a single string
+      if (query[0] == '?' || query[0] == '#') {
+        query = query.substr(1);
+      }
+      kvlist = query.split('&');
+    }
     var out = {};
     var outlist = [];
     for (var i in kvlist) {
@@ -965,6 +975,11 @@
 
 
   function gridFromData(rawdata) {
+    if (rawdata && rawdata.headers && rawdata.data && rawdata.types) {
+      // already in grid format
+      return rawdata;
+    }
+
     var headers, data, types;
 
     var err;
@@ -1013,8 +1028,8 @@
     } else {
       // assume simple [[cols...]...] (two-dimensional array) format, where
       // the first row is the headers.
-      headers = rawdata.shift();
-      data = rawdata;
+      headers = rawdata[0];
+      data = rawdata.slice(1);
     }
     types = guessTypes(data);
     parseDates(data, types);
@@ -1109,7 +1124,11 @@
         transform(doExtractRegexp, argval);
       }
     }
+  }
 
+
+  function addRenderers(queue, args) {
+    var trace = args.get('trace');
     var chartops = args.get('chart');
     var t, datatable;
     var options = {};
@@ -1216,7 +1235,7 @@
   }
 
 
-  function finishQueue(queue, args) {
+  function finishQueue(queue, args, done) {
     var trace = args.get('trace');
     if (trace) {
       var prevdata;
@@ -1245,9 +1264,9 @@
           $('.vizstep').show();
         }
       };
-      runqueue(queue, null, null, showstatus, wrap, after_each);
+      runqueue(queue, null, done, showstatus, wrap, after_each);
     } else {
-      runqueue(queue, null, null, showstatus, wrap);
+      runqueue(queue, null, done, showstatus, wrap);
     }
   }
 
@@ -1410,19 +1429,22 @@
   }
 
 
-  function render(query) {
-    var args = parseArgs(query);
-    var url = args.get('url');
-    console.debug('original data url:', url);
-    if (!url) throw new Error('Missing url= in query parameter');
-    url = extendDataUrl(url);
-    showstatus('Loading <a href="' + encodeURI(url) + '">data</a>...');
+  function addUrlGetters(queue, args, startdata) {
+    if (!startdata) {
+      var url = args.get('url');
+      console.debug('original data url:', url);
+      if (!url) throw new Error('Missing url= in query parameter');
+      url = extendDataUrl(url);
+      showstatus('Loading <a href="' + encodeURI(url) + '">data</a>...');
 
-    var queue = [];
-
-    enqueue(queue, 'get data', function(_, done) {
-      getUrlData(url, wrap(done), wrap(gotError, url));
-    });
+      enqueue(queue, 'get data', function(_, done) {
+        getUrlData(url, wrap(done), wrap(gotError, url));
+      });
+    } else {
+      enqueue(queue, 'init data', function(_, done) {
+        done(startdata);
+      });
+    }
 
     enqueue(queue, 'parse', function(rawdata, done) {
       console.debug('rawdata:', rawdata);
@@ -1430,14 +1452,30 @@
       console.debug('grid:', outgrid);
       done(outgrid);
     });
+  }
 
+
+  function exec(query, startdata, done) {
+    var args = parseArgs(query);
+    var queue = [];
+    addUrlGetters(queue, args, startdata);
     addTransforms(queue, args);
+    runqueue(queue, startdata, done);
+  }
 
-    finishQueue(queue, args);
+
+  function render(query, startdata, done) {
+    var args = parseArgs(query);
     var editlink = args.get('editlink');
     if (editlink == 0) {
       $('#editmenu').hide();
     }
+
+    var queue = [];
+    addUrlGetters(queue, args, startdata);
+    addTransforms(queue, args);
+    addRenderers(queue, args);
+    finishQueue(queue, args, done);
   }
 
 
@@ -1462,6 +1500,7 @@
       gridFromData: gridFromData
     },
     parseArgs: parseArgs,
+    exec: exec,
     render: wrap(render)
   };
 })();
diff --git a/t/trender.js b/t/trender.js
index 78c5869..1dd9e17 100644
--- a/t/trender.js
+++ b/t/trender.js
@@ -40,7 +40,7 @@
 
   WVPASSEQ(afterquery.parseArgs('').all.join('|'), ',');
   WVPASSEQ(afterquery.parseArgs('?').all.join('|'), ',');
-  WVPASSEQ(afterquery.parseArgs('abc=def').all.join('|'), 'bc,def');
+  WVPASSEQ(afterquery.parseArgs('abc=def').all.join('|'), 'abc,def');
 });
 
 
@@ -160,3 +160,49 @@
   WVPASSEQ(afterquery.internal.urlMinusPath('//foo/blah//whatever'),
            '//foo');
 });
+
+
+function _gridAsText(grid) {
+  return [].concat(grid.headers, grid.types, grid.data);
+}
+
+
+wvtest('gridFromData', function() {
+  var rawdata = [
+    ['a', 'b', 'c'],
+    [1, 2, 3]
+  ];
+  var otherdata = [
+    ['a', 'b', 'c'],
+    [1, 2, 4]
+  ];
+  var grid = {
+    headers: ['a', 'b', 'c'],
+    data: [[1, 2, 3]],
+    types: ['boolean', 'number', 'number']
+  };
+  var gtext = _gridAsText(grid);
+  WVPASSEQ(_gridAsText(afterquery.internal.gridFromData(grid)), gtext);
+  WVPASSEQ(_gridAsText(afterquery.internal.gridFromData(rawdata)), gtext);
+  WVPASSNE(_gridAsText(afterquery.internal.gridFromData(otherdata)), gtext);
+});
+
+
+wvtest('exec', function() {
+  var rawdata = [
+    ['a', 'b', 'c'],
+    [1, 2, 3],
+    [1, 5, 6],
+    [1, 5, 9]
+  ];
+  afterquery.exec('group=a;', rawdata, function(grid) {
+    WVPASSEQ(grid.data, [[1]]);
+  });
+  afterquery.exec('group=a,b;count(c)&group=a', rawdata, function(grid) {
+    WVPASSEQ(grid.data, [[1, 7, 3]]);
+  });
+  afterquery.exec(['group=a,b;count(c)', 'pivot=a;b;c'], rawdata,
+		  function(grid) {
+    WVPASSEQ(grid.data, [[1, 1, 2]]);
+  });
+});