Overhaul how we deal with related data in TKO -- test labels, test attributes, machine labels, and iteration results.

This has proven one of the trickiest areas of TKO.  The first foray into this area was machine label headers, an early feature request implemented in a pretty ad-hoc manner in spreadsheet view which allowed them to be used as header fields.  (Ironically, this was closest to the right approach on the server side, but I didn't appreciate it at the time.  The original client-side implementation was a mess.)  Next was filtering on test attributes and test labels, implemented with the include_labels, exclude_labels, include_attributes_where, and exclude_attributes_where options.  This server-side implementation supported filtering but not viewing, grouping or sorting at all.  Furthermore, even the filtering support was weak -- it only supporting ORing of inclusion requests and ANDing of exclusion requests.  The client-side implementation was still pretty messy but was moving towards correctness.  Finally, support was recently added for viewing iteration results in table view, but 
 grouping and filtering were excluded since they would've been very difficult to fit into the design.  This was again a limited server-side approach, though the client-side implementation continued improving, albeit still using the trouble "generator items" in the mutliple list selector widget.

When I started working on support for test iterations and attributes in TKO table view, I finally hit upon the right server-side approach: specify the attributes that you're interested in, have the server perform a separate JOIN for each one, so that there's now a new column for each one, NULL if the attribute didn't exist and having the attribute's value if it did.  Once it's created as a normal column, the user can do selection, grouping, sorting and filtering using the regular mechanisms.  Everything just works.  (For labels, it's slightly different, since whether or not a label is attached to a test is a boolean value.  I opted to have the column's value be either NULL or the name of the label.)

Well, not quite perfectly.  MySQL lets us define column aliases in a SELECT which are then usable in GROUP BY and ORDER BY.  They aren't however, usable in the WHERE clause, because certain select expressions may not exist at the time the WHERE is applied.  (Our expressions happen to be fine, but MySQL will have none of it.)  There's absolutely no way I can see to define aliases for use in the WHERE clause.  And unfortunately, our current interface allows users to provide a WHERE clause directly, so we can't perform translations or substitutions.  As a result, filtering must be performed a little differently for these fields.  You can't just say <field_name> = "<value>", like you can for most fields.  For test attributes and iteration results, you say <field_name>.value = "<value>".  For test labels and machine labels, you say <field_name>.id IS [NOT] NULL.

The first part of this CL is changing the server to use this new approach.  get_test_views() now accepts test_label_fields, test_attribute_fields, machine_label_fields, and iteration_result_fields parameters, which allow the user to add extra fields based on these data types.

At the same time, I've changed how the TKO web clients deals with these data types in a way that mirrors the new way of handling these features on the server.  There is now a global widget for adding custom fields based on any of the four data types.  Once one is created, it can be used just like any other field in spreadsheet view, table view, and the global condition.  This vastly simplifies most pieces of the code that previously dealt with these features, and it greatly expands the available space of features.  Where we formerly had spreadsheet grouping/filtering on machine labels, table viewing of iteration results, and limited filtering on test labels and attributes, we now have viewing, grouping sorting, and filtering on all four.

High-level changes involved:

Server side
* added code to TestViewManager to handle the new options for creating fields, documented them, and documented that these options are supported and the rest are deprecated (we can probably delete them but we should check, they might be in use)
* added thorough unit tests for all of the above.  on a side note, i discovered a neat feature of SQLite where you can add any function you've defined at a callable function from SQL statements.  I used this to add some functions emulating MySQL-only functions.  This could be used to good effect elsewhere, but this CL is big enough :)
* got rid of now-obsolete code for machine_label_headers option and iteration views

Client side:
* made HeaderFields immutable.  Mutable HeaderFields turned out to be way too much of a nightmare.  Users can specify values for ParameterizedFields at creation time, and if they want to modify them, they can delete and add.
* made all parts of the application (namely SpreadsheetView (both header selectors), TableView, and CommonPanel) use a single global HeaderFieldCollection
* changed ParameterizedFieldListPresenter to handle the new job of allowing creation and deletion of any kind of ParameterizedField.  This new widget replaces the label/attribute filtering widget in the CommonPanel -- I got rid of all the code for that widget.
* removed the now-obsolete code for "generator items" in the MultiListSelectPresenter.
* finally made TableView use HeaderSelect.  Since HeaderSelect plays a more significant role and it's role is more unified, it made sense to finally do this (TableView was previously duplicating logic from HeaderSelect, which was only used in SpreadsheetView).  Since the HeaderSelect used in TableView is much simpler than the one used in SpreadsheetView, I extracted a new class SpreadsheetHeaderSelect, using composition rather than inheritance (it didn't really follow an is-a relationship).

Signed-off-by: Steve Howard <showard@google.com>


git-svn-id: http://test.kernel.org/svn/autotest/trunk@4049 592f7852-d20e-0410-864c-8624ca9c26a4
diff --git a/frontend/afe/model_logic.py b/frontend/afe/model_logic.py
index a798416..c683699 100644
--- a/frontend/afe/model_logic.py
+++ b/frontend/afe/model_logic.py
@@ -9,6 +9,7 @@
 from django.utils import datastructures
 from autotest_lib.frontend.afe import readonly_connection
 
+_quote_name = connection.ops.quote_name
 
 class ValidationError(Exception):
     """\
@@ -115,7 +116,8 @@
             for join_alias, join in self._customSqlQ._joins.iteritems():
                 join_table, join_type, condition = join
                 join_clause += ' %s %s AS %s ON (%s)' % (
-                    join_type, join_table, join_alias, condition)
+                    join_type, _quote_name(join_table),
+                    _quote_name(join_alias), condition)
 
             if join_clause:
                 from_.append(join_clause)
@@ -158,24 +160,28 @@
         return query_set.filter(filter_object)
 
 
-    def add_join(self, query_set, join_table, join_key,
-                 join_condition='', suffix='', exclude=False,
-                 force_left_join=False):
+    def add_join(self, query_set, join_table, join_key, join_condition='',
+                 alias=None, suffix='', exclude=False, force_left_join=False):
         """
         Add a join to query_set.
         @param join_table table to join to
         @param join_key field referencing back to this model to use for the join
         @param join_condition extra condition for the ON clause of the join
-        @param suffix suffix to add to join_table for the join alias
+        @param alias alias to use for for join
+        @param suffix suffix to add to join_table for the join alias, if no
+                alias is provided
         @param exclude if true, exclude rows that match this join (will use a
         LEFT OUTER JOIN and an appropriate WHERE condition)
         @param force_left_join - if true, a LEFT OUTER JOIN will be used
         instead of an INNER JOIN regardless of other options
         """
-        join_from_table = self.model._meta.db_table
-        join_from_key = self.model._meta.pk.name
-        join_alias = join_table + suffix
-        full_join_key = join_alias + '.' + join_key
+        join_from_table = _quote_name(self.model._meta.db_table)
+        join_from_key = _quote_name(self.model._meta.pk.name)
+        if alias:
+            join_alias = alias
+        else:
+            join_alias = join_table + suffix
+        full_join_key = _quote_name(join_alias) + '.' + _quote_name(join_key)
         full_join_condition = '%s = %s.%s' % (full_join_key, join_from_table,
                                               join_from_key)
         if join_condition:
@@ -648,7 +654,7 @@
         sort_by = special_params.get('sort_by', None)
         if sort_by:
             assert isinstance(sort_by, list) or isinstance(sort_by, tuple)
-            query = query.order_by(*sort_by)
+            query = query.extra(order_by=sort_by)
 
         query_start = special_params.get('query_start', None)
         query_limit = special_params.get('query_limit', None)
diff --git a/frontend/client/src/autotest/common/SimpleChangeListener.java b/frontend/client/src/autotest/common/SimpleChangeListener.java
new file mode 100644
index 0000000..0e23d6c
--- /dev/null
+++ b/frontend/client/src/autotest/common/SimpleChangeListener.java
@@ -0,0 +1,5 @@
+package autotest.common;
+
+public interface SimpleChangeListener {
+    public void onChange(Object source);
+}
diff --git a/frontend/client/src/autotest/common/SimpleChangeListenerCollection.java b/frontend/client/src/autotest/common/SimpleChangeListenerCollection.java
new file mode 100644
index 0000000..4fdfc9d
--- /dev/null
+++ b/frontend/client/src/autotest/common/SimpleChangeListenerCollection.java
@@ -0,0 +1,23 @@
+package autotest.common;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SimpleChangeListenerCollection {
+    private Object source;
+    private List<SimpleChangeListener> listeners = new ArrayList<SimpleChangeListener>();
+
+    public SimpleChangeListenerCollection(Object source) {
+        this.source = source;
+    }
+
+    public void add(SimpleChangeListener listener) {
+        listeners.add(listener);
+    }
+
+    public void notifyListeners() {
+        for (SimpleChangeListener listener : listeners) {
+            listener.onChange(source);
+        }
+    }
+}
diff --git a/frontend/client/src/autotest/common/Utils.java b/frontend/client/src/autotest/common/Utils.java
index b791006..8e3548f 100644
--- a/frontend/client/src/autotest/common/Utils.java
+++ b/frontend/client/src/autotest/common/Utils.java
@@ -197,7 +197,12 @@
             return string.stringValue();
         }
         if ((number = value.isNumber()) != null) {
-            return Integer.toString((int) number.doubleValue());
+            double doubleValue = number.doubleValue();
+            if (doubleValue == (int) doubleValue) {
+                return Integer.toString((int) doubleValue);
+            }
+            return Double.toString(doubleValue);
+            
         }
         if (value.isNull() != null) {
             return JSON_NULL;
diff --git a/frontend/client/src/autotest/common/table/DataTable.java b/frontend/client/src/autotest/common/table/DataTable.java
index 373ed98..8fba258 100644
--- a/frontend/client/src/autotest/common/table/DataTable.java
+++ b/frontend/client/src/autotest/common/table/DataTable.java
@@ -1,6 +1,7 @@
 package autotest.common.table;
 
 
+import autotest.common.Utils;
 import autotest.common.ui.RightClickTable;
 
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -137,17 +138,6 @@
      */
     protected void preprocessRow(JSONObject row) {}
     
-    protected String getTextForValue(JSONValue value) {
-        if (value == null || value.isNull() != null)
-            return "";
-        else if (value.isNumber() != null)
-            return Integer.toString((int) value.isNumber().doubleValue());
-        else if (value.isString() != null)
-            return  value.isString().stringValue();
-        else
-            throw new IllegalArgumentException(value.toString());
-    }
-    
     protected String[] getRowText(JSONObject row) {
         String[] rowText = new String[columns.length];
         for (int i = 0; i < columns.length; i++) {
@@ -156,7 +146,7 @@
             
             String columnKey = columns[i][0];
             JSONValue columnValue = row.get(columnKey);
-            rowText[i] = getTextForValue(columnValue);
+            rowText[i] = Utils.jsonToString(columnValue);
         }
         return rowText;
     }
diff --git a/frontend/client/src/autotest/common/ui/MultiListSelectPresenter.java b/frontend/client/src/autotest/common/ui/MultiListSelectPresenter.java
index 8d7e4fa..0d9efaa 100644
--- a/frontend/client/src/autotest/common/ui/MultiListSelectPresenter.java
+++ b/frontend/client/src/autotest/common/ui/MultiListSelectPresenter.java
@@ -43,11 +43,6 @@
 
     public interface GeneratorHandler {
         /**
-         * The given generator Item was just selected; create and return a new generated Item.
-         */
-        public Item generateItem(Item generatorItem);
-        
-        /**
          * The given generated Item was just deselected; handle any necessary cleanup.
          */
         public void onRemoveGeneratedItem(Item generatedItem);
@@ -56,8 +51,6 @@
     public static class Item implements Comparable<Item> {
         public String name;
         public String value;
-        // a generator, when selected, generates a new item and selects that item instead
-        public boolean isGenerator;
         // a generated item is destroyed when deselected.
         public boolean isGeneratedItem;
 
@@ -72,12 +65,6 @@
             return new Item(name, value);
         }
 
-        public static Item createGenerator(String name, String value) {
-            Item item = new Item(name, value);
-            item.isGenerator = true;
-            return item;
-        }
-
         public static Item createGeneratedItem(String name, String value) {
             Item item = new Item(name, value);
             item.isGeneratedItem = true;
@@ -107,22 +94,22 @@
             return "Item<" + name + ", " + value + ">";
         }
         
-        public boolean isSelected() {
-            if (isGenerator) {
-                return false;
-            }
+        private boolean isSelected() {
             if (isGeneratedItem) {
                 return true;
             }
             return selected;
         }
         
-        public void setSelected(boolean selected) {
-            assert !isGenerator && !isGeneratedItem;
+        private void setSelected(boolean selected) {
+            assert !isGeneratedItem;
             this.selected = selected;
         }
     }
 
+    /**
+     * Null object to support displays that don't do toggling.
+     */
     private static class NullToggleDisplay implements ToggleDisplay {
         @Override
         public SimplifiedList getSingleSelector() {
@@ -184,15 +171,6 @@
             return;
         }
     }
-    
-    // convenience method
-    public static Set<String> getItemNameSet(List<Item> items) {
-        Set<String> nameSet = new HashSet<String>();
-        for (Item item : items) {
-            nameSet.add(item.name);
-        }
-        return nameSet;
-    }
 
     private List<Item> items = new ArrayList<Item>();
     // need a second list to track ordering
@@ -235,9 +213,7 @@
     }
 
     public void addItem(Item item) {
-        if (item.isGenerator) {
-            assert generatorHandler != null : "generator items require a GeneratorHandler";
-        } else if (item.isGeneratedItem && isItemPresent(item)) {
+        if (item.isGeneratedItem && isItemPresent(item)) {
             return;
         }
         items.add(item);
@@ -258,31 +234,25 @@
         if (item.isSelected()) {
             selectedItems.remove(item);
         }
-        if (item.isGeneratedItem) {
-            generatorHandler.onRemoveGeneratedItem(item);
-        }
         assert verifyConsistency();
         refresh();
     }
 
-    public void removeItemByName(String name) {
-        removeItem(getItemByName(name));
+    public void clearItems() {
+        for (Item item : new ArrayList<Item>(items)) {
+            removeItem(item);
+        }
     }
 
     private void refreshSingleSelector() {
         SimplifiedList selector = toggleDisplay.getSingleSelector();
 
-        boolean isGeneratedItemSelected = false;
         if (!selectedItems.isEmpty()) {
             assert selectedItems.size() == 1;
-            isGeneratedItemSelected = selectedItems.get(0).isGeneratedItem;
         }
 
         selector.clear();
         for (Item item : items) {
-            if (item.isGenerator && isGeneratedItemSelected) {
-                continue;
-            }
             selector.addItem(item.name, item.value);
             if (item.isSelected()) {
                 selector.selectByName(item.name);
@@ -312,34 +282,16 @@
             refreshMultipleSelector();
         } else {
             // single selector always needs something selected
-            if (selectedItems.size() == 0) {
-                Item firstItem = getFirstNonGenerator(); // can't default to a generator
-                if (firstItem != null) {
-                    selectItem(items.get(0));
-                }
+            if (selectedItems.size() == 0 && !items.isEmpty()) {
+                selectItem(items.get(0));
             }
             refreshSingleSelector();
         }
     }
 
-    private Item getFirstNonGenerator() {
-        for (Item item : items) {
-            if (!item.isGenerator) {
-                return item;
-            }
-        }
-        return null;
-    }
-
     private void selectItem(Item item) {
-        if (item.isGenerator) {
-            Item generatedItem = generatorHandler.generateItem(item);
-            addItem(generatedItem);
-        } else {
-            item.setSelected(true);
-            selectedItems.add(item);
-        }
-
+        item.setSelected(true);
+        selectedItems.add(item);
         assert verifyConsistency();
     }
     
@@ -347,7 +299,11 @@
         selectItem(getItemByName(name));
         refresh();
     }
-    
+
+    /**
+     * Set the set of selected items by specifying item names.  All names must exist in the set of
+     * header fields.
+     */
     public void setSelectedItemsByName(List<String> names) {
         for (String itemName : names) {
             Item item = getItemByName(itemName);
@@ -369,9 +325,24 @@
         refresh();
     }
 
+    /**
+     * Set the set of selected items, silently dropping any that don't exist in the header field 
+     * list.
+     */
+    public void restoreSelectedItems(List<Item> items) {
+        List<String> currentItems = new ArrayList<String>();
+        for (Item item : items) {
+            if (hasItemName(item.name)) {
+                currentItems.add(item.name);
+            }
+        }
+        setSelectedItemsByName(currentItems);
+    }
+
     private void deselectItem(Item item) {
         if (item.isGeneratedItem) {
             removeItem(item);
+            generatorHandler.onRemoveGeneratedItem(item);
         } else {
             item.setSelected(false);
             selectedItems.remove(item);
@@ -380,7 +351,7 @@
     }
 
     public List<Item> getSelectedItems() {
-        return Collections.unmodifiableList(selectedItems);
+        return new ArrayList<Item>(selectedItems);
     }
 
     private boolean isMultipleSelectActive() {
@@ -403,13 +374,24 @@
     }
 
     private Item getItemByName(String name) {
+        Item item = findItem(name);
+        if (item != null) {
+            return item;
+        }
+        throw new IllegalArgumentException("Item '" + name + "' does not exist in " + items);
+    }
+
+    private Item findItem(String name) {
         for (Item item : items) {
             if (item.name.equals(name)) {
                 return item;
             }
         }
-        
-        throw new IllegalArgumentException("Item '" + name + "' does not exist in " + items);
+        return null;
+    }
+    
+    public boolean hasItemName(String name) {
+        return findItem(name) != null;
     }
 
     @Override
@@ -494,7 +476,7 @@
 
     private void addAll() {
         for (Item item : items) {
-            if (!item.isSelected() && !item.isGenerator) {
+            if (!item.isSelected()) {
                 selectItem(item);
             }
         }
diff --git a/frontend/client/src/autotest/public/tkoclient.css b/frontend/client/src/autotest/public/tkoclient.css
index 66ea371..80e8e17 100644
--- a/frontend/client/src/autotest/public/tkoclient.css
+++ b/frontend/client/src/autotest/public/tkoclient.css
@@ -62,3 +62,7 @@
   font-weight: bold;
 }
 
+.fixed-headers-input {
+  width: 30em;
+  height: 10em;
+}
diff --git a/frontend/client/src/autotest/tko/AttributeField.java b/frontend/client/src/autotest/tko/AttributeField.java
new file mode 100644
index 0000000..63607f1
--- /dev/null
+++ b/frontend/client/src/autotest/tko/AttributeField.java
@@ -0,0 +1,8 @@
+package autotest.tko;
+
+public abstract class AttributeField extends ParameterizedField {
+    @Override
+    public String getSqlCondition(String value) {
+        return getSimpleSqlCondition(getQuotedSqlName() + ".value", value);
+    }
+}
diff --git a/frontend/client/src/autotest/tko/CommonPanel.java b/frontend/client/src/autotest/tko/CommonPanel.java
index c3de8e3..6a019c8 100644
--- a/frontend/client/src/autotest/tko/CommonPanel.java
+++ b/frontend/client/src/autotest/tko/CommonPanel.java
@@ -1,10 +1,10 @@
 package autotest.tko;
 
+import autotest.common.SimpleCallback;
 import autotest.common.Utils;
 import autotest.common.ui.NotifyManager;
 import autotest.common.ui.SimpleHyperlink;
 import autotest.tko.TkoUtils.FieldInfo;
-import autotest.tko.WidgetList.ListWidgetFactory;
 
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
@@ -17,20 +17,15 @@
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.HorizontalPanel;
 import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.ListBox;
 import com.google.gwt.user.client.ui.Panel;
 import com.google.gwt.user.client.ui.PopupPanel;
 import com.google.gwt.user.client.ui.RootPanel;
 import com.google.gwt.user.client.ui.TextArea;
-import com.google.gwt.user.client.ui.TextBox;
 import com.google.gwt.user.client.ui.VerticalPanel;
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwt.user.client.ui.PopupPanel.PositionCallback;
 
-import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -40,209 +35,15 @@
     private static final String HIDE_QUICK_REFERENCE = "Hide quick reference";
     private static final String SHOW_CONTROLS = "Show controls";
     private static final String HIDE_CONTROLS = "Hide controls";
-    private static final String INCLUDE_ATTRIBUTES_TABLE = "test_attributes_include";
-    private static final String EXCLUDE_ATTRIBUTES_TABLE = "test_attributes_exclude";
 
     private static CommonPanel theInstance = new CommonPanel();
 
-    private static abstract class FilterData {
-        protected boolean isInclude;
+    private String savedSqlCondition;
+    private boolean savedShowInvalid = false;
+    private HeaderFieldCollection headerFields = new HeaderFieldCollection();
 
-        public boolean isInclude() {
-            return isInclude;
-        }
-
-        public abstract String getFilterString();
-        public abstract String getFilterType();
-        public abstract void addToHistory(Map<String, String> args, String prefix);
-
-        public static FilterData dataFromHistory(Map<String, String> args, String prefix) {
-            String includeKey = prefix + "_include";
-            if (!args.containsKey(includeKey)) {
-                return null;
-            }
-
-            if (args.containsKey(prefix + "_attribute")) {
-                return AttributeFilterData.fromHistory(args, prefix);
-            } else {
-                return LabelFilterData.fromHistory(args, prefix);
-            }
-        }
-    }
-
-    private static class AttributeFilterData extends FilterData {
-        private String attributeWhere, valueWhere;
-
-        public AttributeFilterData(boolean isInclude, String attributeWhere, String valueWhere) {
-            this.isInclude = isInclude;
-            this.attributeWhere = attributeWhere;
-            this.valueWhere = valueWhere;
-        }
-
-        @Override
-        public String getFilterString() {
-            String tableName = isInclude ? INCLUDE_ATTRIBUTES_TABLE : EXCLUDE_ATTRIBUTES_TABLE;
-            return "(" + tableName + ".attribute " + attributeWhere + " AND " +
-                   tableName + ".value " + valueWhere + ")";
-        }
-
-        @Override
-        public String getFilterType() {
-            return FilterFactory.ATTRIBUTE_TYPE;
-        }
-
-        @Override
-        public void addToHistory(Map<String, String> args, String prefix) {
-            args.put(prefix + "_include", Boolean.toString(isInclude()));
-            args.put(prefix + "_attribute", attributeWhere);
-            args.put(prefix + "_value", valueWhere);
-        }
-
-        public static AttributeFilterData fromHistory(Map<String, String> args, String prefix) {
-            String includeKey = prefix + "_include";
-            boolean include = Boolean.valueOf(args.get(includeKey));
-            String attributeWhere = args.get(prefix + "_attribute");
-            String valueWhere = args.get(prefix + "_value");
-            return new AttributeFilterData(include, attributeWhere, valueWhere);
-        }
-    }
-
-    private static class LabelFilterData extends FilterData {
-        private String labelWhere;
-
-        public LabelFilterData(boolean isInclude, String labelWhere) {
-            this.isInclude = isInclude;
-            this.labelWhere = labelWhere;
-        }
-
-        @Override
-        public String getFilterString() {
-            return labelWhere;
-        }
-
-        @Override
-        public String getFilterType() {
-            return FilterFactory.LABEL_TYPE;
-        }
-
-        @Override
-        public void addToHistory(Map<String, String> args, String prefix) {
-            args.put(prefix + "_include", Boolean.toString(isInclude()));
-            args.put(prefix + "_label", labelWhere);
-        }
-
-        public static LabelFilterData fromHistory(Map<String, String> args, String prefix) {
-            String includeKey = prefix + "_include";
-            boolean include = Boolean.valueOf(args.get(includeKey));
-            String labelWhere = args.get(prefix + "_label");
-            return new LabelFilterData(include, labelWhere);
-      }
-    }
-
-    private abstract class TestFilterWidget extends Composite implements ClickHandler {
-        protected ListBox includeOrExclude = new ListBox();
-
-        protected boolean isInclude() {
-            return includeOrExclude.getSelectedIndex() == 0;
-        }
-
-        protected void setupPanel(List<TextBox> textBoxes) {
-            Panel panel = new HorizontalPanel();
-
-            includeOrExclude.addItem("Include");
-            includeOrExclude.addItem("Exclude");
-            panel.add(includeOrExclude);
-
-            for (TextBox textBox : textBoxes) {
-                panel.add(new Label(textBox.getName()));
-                panel.add(textBox);
-            }
-
-            SimpleHyperlink deleteLink = new SimpleHyperlink("[X]");
-            deleteLink.addClickHandler(this);
-            panel.add(deleteLink);
-
-            initWidget(panel);
-        }
-
-        public void onClick(ClickEvent event) {
-            filterList.deleteWidget(this);
-        }
-
-        public abstract FilterData getFilterData();
-    }
-
-    private class AttributeFilter extends TestFilterWidget {
-        private TextBox attributeWhere = new TextBox(), valueWhere = new TextBox();
-
-        public AttributeFilter() {
-            attributeWhere.setName("tests with attribute");
-            valueWhere.setName("and value");
-            List<TextBox> textBoxes = Arrays.asList(attributeWhere, valueWhere);
-
-            setupPanel(textBoxes);
-        }
-
-        @Override
-        public FilterData getFilterData() {
-            return new AttributeFilterData(isInclude(), attributeWhere.getText(),
-                                           valueWhere.getText());
-        }
-    }
-
-    private class LabelFilter extends TestFilterWidget {
-        private TextBox labelWhere = new TextBox();
-
-        public LabelFilter() {
-            labelWhere.setName("tests with label");
-            List<TextBox> textBoxes = Arrays.asList(labelWhere);
-
-            setupPanel(textBoxes);
-        }
-
-        @Override
-        public FilterData getFilterData() {
-            return new LabelFilterData(isInclude(), labelWhere.getText());
-        }
-    }
-
-    private class FilterFactory implements ListWidgetFactory<TestFilterWidget> {
-        private static final String LABEL_TYPE = "Add label filter";
-        private static final String ATTRIBUTE_TYPE = "Add attribute filter";
-
-        public List<String> getWidgetTypes() {
-            List<String> types = Arrays.asList(LABEL_TYPE, ATTRIBUTE_TYPE);
-
-            return types;
-        }
-
-        public TestFilterWidget getNewWidget(String type) {
-            if (type.equals(LABEL_TYPE)) {
-                return new LabelFilter();
-            } else {
-                assert(type.equals(ATTRIBUTE_TYPE));
-                return new AttributeFilter();
-            }
-        }
-
-        public TestFilterWidget getFilterWidgetFromData(FilterData filterData) {
-            TestFilterWidget filter = null;
-            if (filterData.getFilterType().equals(FilterFactory.ATTRIBUTE_TYPE)) {
-                AttributeFilter aFilter = new AttributeFilter();
-                aFilter.attributeWhere.setText(((AttributeFilterData)filterData).attributeWhere);
-                aFilter.valueWhere.setText(((AttributeFilterData)filterData).valueWhere);
-                filter = aFilter;
-            } else {
-                assert(filterData.getFilterType().equals(FilterFactory.LABEL_TYPE));
-                LabelFilter lFilter = new LabelFilter();
-                lFilter.labelWhere.setText(((LabelFilterData)filterData).labelWhere);
-                filter = lFilter;
-            }
-            filter.includeOrExclude.setSelectedIndex(filterData.isInclude() ? 0 : 1);
-
-            return filter;
-        }
-    }
+    private ParameterizedFieldListPresenter parameterizedFieldPresenter =
+        new ParameterizedFieldListPresenter(headerFields);
 
     private HTMLPanel htmlPanel;
     private TextArea customSqlBox = new TextArea();
@@ -252,15 +53,20 @@
     private SimpleHyperlink showHideControlsLink = new SimpleHyperlink(HIDE_CONTROLS);
     private Panel allControlsPanel = RootPanel.get("common_all_controls");
     private Set<CommonPanelListener> listeners = new HashSet<CommonPanelListener>();
-    private WidgetList<TestFilterWidget> filterList;
-    private FilterFactory filterFactory = new FilterFactory();
-
-    private String savedSqlCondition;
-    private boolean savedShowInvalid = false;
-    private List<FilterData> savedFilters = new ArrayList<FilterData>();
+    private ParameterizedFieldListDisplay parameterizedFieldDisplay =
+        new ParameterizedFieldListDisplay();
+    
 
     public static interface CommonPanelListener {
+        /**
+         * Called to show or hide tab-specific controls.
+         */
         public void onSetControlsVisible(boolean visible);
+        
+        /**
+         * Called when the set of HeaderFields has changed.
+         */
+        public void onFieldsChanged();
     }
 
     private CommonPanel() {
@@ -273,15 +79,14 @@
         quickReferenceLink.addClickHandler(this);
         showHideControlsLink.addClickHandler(this);
 
-        filterList = new WidgetList<TestFilterWidget>(filterFactory);
         Panel titlePanel = new HorizontalPanel();
-        titlePanel.add(getFieldLabel("Test attributes:"));
-        titlePanel.add(new HTML("&nbsp;<a href=\"" + WIKI_URL + "#attribute_filtering\" " +
+        titlePanel.add(getFieldLabel("Custom fields:"));
+        titlePanel.add(new HTML("&nbsp;<a href=\"" + Utils.escape(WIKI_URL) + "#custom_fields\" " +
                                 "target=\"_blank\">[?]</a>"));
         Panel attributeFilters = new VerticalPanel();
         attributeFilters.setStyleName("box");
         attributeFilters.add(titlePanel);
-        attributeFilters.add(filterList);
+        attributeFilters.add(parameterizedFieldDisplay);
 
         Panel commonFilterPanel = new VerticalPanel();
         commonFilterPanel.add(customSqlBox);
@@ -291,6 +96,17 @@
         htmlPanel.add(quickReferenceLink, "common_quick_reference");
         htmlPanel.add(showHideControlsLink, "common_show_hide_controls");
         generateQuickReferencePopup();
+
+        headerFields.populateFromList("all_fields");
+        notifyOnFieldsChanged();
+
+        parameterizedFieldPresenter.bindDisplay(parameterizedFieldDisplay);
+        parameterizedFieldPresenter.setListener(new SimpleCallback() {
+            @Override
+            public void doCallback(Object source) {
+                notifyOnFieldsChanged();
+            }
+        });
     }
 
     private Widget getFieldLabel(String string) {
@@ -330,69 +146,26 @@
     public void updateStateFromView() {
         savedSqlCondition = customSqlBox.getText().trim();
         savedShowInvalid = showInvalid.getValue();
-
-        savedFilters.clear();
-        for (TestFilterWidget filter : filterList.getWidgets()) {
-            savedFilters.add(filter.getFilterData());
-        }
     }
 
     public void updateViewFromState() {
         customSqlBox.setText(savedSqlCondition);
         showInvalid.setValue(savedShowInvalid);
-
-        filterList.clear();
-        for (FilterData filterData : savedFilters) {
-            filterList.addWidget(filterFactory.getFilterWidgetFromData(filterData));
-        }
-    }
-
-    private void addAttributeFilters(JSONObject conditionArgs) {
-        List<String> include = new ArrayList<String>(), exclude = new ArrayList<String>();
-
-        getIncludeAndExclude(include, exclude, FilterFactory.ATTRIBUTE_TYPE);
-
-        String includeSql = Utils.joinStrings(" OR ", include);
-        String excludeSql = Utils.joinStrings(" OR ", exclude);
-        addIfNonempty(conditionArgs, "include_attributes_where", includeSql);
-        addIfNonempty(conditionArgs, "exclude_attributes_where", excludeSql);
-    }
-
-    private void addLabelFilters(JSONObject conditionArgs) {
-        List<String> include = new ArrayList<String>(), exclude = new ArrayList<String>();
-
-        getIncludeAndExclude(include, exclude, FilterFactory.LABEL_TYPE);
-
-        if (!savedShowInvalid) {
-            exclude.add(TestLabelManager.INVALIDATED_LABEL);
-        }
-
-        if (!include.isEmpty()) {
-            conditionArgs.put("include_labels", Utils.stringsToJSON(include));
-        }
-        if (!exclude.isEmpty()) {
-            conditionArgs.put("exclude_labels", Utils.stringsToJSON(exclude));
-        }
-    }
-
-    private void getIncludeAndExclude(List<String> include, List<String> exclude, String type) {
-        for (FilterData filterData : savedFilters) {
-            if (filterData.getFilterType().equals(type)) {
-                if (filterData.isInclude()) {
-                    include.add(filterData.getFilterString());
-                }
-                else {
-                    exclude.add(filterData.getFilterString());
-                }
-            }
-        }
     }
 
     public JSONObject getConditionArgs() {
+        String condition = savedSqlCondition;
+        if (!savedShowInvalid) {
+            parameterizedFieldPresenter.addFieldIfNotPresent(TestLabelField.TYPE_NAME,
+                                                             "invalidated");
+            condition = "(" + condition + ") AND label_invalidated.id IS NULL";
+        }
+
         JSONObject conditionArgs = new JSONObject();
-        addIfNonempty(conditionArgs, "extra_where", savedSqlCondition);
-        addAttributeFilters(conditionArgs);
-        addLabelFilters(conditionArgs);
+        addIfNonempty(conditionArgs, "extra_where", condition);
+        for (HeaderField field : headerFields) {
+            field.addQueryParameters(conditionArgs);
+        }
 
         return conditionArgs;
     }
@@ -412,35 +185,17 @@
         refineCondition(tests.getPartialSqlCondition());
     }
 
-    private String getListKey(String base, int index) {
-        return base + "_" + Integer.toString(index);
-    }
-
     public void handleHistoryArguments(Map<String, String> arguments) {
         setSqlCondition(arguments.get("condition"));
         savedShowInvalid = Boolean.valueOf(arguments.get("show_invalid"));
-
-        savedFilters.clear();
-        for (int index = 0; ; index++) {
-            FilterData filterData = FilterData.dataFromHistory(
-                    arguments, getListKey("filter", index));
-            if (filterData == null) {
-                break;
-            }
-            savedFilters.add(filterData);
-        }
-
+        parameterizedFieldPresenter.handleHistoryArguments(arguments);
         updateViewFromState();
     }
 
     public void addHistoryArguments(Map<String, String> arguments) {
         arguments.put("condition", savedSqlCondition);
         arguments.put("show_invalid", Boolean.toString(savedShowInvalid));
-        int index = 0;
-        for (FilterData filterData : savedFilters) {
-            filterData.addToHistory(arguments, getListKey("filter", index));
-            index++;
-        }
+        parameterizedFieldPresenter.addHistoryArguments(arguments);
     }
 
     public void fillDefaultHistoryValues(Map<String, String> arguments) {
@@ -507,4 +262,14 @@
     public void addListener(CommonPanelListener listener) {
         listeners.add(listener);
     }
+    
+    public HeaderFieldCollection getHeaderFields() {
+        return headerFields;
+    }
+
+    private void notifyOnFieldsChanged() {
+        for (CommonPanelListener listener : listeners) {
+            listener.onFieldsChanged();
+        }
+    }
 }
diff --git a/frontend/client/src/autotest/tko/ContentSelect.java b/frontend/client/src/autotest/tko/ContentSelect.java
index d9d2571..a1ffcbf 100644
--- a/frontend/client/src/autotest/tko/ContentSelect.java
+++ b/frontend/client/src/autotest/tko/ContentSelect.java
@@ -34,10 +34,13 @@
     public static final String ADD_ADDITIONAL_CONTENT = "Add additional content...";
     public static final String CANCEL_ADDITIONAL_CONTENT = "Don't use additional content";
   
+    private HeaderFieldCollection headerFields;
     private SimpleHyperlink addLink = new SimpleHyperlink(ADD_ADDITIONAL_CONTENT);
     private ListBox contentSelect = new ListBox(true);
         
-    public ContentSelect() {
+    public ContentSelect(HeaderFieldCollection headerFields) {
+        this.headerFields = headerFields;
+
         Panel panel = new VerticalPanel();
         contentSelect.setVisible(false);
         
@@ -75,8 +78,11 @@
         notifyHandlers();
     }
     
-    public void addItem(HeaderField field) {
-        contentSelect.addItem(field.getName(), field.getSqlName());
+    public void refreshFields() {
+        contentSelect.clear();
+        for (HeaderField field : headerFields) {
+            contentSelect.addItem(field.getName(), field.getSqlName());
+        }
     }
     
     public HandlerRegistration addValueChangeHandler(ValueChangeHandler<Boolean> handler) {
diff --git a/frontend/client/src/autotest/tko/HeaderField.java b/frontend/client/src/autotest/tko/HeaderField.java
index be7d64e..25b5466 100644
--- a/frontend/client/src/autotest/tko/HeaderField.java
+++ b/frontend/client/src/autotest/tko/HeaderField.java
@@ -5,12 +5,19 @@
 
 import com.google.gwt.json.client.JSONObject;
 
-import java.util.Map;
-
 /**
- * A database field which the user may select for display or filter on.  HeaderFields may generate
- * arbitrary SQL to perform filtering, and they may add arbitrary query arguments to support display
- * and filtering.
+ * A field associated with test results.  The user may
+ * * view this field in table view,
+ * * sort by this field in table view,
+ * * group by this field in spreadsheet or table view, and
+ * * filter on this field in the SQL condition.
+ * It's assumed that the name returned by getSqlName() is a field returned by the server which may 
+ * also be used for grouping and sorting.  Filtering, however, is done separately (through 
+ * getSqlCondition()), so HeaderFields may generate arbitrary SQL to perform filtering.  
+ * HeaderFields may also add arbitrary query arguments to support themselves.
+ * 
+ * While the set of HeaderFields active in the application may change at runtime, HeaderField 
+ * objects themselves are immutable.
  */
 abstract class HeaderField implements Comparable<HeaderField> {
     protected String name;
@@ -58,20 +65,25 @@
     public String getSqlName() {
         return sqlName;
     }
-
+    
     /**
-     * Get the attribute name of this field on a result object.  This should always be the same as 
-     * sqlName, but due to some current flaws in the design, it's necessary as a separate item.
-     * TODO: Get rid of this and fix up the design.
+     * Get a quoted version of getSqlName() safe for use directly in SQL.
      */
-    public String getAttributeName() {
-        return getSqlName();
+    public String getQuotedSqlName() {
+        return "`" + getSqlName() + "`";
     }
 
     @Override
     public String toString() {
         return "HeaderField<" + getName() + ", " + getSqlName() + ">";
     }
+
+    /**
+     * Should this field be provided as a choice for the user to select?
+     */
+    public boolean isUserSelectable() {
+        return true;
+    }
     
     /**
      * @return a MultiListSelectPresenter.Item for this HeaderField.
@@ -85,16 +97,4 @@
      * @param parameters query parameters
      */
     public void addQueryParameters(JSONObject parameters) {}
-
-    /**
-     * Add necessary parameters to history state.  Does nothing by default.
-     * @param arguments history arguments
-     */
-    public void addHistoryArguments(Map<String, String> arguments) {}
-
-    /**
-     * Parse information as necessary from history state.
-     * @param arguments history arguments
-     */
-    public void handleHistoryArguments(Map<String, String> arguments) {} 
 }
diff --git a/frontend/client/src/autotest/tko/HeaderFieldCollection.java b/frontend/client/src/autotest/tko/HeaderFieldCollection.java
index 4a379fc..03ba147 100644
--- a/frontend/client/src/autotest/tko/HeaderFieldCollection.java
+++ b/frontend/client/src/autotest/tko/HeaderFieldCollection.java
@@ -17,7 +17,7 @@
     private Map<String, HeaderField> fieldsByName = new HashMap<String, HeaderField>();
     private Map<String, HeaderField> fieldsBySqlName = new HashMap<String, HeaderField>();
     private List<HeaderField> orderedFields = new ArrayList<HeaderField>();
-
+    
     public void populateFromList(String fieldListName) {
         for (FieldInfo fieldInfo : TkoUtils.getFieldList(fieldListName)) {
             HeaderField field = new SimpleHeaderField(fieldInfo.name, fieldInfo.field);
@@ -144,16 +144,4 @@
         fieldsBySqlName.remove(field.getSqlName());
         return true;
     }
-
-    void addHistoryArguments(Map<String, String> arguments) {
-        for (HeaderField field : this) {
-            field.addHistoryArguments(arguments);
-        }
-    }
-
-    public void handleHistoryArguments(Map<String, String> arguments) {
-        for (HeaderField field : this) {
-            field.handleHistoryArguments(arguments);
-        }
-    }
 }
diff --git a/frontend/client/src/autotest/tko/HeaderSelect.java b/frontend/client/src/autotest/tko/HeaderSelect.java
index d385f90..071a5e8 100644
--- a/frontend/client/src/autotest/tko/HeaderSelect.java
+++ b/frontend/client/src/autotest/tko/HeaderSelect.java
@@ -2,73 +2,56 @@
 
 import autotest.common.Utils;
 import autotest.common.ui.MultiListSelectPresenter;
-import autotest.common.ui.ToggleControl;
+import autotest.common.ui.MultiListSelectPresenter.DoubleListDisplay;
+import autotest.common.ui.MultiListSelectPresenter.GeneratorHandler;
 import autotest.common.ui.MultiListSelectPresenter.Item;
 
-import com.google.gwt.event.dom.client.ClickEvent;
-import com.google.gwt.event.dom.client.ClickHandler;
-import com.google.gwt.json.client.JSONObject;
-import com.google.gwt.user.client.ui.HasText;
-
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
-class HeaderSelect implements ClickHandler {
-    public static final String HISTORY_FIXED_VALUES = "_fixed_values";
-    
+class HeaderSelect {
     public static class State {
         private List<HeaderField> selectedFields;
-        private String fixedValues;
-    }
 
-    public interface Display {
-        public MultiListSelectPresenter.DoubleListDisplay getDoubleListDisplay();
-        public MultiListSelectPresenter.ToggleDisplay getToggleDisplay();
-        public ParameterizedFieldListPresenter.Display getParameterizedFieldDisplay();
-
-        public HasText getFixedValuesInput();
-        public void setFixedValuesVisible(boolean visible);
-        public ToggleControl getFixedValuesToggle();
+        public List<HeaderField> getSelectedFields() {
+            return new ArrayList<HeaderField>(selectedFields);
+        }
     }
     
     private HeaderFieldCollection headerFields;
-    private State savedState = new State();
+    private final State savedState;
 
-    private Display display;
-    private MultiListSelectPresenter multiListSelect = new MultiListSelectPresenter();
-    private ParameterizedFieldListPresenter parameterizedFieldPresenter; 
+    protected MultiListSelectPresenter multiListSelect = new MultiListSelectPresenter();
 
-    public HeaderSelect(HeaderFieldCollection headerFields) {
+    public HeaderSelect(HeaderFieldCollection headerFields, State state) {
         this.headerFields = headerFields;
-        parameterizedFieldPresenter = new ParameterizedFieldListPresenter(headerFields);
-        multiListSelect.setGeneratorHandler(parameterizedFieldPresenter);
+        savedState = state;
     }
 
-    public void bindDisplay(Display display) {
-        this.display = display;
-        display.getFixedValuesToggle().addClickHandler(this);
-        display.setFixedValuesVisible(false);
-        multiListSelect.bindDisplay(display.getDoubleListDisplay());
-        multiListSelect.bindToggleDisplay(display.getToggleDisplay());
-        parameterizedFieldPresenter.bindDisplay(display.getParameterizedFieldDisplay());
+    public void bindDisplay(DoubleListDisplay display) {
+        multiListSelect.bindDisplay(display);
+        refreshFields();
+    }
 
+    public void refreshFields() {
+        List<Item> selection = multiListSelect.getSelectedItems();
+        multiListSelect.clearItems();
         for (HeaderField field : headerFields) {
-            multiListSelect.addItem(field.getItem());
+            if (field.isUserSelectable()) {
+                multiListSelect.addItem(field.getItem());
+            }
         }
-        multiListSelect.addItem(ParameterizedField.getGenerator(MachineLabelField.BASE_NAME));
+        multiListSelect.restoreSelectedItems(selection);
     }
 
     public void updateStateFromView() {
         saveToState(savedState);
-        parameterizedFieldPresenter.updateStateFromView();
     }
 
-    private void saveToState(State state) {
+    protected void saveToState(State state) {
         state.selectedFields = getSelectedItemsFromView();
-        state.fixedValues = getFixedValuesText();
     }
     
     public State getStateFromView() {
@@ -84,31 +67,20 @@
         }
         return selectedFields;
     }
-
-    private String getFixedValuesText() {
-        if (!isFixedValuesActive()) {
-            return "";
-        }
-        
-        return display.getFixedValuesInput().getText();
-    }
     
     public List<HeaderField> getSelectedItems() {
-        return Collections.unmodifiableList(savedState.selectedFields);
+        return savedState.getSelectedFields();
     }
     
     public void updateViewFromState() {
         loadFromState(savedState);
-        parameterizedFieldPresenter.updateViewFromState();
     }
 
     public void loadFromState(State state) {
-        selectItemsInView(state.selectedFields);
-        display.getFixedValuesInput().setText(state.fixedValues);
-        display.getFixedValuesToggle().setActive(!state.fixedValues.equals(""));
+        setSelectedItemsInView(state.selectedFields);
     }
 
-    private void selectItemsInView(List<HeaderField> fields) {
+    private void setSelectedItemsInView(List<HeaderField> fields) {
         List<String> fieldNames = new ArrayList<String>();
         for (HeaderField field : fields) {
             Item item = field.getItem();
@@ -120,19 +92,27 @@
         multiListSelect.setSelectedItemsByName(fieldNames);
     }
 
-    public void selectItems(List<HeaderField> fields) {
+    public void setSelectedItems(List<HeaderField> fields) {
         savedState.selectedFields = new ArrayList<HeaderField>(fields);
-        savedState.fixedValues = "";
     }
     
-    public void selectItem(HeaderField field) {
-        selectItems(Arrays.asList(new HeaderField[] {field}));
+    public void setSelectedItem(HeaderField field) {
+        setSelectedItems(Arrays.asList(new HeaderField[] {field}));
     }
-
-    @Override
-    public void onClick(ClickEvent event) {
-        assert event.getSource() == display.getFixedValuesToggle();
-        display.setFixedValuesVisible(isFixedValuesActive());
+    
+    public void selectItemInView(HeaderField field) {
+        List<HeaderField> fields = getSelectedItemsFromView();
+        if (!fields.contains(field)) {
+            fields.add(field);
+            setSelectedItemsInView(fields);
+        }
+    }
+    
+    public void deselectItemInView(HeaderField field) {
+        List<HeaderField> fields = getSelectedItemsFromView();
+        if (fields.remove(field)) {
+            setSelectedItemsInView(fields);
+        }
     }
 
     public void addHistoryArguments(Map<String, String> arguments, String name) {
@@ -142,35 +122,12 @@
         }
         String fieldList = Utils.joinStrings(",", fields);
         arguments.put(name, fieldList);
-        if (isFixedValuesActive()) {
-            arguments.put(name + HISTORY_FIXED_VALUES, display.getFixedValuesInput().getText());
-        }
-
-        headerFields.addHistoryArguments(arguments);
-    }
-
-    private boolean isFixedValuesActive() {
-        return !display.getToggleDisplay().getToggleMultipleLink().isActive()
-               && display.getFixedValuesToggle().isActive();
     }
 
     public void handleHistoryArguments(Map<String, String> arguments, String name) {
         String[] fields = arguments.get(name).split(",");
-        addParameterizedFields(fields);
-        headerFields.handleHistoryArguments(arguments);
         List<HeaderField> selectedFields = getHeaderFieldsFromValues(fields);
-        selectItems(selectedFields);
-        String fixedValuesText = arguments.get(name + HISTORY_FIXED_VALUES);
-        savedState.fixedValues = fixedValuesText;
-    }
-
-    private void addParameterizedFields(String[] sqlNames) {
-        for (String sqlName : sqlNames) {
-            if (!headerFields.containsSqlName(sqlName)) {
-                ParameterizedField field = ParameterizedField.fromSqlName(sqlName);
-                parameterizedFieldPresenter.addField(field);
-            }
-        }
+        setSelectedItems(selectedFields);
     }
 
     private List<HeaderField> getHeaderFieldsFromValues(String[] fieldSqlNames) {
@@ -181,32 +138,11 @@
         return fields;
     }
 
-    /**
-     * @return true if all machine label header inputs are not empty.
-     */
-    public boolean checkMachineLabelHeaders() {
-        return parameterizedFieldPresenter.areAllInputsFilled();
+    protected State getState() {
+        return savedState;
     }
 
-    public void addQueryParameters(JSONObject parameters) {
-        for (HeaderField field : getSelectedItems()) {
-            field.addQueryParameters(parameters);
-        }
-
-        List<String> fixedValues = getFixedValues();
-        if (fixedValues != null) {
-            JSONObject fixedValuesObject = 
-                Utils.setDefaultValue(parameters, "fixed_headers", new JSONObject()).isObject();
-            fixedValuesObject.put(getSelectedItems().get(0).getSqlName(), 
-                            Utils.stringsToJSON(fixedValues));
-        }
-    }
-
-    private List<String> getFixedValues() {
-        String valueText = savedState.fixedValues.trim();
-        if (valueText.equals("")) {
-            return null;
-        }
-        return Utils.splitListWithSpaces(valueText);
+    public void setGeneratorHandler(GeneratorHandler handler) {
+        multiListSelect.setGeneratorHandler(handler);
     }
 }
diff --git a/frontend/client/src/autotest/tko/IterationDataSource.java b/frontend/client/src/autotest/tko/IterationDataSource.java
deleted file mode 100644
index 910687d..0000000
--- a/frontend/client/src/autotest/tko/IterationDataSource.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package autotest.tko;
-
-import autotest.common.Utils;
-import autotest.common.table.RpcDataSource;
-
-import com.google.gwt.json.client.JSONObject;
-import com.google.gwt.json.client.JSONString;
-import com.google.gwt.json.client.JSONValue;
-
-import java.util.List;
-
-public class IterationDataSource extends RpcDataSource {
-    public IterationDataSource() {
-        super("get_iteration_views", "get_num_iteration_views");
-    }
-
-    /**
-     * Add 'id' field, needed by SelectionManager.
-     */
-    @Override
-    protected List<JSONObject> handleJsonResult(JSONValue result) {
-        List<JSONObject> objects = super.handleJsonResult(result);
-        for (JSONObject object : objects) {
-            String iterationId = Utils.jsonToString(object.get("test_idx")) + "-"
-                    + Utils.jsonToString(object.get("iteration_index"));
-            object.put("id", new JSONString(iterationId));
-        }
-        return objects;
-    }
-}
diff --git a/frontend/client/src/autotest/tko/IterationResultField.java b/frontend/client/src/autotest/tko/IterationResultField.java
index e883189..570b704 100644
--- a/frontend/client/src/autotest/tko/IterationResultField.java
+++ b/frontend/client/src/autotest/tko/IterationResultField.java
@@ -1,13 +1,8 @@
 package autotest.tko;
 
-import autotest.common.Utils;
 
-import com.google.gwt.json.client.JSONArray;
-import com.google.gwt.json.client.JSONObject;
-import com.google.gwt.json.client.JSONString;
-
-public class IterationResultField extends StringParameterizedField {
-    public static final String BASE_NAME = "Iteration result";
+public class IterationResultField extends AttributeField {
+    public static final String TYPE_NAME = "Iteration result";
 
     @Override
     protected ParameterizedField freshInstance() {
@@ -15,31 +10,17 @@
     }
 
     @Override
-    protected String getBaseName() {
-        return BASE_NAME;
+    public String getTypeName() {
+        return TYPE_NAME;
+    }
+    
+    @Override
+    protected String getFieldParameterName() {
+        return "iteration_fields";
     }
 
     @Override
     public String getBaseSqlName() {
-        return "iteration_result_";
+        return "iteration_";
     }
-
-    @Override
-    public String getAttributeName() {
-        return getValue();
-    }
-
-    @Override
-    public void addQueryParameters(JSONObject parameters) {
-        JSONArray iterationKeys = 
-            Utils.setDefaultValue(parameters, "result_keys", new JSONArray()).isArray();
-        iterationKeys.set(iterationKeys.size(), new JSONString(getValue()));
-    }
-
-    @Override
-    public String getSqlCondition(String value) {
-        // TODO: when grouping on iteration results is added, this will be necessary
-        throw new UnsupportedOperationException();
-    }
-
 }
diff --git a/frontend/client/src/autotest/tko/LabelField.java b/frontend/client/src/autotest/tko/LabelField.java
new file mode 100644
index 0000000..4437dc5
--- /dev/null
+++ b/frontend/client/src/autotest/tko/LabelField.java
@@ -0,0 +1,14 @@
+package autotest.tko;
+
+import autotest.common.Utils;
+
+public abstract class LabelField extends ParameterizedField {
+    @Override
+    public String getSqlCondition(String value) {
+        String condition = "IS NOT NULL";
+        if (value.equals(Utils.JSON_NULL)) {
+            condition = "IS NULL";
+        }
+        return getQuotedSqlName() + ".id " + condition;
+    }
+}
diff --git a/frontend/client/src/autotest/tko/MachineLabelField.java b/frontend/client/src/autotest/tko/MachineLabelField.java
index 495d781..12d64eb 100644
--- a/frontend/client/src/autotest/tko/MachineLabelField.java
+++ b/frontend/client/src/autotest/tko/MachineLabelField.java
@@ -1,65 +1,26 @@
 package autotest.tko;
 
-import autotest.common.Utils;
 
-import com.google.gwt.json.client.JSONObject;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-public class MachineLabelField extends ParameterizedField {
-    public static final String BASE_NAME = "Machine labels";
-    private static final String MACHINE_LABEL_HEADERS = "machine_label_headers";
-
-    private List<String> labels = new ArrayList<String>();
+public class MachineLabelField extends LabelField {
+    public static final String TYPE_NAME = "Machine label";
 
     @Override
-    public String getSqlCondition(String value) {
-        List<String> conditionParts = new ArrayList<String>();
-        Set<String> selectedLabels = new HashSet<String>(Utils.splitList(value));
-        
-        for (String label : labels) {
-            String condition = "FIND_IN_SET('" + label + "', test_attributes_host_labels.value)";
-            if (!selectedLabels.contains(label)) {
-                condition = "NOT " + condition;
-            }
-            conditionParts.add(condition);
-        }
-        
-        return Utils.joinStrings(" AND ", conditionParts);
-    }
-
-    @Override
-    public void addQueryParameters(JSONObject parameters) {
-        JSONObject machineLabelHeaders = 
-            Utils.setDefaultValue(parameters, MACHINE_LABEL_HEADERS, new JSONObject()).isObject();
-        machineLabelHeaders.put(getSqlName(), Utils.stringsToJSON(labels));
-    }
-
-    @Override
-    public String getValue() {
-        return Utils.joinStrings(",", labels);
-    }
-
-    @Override
-    public void setValue(String value) {
-        labels = Utils.splitListWithSpaces(value);
-    }
-
-    @Override
-    public String getBaseSqlName() {
-        return "machine_labels_";
-    }
-
-    @Override
-    protected String getBaseName() {
-        return BASE_NAME;
+    public String getTypeName() {
+        return TYPE_NAME;
     }
 
     @Override
     protected ParameterizedField freshInstance() {
         return new MachineLabelField();
     }
+
+    @Override
+    protected String getFieldParameterName() {
+        return "machine_label_fields";
+    }
+
+    @Override
+    public String getBaseSqlName() {
+        return "machine_label_";
+    }
 }
diff --git a/frontend/client/src/autotest/tko/ParameterizedField.java b/frontend/client/src/autotest/tko/ParameterizedField.java
index 8dc4158..b24a788 100644
--- a/frontend/client/src/autotest/tko/ParameterizedField.java
+++ b/frontend/client/src/autotest/tko/ParameterizedField.java
@@ -1,109 +1,130 @@
 package autotest.tko;
 
-import java.util.Map;
+import autotest.common.Utils;
 
-import autotest.common.ui.MultiListSelectPresenter.Item;
+import com.google.gwt.json.client.JSONArray;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.json.client.JSONString;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
 
 public abstract class ParameterizedField extends HeaderField {
+    private static class FieldIdentifier {
+        String type;
+        String value;
+        
+        public FieldIdentifier(ParameterizedField field) {
+            this.type = field.getTypeName();
+            this.value = field.getValue();
+        }
+    
+        @Override
+        public int hashCode() {
+            return type.hashCode() + 31 * value.hashCode();
+        }
+    
+        @Override
+        public boolean equals(Object obj) {
+            if (obj == null || !(obj instanceof FieldIdentifier)) {
+                return false;
+            }
+    
+            FieldIdentifier other = (FieldIdentifier) obj;
+            return type.equals(other.type) && value.equals(other.value);
+        }
+    }
+
     private static final ParameterizedField[] prototypes = new ParameterizedField[] {
         // add all ParameterizedField subclasses here.  these instances should never escape. 
         new MachineLabelField(),
         new IterationResultField(),
         new TestAttributeField(),
+        new TestLabelField(),
     };
+    
+    private static final List<String> prototypeNames = new ArrayList<String>();
+    static {
+        for (ParameterizedField prototype : prototypes) {
+            prototypeNames.add(prototype.getTypeName());
+        }
+    }
 
-    private int fieldNumber;
+    private String value;
 
     protected ParameterizedField() {
         super("", "");
     }
 
-    public static ParameterizedField fromName(String name) {
-        ParameterizedField prototype = getPrototypeByName(name);
+    public static ParameterizedField newInstance(String typeName, String value) {
+        ParameterizedField prototype = getPrototype(typeName);
         ParameterizedField newField = prototype.freshInstance();
-        newField.initializeFrom(name, prototype.getBaseName());
+        newField.setValue(value);
         return newField;
     }
 
-    public static ParameterizedField fromSqlName(String name) {
-        ParameterizedField prototype = getPrototypeBySqlName(name);
-        ParameterizedField newField = prototype.freshInstance();
-        newField.initializeFrom(name, prototype.getBaseSqlName());
-        return newField;
+    public static Collection<String> getFieldTypeNames() {
+        return Collections.unmodifiableCollection(prototypeNames);
     }
 
-    private static ParameterizedField getPrototype(String name, boolean isSqlName) {
+    private static ParameterizedField getPrototype(String name) {
         for (ParameterizedField prototype : prototypes) {
-            String base;
-            if (isSqlName) {
-                base = prototype.getBaseSqlName();
-            } else {
-                base = prototype.getBaseName();
-            }
-            if (name.startsWith(base)) {
+            if (name.startsWith(prototype.getTypeName())) {
                 return prototype;
             }
         }
-        
+
         throw new IllegalArgumentException("No prototype found for " + name);
     }
-
-    private static ParameterizedField getPrototypeByName(String name) {
-        return getPrototype(name, false);
+    
+    @Override
+    public String getSqlName() {
+        return getBaseSqlName() + getValue();
     }
 
-    private static ParameterizedField getPrototypeBySqlName(String sqlName) {
-        return getPrototype(sqlName, true);
-    }
-
-    private void initializeFrom(String name, String base) {
-        assert name.startsWith(base);
-
-        String numberString = name.substring(base.length()).trim();
-        try {
-            fieldNumber = Integer.valueOf(numberString);
-        } catch (NumberFormatException exc) {
-            throw new IllegalArgumentException("Failed to parse number for header " + name 
-                                               + " (" + numberString + ")");
-        }
-
-        this.name = getBaseName() + " " + numberString;
-        this.sqlName = getBaseSqlName() + numberString;
-    }
-
-    public int getFieldNumber() {
-        return fieldNumber;
+    @Override
+    public void addQueryParameters(JSONObject parameters) {
+        JSONArray fieldValueList = 
+            Utils.setDefaultValue(parameters, getFieldParameterName(), new JSONArray()).isArray();
+        fieldValueList.set(fieldValueList.size(), new JSONString(getValue()));
     }
 
     /**
-     * Parameterized fields create generated items rather than regular items.
+     * @return the prefix of the SQL name generated for each field
      */
-    @Override
-    public Item getItem() {
-        return Item.createGeneratedItem(getName(), getSqlName());
-    }
+    protected abstract String getBaseSqlName();
 
-    public static Item getGenerator(String baseName) {
-        ParameterizedField prototype = getPrototypeByName(baseName);
-        return Item.createGenerator(prototype.getBaseName() + "...", prototype.getBaseSqlName());
-    }
+    /**
+     * @return name of the parameter to pass with a list of field names for this field type
+     */
+    protected abstract String getFieldParameterName();
 
-    @Override
-    public void addHistoryArguments(Map<String, String> arguments) {
-        arguments.put(getName(), getValue());
-    }
+    /**
+     * @return a string identifying this type of field
+     */
+    public abstract String getTypeName();
 
-    @Override
-    public void handleHistoryArguments(Map<String, String> arguments) {
-        assert arguments.containsKey(getName()) : getName();
-        setValue(arguments.get(getName()));
-    }
-
-    public abstract String getBaseSqlName();
-    protected abstract String getBaseName();
-
-    public abstract String getValue();
-    public abstract void setValue(String value);
-
+    /**
+     * @return a new instance of the subclass type.
+     */
     protected abstract ParameterizedField freshInstance();
+
+    @Override
+    public String getName() {
+        return getTypeName() + ": " + getValue();
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public void setValue(String value) {
+        this.value = value;
+    }
+    
+    public Object getIdentifier() {
+        return new FieldIdentifier(this);
+    }
 }
diff --git a/frontend/client/src/autotest/tko/ParameterizedFieldListDisplay.java b/frontend/client/src/autotest/tko/ParameterizedFieldListDisplay.java
index be6f0fd..a4203f1 100644
--- a/frontend/client/src/autotest/tko/ParameterizedFieldListDisplay.java
+++ b/frontend/client/src/autotest/tko/ParameterizedFieldListDisplay.java
@@ -1,7 +1,11 @@
 package autotest.tko;
 
+import autotest.common.ui.ExtendedListBox;
+import autotest.common.ui.SimpleHyperlink;
+import autotest.common.ui.SimplifiedList;
 import autotest.tko.ParameterizedFieldListPresenter.Display;
 
+import com.google.gwt.event.dom.client.HasClickHandlers;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.HasText;
 import com.google.gwt.user.client.ui.HorizontalPanel;
@@ -12,42 +16,64 @@
 import com.google.gwt.user.client.ui.Widget;
 
 public class ParameterizedFieldListDisplay extends Composite implements Display {
-    private static class FieldInput extends Composite implements HasText {
-        private TextBox inputBox = new TextBox();
+    private static class FieldWidget extends Composite implements Display.FieldWidget {
+        private SimpleHyperlink deleteLink = new SimpleHyperlink("[X]");
 
-        public FieldInput(String label) {
+        public FieldWidget(String label) {
             Panel panel = new HorizontalPanel();
-            panel.add(new Label(label + ":"));
-            panel.add(inputBox);
+            panel.add(new Label(label));
+            panel.add(deleteLink);
             initWidget(panel);
         }
 
         @Override
-        public String getText() {
-            return inputBox.getText();
-        }
-
-        @Override
-        public void setText(String text) {
-            inputBox.setText(text);
+        public HasClickHandlers getDeleteLink() {
+            return deleteLink;
         }
     }
     
-    private Panel panel = new VerticalPanel();
+    private ExtendedListBox typeSelect = new ExtendedListBox();
+    private TextBox valueInput = new TextBox();
+    private SimpleHyperlink addLink = new SimpleHyperlink("Add");
+    private Panel fieldListPanel = new VerticalPanel();
     
     public ParameterizedFieldListDisplay() {
-        initWidget(panel);
+        Panel addFieldPanel = new HorizontalPanel();
+        addFieldPanel.add(new Label("Add custom field:"));
+        addFieldPanel.add(typeSelect);
+        addFieldPanel.add(valueInput);
+        addFieldPanel.add(addLink);
+        
+        Panel container = new VerticalPanel();
+        container.add(fieldListPanel);
+        container.add(addFieldPanel);
+        initWidget(container);
     }
 
     @Override
-    public HasText addFieldInput(String name) {
-        FieldInput input = new FieldInput(name);
-        panel.add(input);
-        return input;
+    public HasClickHandlers getAddLink() {
+        return addLink;
     }
 
     @Override
-    public void removeFieldInput(HasText input) {
-        panel.remove((Widget) input);
+    public SimplifiedList getTypeSelect() {
+        return typeSelect;
+    }
+
+    @Override
+    public HasText getValueInput() {
+        return valueInput;
+    }
+    
+    @Override
+    public Display.FieldWidget addFieldWidget(String name) {
+        FieldWidget widget = new FieldWidget(name);
+        fieldListPanel.add(widget);
+        return widget;
+    }
+
+    @Override
+    public void removeFieldWidget(Display.FieldWidget widget) {
+        fieldListPanel.remove((Widget) widget);
     }
 }
diff --git a/frontend/client/src/autotest/tko/ParameterizedFieldListPresenter.java b/frontend/client/src/autotest/tko/ParameterizedFieldListPresenter.java
index 596dce1..6f101fe 100644
--- a/frontend/client/src/autotest/tko/ParameterizedFieldListPresenter.java
+++ b/frontend/client/src/autotest/tko/ParameterizedFieldListPresenter.java
@@ -1,24 +1,40 @@
 package autotest.tko;
 
-import autotest.common.ui.MultiListSelectPresenter;
-import autotest.common.ui.MultiListSelectPresenter.Item;
+import autotest.common.SimpleCallback;
+import autotest.common.ui.NotifyManager;
+import autotest.common.ui.SimplifiedList;
 
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.event.dom.client.HasClickHandlers;
 import com.google.gwt.user.client.ui.HasText;
 
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
-public class ParameterizedFieldListPresenter implements MultiListSelectPresenter.GeneratorHandler {
+public class ParameterizedFieldListPresenter implements ClickHandler {
     public interface Display {
-        public HasText addFieldInput(String name);
-        public void removeFieldInput(HasText input);
-    }
+        public interface FieldWidget {
+            public HasClickHandlers getDeleteLink();
+        }
 
-    private int nameCounter;
+        public SimplifiedList getTypeSelect();
+        public HasText getValueInput();
+        public HasClickHandlers getAddLink();
+        public FieldWidget addFieldWidget(String name); 
+        public void removeFieldWidget(FieldWidget widget);
+    }
+    
     private Display display;
     private HeaderFieldCollection headerFields;
-    private Map<ParameterizedField, HasText> fieldInputMap = 
-        new HashMap<ParameterizedField, HasText>();
+    private Map<ParameterizedField, Display.FieldWidget> fieldInputMap = 
+        new HashMap<ParameterizedField, Display.FieldWidget>();
+    private Set<Object> fieldIds = new HashSet<Object>();
+    private SimpleCallback changeListener;
 
     /**
      * @param headerFields Generated ParameterizedFields will be added to this (and removed when
@@ -30,57 +46,112 @@
 
     public void bindDisplay(Display display) {
         this.display = display;
+        display.getAddLink().addClickHandler(this);
+        populateTypeSelect();
+    }
+
+    public void setListener(SimpleCallback changeListener) {
+        this.changeListener = changeListener;
+    }
+
+    private void populateTypeSelect() {
+        for (String name : ParameterizedField.getFieldTypeNames()) {
+            display.getTypeSelect().addItem(name, name);
+        }
     }
 
     @Override
-    public Item generateItem(Item generatorItem) {
-        String sqlName = generatorItem.value + Integer.toString(nameCounter);
-        nameCounter++;
-        ParameterizedField field = ParameterizedField.fromSqlName(sqlName);
+    public void onClick(ClickEvent event) {
+        assert event.getSource() == display.getAddLink();
+
+        String type = display.getTypeSelect().getSelectedName();
+        String value = display.getValueInput().getText();
+        if (value.isEmpty()) {
+            NotifyManager.getInstance().showError("You must provide a value");
+            return;
+        }
+        
+        ParameterizedField field = createField(type, value);
+        if (fieldIds.contains(field.getIdentifier())) {
+            NotifyManager.getInstance().showError("This field already exists: " + field.getName());
+            return;
+        }
+
         addField(field);
-        return field.getItem();
+        changeListener.doCallback(this);
+        display.getValueInput().setText("");
+    }
+    
+    private ParameterizedField createField(String type, String value) {
+        return ParameterizedField.newInstance(type, value);
     }
 
-    public void addField(ParameterizedField field) {
+    private void addField(final ParameterizedField field) {
         headerFields.add(field);
 
-        // ensure name counter never overlaps this field name
-        if (nameCounter <= field.getFieldNumber()) {
-            nameCounter = field.getFieldNumber() + 1;
-        }
-
-        HasText fieldInput = display.addFieldInput(field.getName());
-        fieldInputMap.put(field, fieldInput);
-    }
-
-    @Override
-    public void onRemoveGeneratedItem(Item generatedItem) {
-        HeaderField field = headerFields.getFieldByName(generatedItem.name);
-        assert field != null;
-        HasText fieldInput = fieldInputMap.remove(field);
-        display.removeFieldInput(fieldInput);
-        headerFields.remove(field);
-    }
-
-    public void updateStateFromView() {
-        for (ParameterizedField field : fieldInputMap.keySet()) {
-            String newValue = fieldInputMap.get(field).getText();
-            field.setValue(newValue);
-        }
-    }
-
-    public void updateViewFromState() {
-        for (ParameterizedField field : fieldInputMap.keySet()) {
-            fieldInputMap.get(field).setText(field.getValue());
-        }
-    }
-
-    public boolean areAllInputsFilled() {
-        for (HasText fieldInput : fieldInputMap.values()) {
-            if (fieldInput.getText().isEmpty()) {
-                return false;
+        Display.FieldWidget fieldWidget = display.addFieldWidget(field.getName());
+        fieldInputMap.put(field, fieldWidget);
+        fieldWidget.getDeleteLink().addClickHandler(new ClickHandler() {
+            @Override
+            public void onClick(ClickEvent event) {
+                deleteField(field);
+                changeListener.doCallback(this);
             }
+        });
+
+        fieldIds.add(field.getIdentifier());
+    }
+
+    public void addFieldIfNotPresent(String type, String name) {
+        ParameterizedField field = createField(type, name);
+        if (!fieldIds.contains(field.getIdentifier())) {
+            addField(field);
         }
-        return true;
+    }
+
+    private void deleteField(ParameterizedField field) {
+        headerFields.remove(field);
+        Display.FieldWidget widget = fieldInputMap.remove(field);
+        display.removeFieldWidget(widget);
+        fieldIds.remove(field.getIdentifier());
+    }
+
+    private String getListKey(int index) {
+        return "parameterized_field_" + Integer.toString(index);
+    }
+
+    public void addHistoryArguments(Map<String, String> arguments) {
+        int index = 0;
+        for (ParameterizedField field : fieldInputMap.keySet()) {
+            String baseKey = getListKey(index);
+            arguments.put(baseKey + "_type", field.getTypeName());
+            arguments.put(baseKey + "_value", field.getValue());
+            index++;
+        }
+    }
+
+    public void handleHistoryArguments(Map<String, String> arguments) {
+        reset();
+        for (int index = 0; ; index++) {
+            String baseKey = getListKey(index);
+            String typeKey = baseKey + "_type";
+            String valueKey = baseKey + "_value";
+            if (!arguments.containsKey(typeKey) || !arguments.containsKey(valueKey)) {
+                break;
+            }
+
+            String typeName = arguments.get(typeKey), value = arguments.get(valueKey);
+            addField(createField(typeName, value));
+        }
+        changeListener.doCallback(this);
+    }
+
+    private void reset() {
+        // avoid ConcurrentModificationException
+        List<ParameterizedField> fieldListCopy =
+            new ArrayList<ParameterizedField>(fieldInputMap.keySet());
+        for (ParameterizedField field : fieldListCopy) {
+            deleteField(field);
+        }
     }
 }
diff --git a/frontend/client/src/autotest/tko/SimpleHeaderField.java b/frontend/client/src/autotest/tko/SimpleHeaderField.java
index 923f4f0..7a616b7 100644
--- a/frontend/client/src/autotest/tko/SimpleHeaderField.java
+++ b/frontend/client/src/autotest/tko/SimpleHeaderField.java
@@ -8,6 +8,6 @@
 
     @Override
     public String getSqlCondition(String value) {
-        return getSimpleSqlCondition(getSqlName(), value);
+        return getSimpleSqlCondition(getQuotedSqlName(), value);
     }
 }
diff --git a/frontend/client/src/autotest/tko/SpreadsheetHeaderSelect.java b/frontend/client/src/autotest/tko/SpreadsheetHeaderSelect.java
new file mode 100644
index 0000000..bcfb79b
--- /dev/null
+++ b/frontend/client/src/autotest/tko/SpreadsheetHeaderSelect.java
@@ -0,0 +1,139 @@
+package autotest.tko;
+
+import autotest.common.Utils;
+import autotest.common.ui.MultiListSelectPresenter;
+import autotest.common.ui.ToggleControl;
+
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.json.client.JSONObject;
+import com.google.gwt.user.client.ui.HasText;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+public class SpreadsheetHeaderSelect implements ClickHandler {
+    public static final String HISTORY_FIXED_VALUES = "_fixed_values";
+
+    public static class State {
+        private HeaderSelect.State baseState = new HeaderSelect.State();
+        private String fixedValues;
+    }
+
+    public interface Display {
+        public MultiListSelectPresenter.DoubleListDisplay getDoubleListDisplay();
+        public MultiListSelectPresenter.ToggleDisplay getToggleDisplay();
+        public HasText getFixedValuesInput();
+        public void setFixedValuesVisible(boolean visible);
+        public ToggleControl getFixedValuesToggle();
+    }
+
+    private Display display;
+    private final State savedState = new State();
+    private HeaderSelect headerSelect;
+
+    public SpreadsheetHeaderSelect(HeaderFieldCollection headerFields) {
+        headerSelect = new HeaderSelect(headerFields, savedState.baseState);
+    }
+
+    public void bindDisplay(Display display) {
+        this.display = display;
+
+        headerSelect.bindDisplay(display.getDoubleListDisplay());
+        headerSelect.multiListSelect.bindToggleDisplay(display.getToggleDisplay());
+        display.getFixedValuesToggle().addClickHandler(this);
+        display.setFixedValuesVisible(false);
+    }
+
+    protected void saveToState(State state) {
+        headerSelect.saveToState(state.baseState);
+        state.fixedValues = getFixedValuesText();
+    }
+
+    private String getFixedValuesText() {
+        if (!isFixedValuesActive()) {
+            return "";
+        }
+        
+        return display.getFixedValuesInput().getText();
+    }
+
+    private List<String> getFixedValues() {
+        String valueText = savedState.fixedValues.trim();
+        if (valueText.equals("")) {
+            return null;
+        }
+        return Utils.splitListWithSpaces(valueText);
+    }
+
+    private boolean isFixedValuesActive() {
+        return !display.getToggleDisplay().getToggleMultipleLink().isActive()
+               && display.getFixedValuesToggle().isActive();
+    }
+
+    public void loadFromState(State state) {
+        headerSelect.loadFromState(state.baseState);
+        display.getFixedValuesInput().setText(state.fixedValues);
+        display.getFixedValuesToggle().setActive(!state.fixedValues.equals(""));
+    }
+
+    public void setSelectedItems(List<HeaderField> fields) {
+        headerSelect.setSelectedItems(fields);
+        savedState.fixedValues = "";
+    }
+
+    public void onClick(ClickEvent event) {
+        assert event.getSource() == display.getFixedValuesToggle();
+        display.setFixedValuesVisible(isFixedValuesActive());
+    }
+
+    public void addHistoryArguments(Map<String, String> arguments, String name) {
+        headerSelect.addHistoryArguments(arguments, name);
+        if (isFixedValuesActive()) {
+            arguments.put(name + HISTORY_FIXED_VALUES, display.getFixedValuesInput().getText());
+        }
+    }
+
+    public void handleHistoryArguments(Map<String, String> arguments, String name) {
+        headerSelect.handleHistoryArguments(arguments, name);
+        String fixedValuesText = arguments.get(name + HISTORY_FIXED_VALUES);
+        savedState.fixedValues = fixedValuesText;
+    }
+
+    public void addQueryParameters(JSONObject parameters) {
+        List<String> fixedValues = getFixedValues();
+        if (fixedValues != null) {
+            JSONObject fixedValuesObject = 
+                Utils.setDefaultValue(parameters, "fixed_headers", new JSONObject()).isObject();
+            fixedValuesObject.put(getSelectedItems().get(0).getSqlName(), 
+                                  Utils.stringsToJSON(fixedValues));
+        }
+    }
+
+    public List<HeaderField> getSelectedItems() {
+        return headerSelect.getSelectedItems();
+    }
+
+    public void refreshFields() {
+        headerSelect.refreshFields();
+    }
+
+    public void setSelectedItem(HeaderField field) {
+        setSelectedItems(Arrays.asList(new HeaderField[] {field}));
+    }
+
+    public State getStateFromView() {
+        State state = new State();
+        saveToState(state);
+        return state;
+    }
+
+    public void updateStateFromView() {
+        saveToState(savedState);
+    }
+
+    public void updateViewFromState() {
+        loadFromState(savedState);
+    }
+}
diff --git a/frontend/client/src/autotest/tko/HeaderSelectorView.java b/frontend/client/src/autotest/tko/SpreadsheetHeaderSelectorView.java
similarity index 74%
rename from frontend/client/src/autotest/tko/HeaderSelectorView.java
rename to frontend/client/src/autotest/tko/SpreadsheetHeaderSelectorView.java
index 9dc0925..5c457c7 100644
--- a/frontend/client/src/autotest/tko/HeaderSelectorView.java
+++ b/frontend/client/src/autotest/tko/SpreadsheetHeaderSelectorView.java
@@ -8,7 +8,6 @@
 import autotest.common.ui.ToggleLink;
 import autotest.common.ui.MultiListSelectPresenter.DoubleListDisplay;
 import autotest.common.ui.MultiListSelectPresenter.ToggleDisplay;
-import autotest.tko.ParameterizedFieldListPresenter.Display;
 
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.HasText;
@@ -17,54 +16,40 @@
 import com.google.gwt.user.client.ui.TextArea;
 import com.google.gwt.user.client.ui.VerticalPanel;
 
-public class HeaderSelectorView extends Composite 
-                                implements HeaderSelect.Display, 
-                                           MultiListSelectPresenter.ToggleDisplay {
-    final static String SWITCH_TO_SINGLE = "Switch to single";
-    final static String SWITCH_TO_MULTIPLE = "Switch to multiple";
+public class SpreadsheetHeaderSelectorView extends Composite 
+                                           implements MultiListSelectPresenter.ToggleDisplay,
+                                                      SpreadsheetHeaderSelect.Display {
+    protected final static String SWITCH_TO_SINGLE = "Switch to single";
+    protected final static String SWITCH_TO_MULTIPLE = "Switch to multiple";
     static final String CANCEL_FIXED_VALUES = "Don't use fixed values";
     static final String USE_FIXED_VALUES = "Fixed values...";
-
-    private ExtendedListBox listBox = new ExtendedListBox();
+    
     private ToggleLink fixedValuesToggle = new ToggleLink(USE_FIXED_VALUES, CANCEL_FIXED_VALUES);
     private TextArea fixedValues = new TextArea();
+    private ExtendedListBox listBox = new ExtendedListBox();
     private DoubleListSelector doubleListDisplay = new DoubleListSelector();
     private StackPanel stack = new StackPanel();
     private ToggleLink multipleSelectToggle = new ToggleLink(SWITCH_TO_MULTIPLE, SWITCH_TO_SINGLE);
-    private ParameterizedFieldListDisplay parameterizedFieldDisplay = 
-        new ParameterizedFieldListDisplay();
     
-    public HeaderSelectorView() {
+    public SpreadsheetHeaderSelectorView() {
         Panel singleHeaderOptions = new VerticalPanel();
         singleHeaderOptions.add(listBox);
-        singleHeaderOptions.add(fixedValuesToggle);
-        singleHeaderOptions.add(fixedValues);
+        
         stack.add(singleHeaderOptions);
         stack.add(doubleListDisplay);
 
+        Panel fixedValuePanel = new VerticalPanel();
+        fixedValuePanel.add(fixedValuesToggle);
+        fixedValuePanel.add(fixedValues);
+
         Panel panel = new VerticalPanel();
         panel.add(stack);
+        panel.add(fixedValuePanel);
         panel.add(multipleSelectToggle);
-        panel.add(parameterizedFieldDisplay);
         initWidget(panel);
 
         fixedValues.setVisible(false);
-        fixedValues.setSize("30em", "10em");
-    }
-
-    @Override
-    public DoubleListDisplay getDoubleListDisplay() {
-        return doubleListDisplay;
-    }
-
-    @Override
-    public ToggleDisplay getToggleDisplay() {
-        return this;
-    }
-
-    @Override
-    public Display getParameterizedFieldDisplay() {
-        return parameterizedFieldDisplay;
+        fixedValues.addStyleName("fixed-headers-input");
     }
 
     @Override
@@ -78,6 +63,21 @@
     }
 
     @Override
+    public void setFixedValuesVisible(boolean visible) {
+        fixedValues.setVisible(visible);
+    }
+
+    @Override
+    public DoubleListDisplay getDoubleListDisplay() {
+        return doubleListDisplay;
+    }
+
+    @Override
+    public ToggleDisplay getToggleDisplay() {
+        return this;
+    }
+
+    @Override
     public SimplifiedList getSingleSelector() {
         return listBox;
     }
@@ -91,9 +91,4 @@
     public void setDoubleListVisible(boolean doubleListVisible) {
         stack.showStack(doubleListVisible ? 1 : 0);
     }
-
-    @Override
-    public void setFixedValuesVisible(boolean visible) {
-        fixedValues.setVisible(visible);
-    }
 }
diff --git a/frontend/client/src/autotest/tko/SpreadsheetView.java b/frontend/client/src/autotest/tko/SpreadsheetView.java
index adec4df..11d8b76 100644
--- a/frontend/client/src/autotest/tko/SpreadsheetView.java
+++ b/frontend/client/src/autotest/tko/SpreadsheetView.java
@@ -62,13 +62,13 @@
     private static JsonRpcProxy afeRpcProxy = JsonRpcProxy.getProxy(JsonRpcProxy.AFE_BASE_URL);
     private TableSwitchListener listener;
     protected Map<String,String[]> drilldownMap = new HashMap<String,String[]>();
-    private HeaderFieldCollection headerFields = new HeaderFieldCollection();
+    private HeaderFieldCollection headerFields = commonPanel.getHeaderFields();
     
-    private HeaderSelect rowSelect = new HeaderSelect(headerFields);
-    private HeaderSelectorView rowSelectDisplay = new HeaderSelectorView();
-    private HeaderSelect columnSelect = new HeaderSelect(headerFields);
-    private HeaderSelectorView columnSelectDisplay = new HeaderSelectorView();
-    private ContentSelect contentSelect = new ContentSelect();
+    private SpreadsheetHeaderSelect rowSelect = new SpreadsheetHeaderSelect(headerFields);
+    private SpreadsheetHeaderSelectorView rowSelectDisplay = new SpreadsheetHeaderSelectorView();
+    private SpreadsheetHeaderSelect columnSelect = new SpreadsheetHeaderSelect(headerFields);
+    private SpreadsheetHeaderSelectorView columnSelectDisplay = new SpreadsheetHeaderSelectorView();
+    private ContentSelect contentSelect = new ContentSelect(headerFields);
     private CheckBox showIncomplete = new CheckBox("Show incomplete tests");
     private CheckBox showOnlyLatest = new CheckBox("Show only latest test per cell");
     private Button queryButton = new Button("Query");
@@ -83,9 +83,12 @@
     private Panel jobCompletionPanel = new SimplePanel();
     private boolean currentShowIncomplete, currentShowOnlyLatest;
     private boolean notYetQueried = true;
+
     public SpreadsheetView(TableSwitchListener listener) {
         this.listener = listener;
         commonPanel.addListener(this);
+        rowSelect.bindDisplay(rowSelectDisplay);
+        columnSelect.bindDisplay(columnSelectDisplay);
     }
     
     @Override
@@ -97,17 +100,13 @@
     public void initialize() {
         super.initialize();
 
+        setHeaderSelectField(rowSelect, DEFAULT_ROW);
+        setHeaderSelectField(columnSelect, DEFAULT_COLUMN);
+
         actionsPanel.setActionsWithCsvListener(this);
         actionsPanel.setSelectionListener(this);
         actionsPanel.setVisible(false);
 
-        headerFields.populateFromList("group_fields");
-        setupHeaderSelect(rowSelect, rowSelectDisplay, DEFAULT_ROW);
-        setupHeaderSelect(columnSelect, columnSelectDisplay, DEFAULT_COLUMN);
-
-        for (HeaderField field : headerFields) {
-            contentSelect.addItem(field);
-        }
         contentSelect.addValueChangeHandler(new ValueChangeHandler<Boolean>() {
             public void onValueChange(ValueChangeEvent<Boolean> event) {                
                 if (event.getValue()) {
@@ -134,7 +133,7 @@
         SimpleHyperlink swapLink = new SimpleHyperlink("swap");
         swapLink.addClickHandler(new ClickHandler() {
             public void onClick(ClickEvent event) {
-                HeaderSelect.State rowState = rowSelect.getStateFromView();
+                SpreadsheetHeaderSelect.State rowState = rowSelect.getStateFromView();
                 rowSelect.loadFromState(columnSelect.getStateFromView());
                 columnSelect.loadFromState(rowState);
             } 
@@ -164,10 +163,9 @@
         setupDrilldownMap();
     }
 
-    private void setupHeaderSelect(HeaderSelect headerSelect, HeaderSelectorView display, 
-                                   String defaultField) {
-        headerSelect.bindDisplay(display);
-        headerSelect.selectItem(headerFields.getFieldBySqlName(defaultField));
+    private void setHeaderSelectField(SpreadsheetHeaderSelect headerSelect,  
+                                      String defaultField) {
+        headerSelect.setSelectedItem(headerFields.getFieldBySqlName(defaultField));
     }
 
     protected TestSet getWholeTableTestSet() {
@@ -204,7 +202,7 @@
     }
     
     protected void setSelectedHeader(HeaderSelect list, List<HeaderField> fields) {
-        list.selectItems(fields);
+        list.setSelectedItems(fields);
     }
 
     @Override
@@ -279,12 +277,7 @@
             NotifyManager.getInstance().showError("You must select row and column fields");
             return;
         }
-        if (!rowSelect.checkMachineLabelHeaders() || !columnSelect.checkMachineLabelHeaders()) {
-            NotifyManager.getInstance().showError(
-                      "You must enter labels for all machine label fields");
-            return;
-        }
-        
+
         updateStateFromView();
         refresh();
     }
@@ -419,8 +412,8 @@
                                                   String newColumnField) {
         saveHistoryState();
         commonPanel.refineCondition(tests);
-        rowSelect.selectItem(headerFields.getFieldBySqlName(newRowField));
-        columnSelect.selectItem(headerFields.getFieldBySqlName(newColumnField));
+        rowSelect.setSelectedItem(headerFields.getFieldBySqlName(newRowField));
+        columnSelect.setSelectedItem(headerFields.getFieldBySqlName(newColumnField));
         HistoryToken historyArguments = getHistoryArguments();
         restoreHistoryState();
         return historyArguments;
@@ -543,8 +536,10 @@
     protected void fillDefaultHistoryValues(Map<String, String> arguments) {
         Utils.setDefaultValue(arguments, HISTORY_ROW, DEFAULT_ROW);
         Utils.setDefaultValue(arguments, HISTORY_COLUMN, DEFAULT_COLUMN);
-        Utils.setDefaultValue(arguments, HISTORY_ROW + HeaderSelect.HISTORY_FIXED_VALUES, "");
-        Utils.setDefaultValue(arguments, HISTORY_COLUMN + HeaderSelect.HISTORY_FIXED_VALUES, "");
+        Utils.setDefaultValue(arguments, 
+                              HISTORY_ROW + SpreadsheetHeaderSelect.HISTORY_FIXED_VALUES, "");
+        Utils.setDefaultValue(arguments, 
+                              HISTORY_COLUMN + SpreadsheetHeaderSelect.HISTORY_FIXED_VALUES, "");
         Utils.setDefaultValue(arguments, HISTORY_SHOW_INCOMPLETE, Boolean.toString(false));
         Utils.setDefaultValue(arguments, HISTORY_ONLY_LATEST, Boolean.toString(false));
     }
@@ -600,10 +595,18 @@
         NotifyManager.getInstance().setLoading(loading);
     }
 
+    @Override
     public void onSetControlsVisible(boolean visible) {
         TkoUtils.setElementVisible("ss_all_controls", visible);
         if (isTabVisible()) {
             spreadsheet.fillWindow(true);
         }
     }
+
+    @Override
+    public void onFieldsChanged() {
+        rowSelect.refreshFields();
+        columnSelect.refreshFields();
+        contentSelect.refreshFields();
+    }
 }
diff --git a/frontend/client/src/autotest/tko/StringParameterizedField.java b/frontend/client/src/autotest/tko/StringParameterizedField.java
deleted file mode 100644
index c7e66eb6..0000000
--- a/frontend/client/src/autotest/tko/StringParameterizedField.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package autotest.tko;
-
-public abstract class StringParameterizedField extends ParameterizedField {
-    private String attribute;
-
-    @Override
-    public String getValue() {
-        return attribute;
-    }
-
-    @Override
-    public void setValue(String value) {
-        attribute = value;
-    }
-}
diff --git a/frontend/client/src/autotest/tko/TableView.java b/frontend/client/src/autotest/tko/TableView.java
index 8c952e0..ae29e67 100644
--- a/frontend/client/src/autotest/tko/TableView.java
+++ b/frontend/client/src/autotest/tko/TableView.java
@@ -42,9 +42,7 @@
 import java.util.List;
 import java.util.ListIterator;
 import java.util.Map;
-import java.util.Set;
 
-// TODO: consolidate logic between this and HeaderSelect
 public class TableView extends ConditionTabView 
                        implements DynamicTableListener, TableActionsWithExportCsvListener, 
                                   ClickHandler, TableWidgetFactory, CommonPanelListener, 
@@ -83,7 +81,17 @@
         public String getSqlCondition(String value) {
             throw new UnsupportedOperationException();
         }
+
+        @Override
+        public boolean isUserSelectable() {
+            return false;
+        }
     }
+    
+    private GroupCountField groupCountField =
+        new GroupCountField(COUNT_NAME, TestGroupDataSource.GROUP_COUNT_FIELD);
+    private GroupCountField statusCountsField =
+        new GroupCountField(STATUS_COUNTS_NAME, DataTable.WIDGET_COLUMN);
 
     private TestSelectionListener listener;
     
@@ -92,27 +100,19 @@
     private SelectionManager selectionManager;
     private SimpleFilter sqlConditionFilter = new SimpleFilter();
     private RpcDataSource testDataSource = new TestViewDataSource();
-    private RpcDataSource iterationDataSource = new IterationDataSource();
     private TestGroupDataSource groupDataSource = TestGroupDataSource.getTestGroupDataSource();
 
-    private HeaderFieldCollection headerFields = new HeaderFieldCollection();
-    private MultiListSelectPresenter columnSelect = new MultiListSelectPresenter();
-    private ParameterizedFieldListPresenter parameterizedFieldPresenter =
-        new ParameterizedFieldListPresenter(headerFields);
+    private HeaderFieldCollection headerFields = commonPanel.getHeaderFields();
+    private HeaderSelect columnSelect = new HeaderSelect(headerFields, new HeaderSelect.State());
 
     private DoubleListSelector columnSelectDisplay = new DoubleListSelector();
-    private ParameterizedFieldListDisplay parameterizedFieldDisplay =
-        new ParameterizedFieldListDisplay();
     private CheckBox groupCheckbox = new CheckBox("Group by these columns and show counts");
     private CheckBox statusGroupCheckbox = 
         new CheckBox("Group by these columns and show pass rates");
     private Button queryButton = new Button("Query");
     private Panel tablePanel = new SimplePanel();
 
-    private HeaderFieldCollection savedColumns = new HeaderFieldCollection();
     private List<SortSpec> tableSorts = new ArrayList<SortSpec>();
-    private Item iterationGenerator = 
-        ParameterizedField.getGenerator(IterationResultField.BASE_NAME);
 
     public enum TableViewConfig {
         DEFAULT, PASS_RATE, TRIAGE
@@ -125,6 +125,8 @@
     public TableView(TestSelectionListener listener) {
         this.listener = listener;
         commonPanel.addListener(this);
+        columnSelect.setGeneratorHandler(this);
+        columnSelect.bindDisplay(columnSelectDisplay);
     }
 
     @Override
@@ -136,19 +138,8 @@
     public void initialize() {
         super.initialize();
 
-        columnSelect.setGeneratorHandler(this);
-        columnSelect.bindDisplay(columnSelectDisplay);
-
-        headerFields.populateFromList("all_fields");
-        parameterizedFieldPresenter.bindDisplay(parameterizedFieldDisplay);
-
-        for (HeaderField field : headerFields) {
-            columnSelect.addItem(field.getItem());
-        }
-        columnSelect.addItem(iterationGenerator);
-
-        headerFields.add(new GroupCountField(COUNT_NAME, TestGroupDataSource.GROUP_COUNT_FIELD));
-        headerFields.add(new GroupCountField(STATUS_COUNTS_NAME, DataTable.WIDGET_COLUMN));
+        headerFields.add(groupCountField);
+        headerFields.add(statusCountsField);
 
         selectColumnsByName(DEFAULT_COLUMNS);
         updateViewFromState();
@@ -159,7 +150,6 @@
         
         Panel columnPanel = new VerticalPanel();
         columnPanel.add(columnSelectDisplay);
-        columnPanel.add(parameterizedFieldDisplay);
         columnPanel.add(groupCheckbox);
         columnPanel.add(statusGroupCheckbox);
         
@@ -169,10 +159,11 @@
     }
     
     private void selectColumnsByName(String[] columnNames) {
-        savedColumns.clear();
+        List<HeaderField> fields = new ArrayList<HeaderField>();
         for (String name : columnNames) {
-            savedColumns.add(headerFields.getFieldByName(name));
+            fields.add(headerFields.getFieldByName(name));
         }
+        columnSelect.setSelectedItems(fields);
         cleanupSortsForNewColumns();
     }
     
@@ -219,23 +210,24 @@
     }
 
     private String[][] buildColumnSpecs() {
-        int numColumns = savedColumns.size();
+        int numColumns = savedColumns().size();
         String[][] columns = new String[numColumns][2];
         int i = 0;
-        for (HeaderField field : savedColumns) {
-            columns[i][0] = field.getAttributeName();
+        for (HeaderField field : savedColumns()) {
+            columns[i][0] = field.getSqlName();
             columns[i][1] = field.getName();
             i++;
         }
         return columns;
     }
 
+    private List<HeaderField> savedColumns() {
+        return columnSelect.getSelectedItems();
+    }
+
     private RpcDataSource getDataSource() {
         GroupingType groupingType = getActiveGrouping();
         if (groupingType == GroupingType.NO_GROUPING) {
-            if (isIterationFieldPresentIn(savedColumns)) {
-                return iterationDataSource;
-            }
             return testDataSource;
         } else if (groupingType == GroupingType.TEST_GROUPING) {
             groupDataSource = TestGroupDataSource.getTestGroupDataSource();
@@ -247,48 +239,21 @@
         return groupDataSource;
     }
 
-    private boolean isIterationFieldPresentIn(Collection<HeaderField> fields) {
-        for (HeaderField field : fields) {
-            if (field.getName().startsWith(IterationResultField.BASE_NAME)) {
-                return true;
-            }
-        }
-        return false;
-    }
-
     private void updateStateFromView() {
         commonPanel.updateStateFromView();
-        parameterizedFieldPresenter.updateStateFromView();
-        savedColumns.clear();
-        for (Item item : columnSelect.getSelectedItems()) {
-            savedColumns.add(headerFields.getFieldByName(item.name));
-        }
+        columnSelect.updateStateFromView();
     }
     
     private void updateViewFromState() {
         commonPanel.updateViewFromState();
-        selectColumnsInView();
-        parameterizedFieldPresenter.updateViewFromState();
-    }
-
-    private void selectColumnsInView() {
-        List<String> fieldNames = new ArrayList<String>();
-        for (HeaderField field : savedColumns) {
-            Item item = field.getItem();
-            if (item.isGeneratedItem) {
-                columnSelect.addItem(item);
-            }
-            fieldNames.add(field.getName());
-        }
-        columnSelect.setSelectedItemsByName(fieldNames);
-        updateCheckboxesFromFields();
+        columnSelect.updateViewFromState();
     }
 
     private void updateGroupColumns() {
         List<String> groupFields = new ArrayList<String>();
-        for (HeaderField field : savedColumns) {
+        for (HeaderField field : savedColumns()) {
             if (!isGroupField(field)) {
-                groupFields.add(field.getAttributeName());
+                groupFields.add(field.getSqlName());
             }
         }
 
@@ -325,16 +290,16 @@
 
         if (tableSorts.isEmpty()) {
             // default to sorting on the first column
-            HeaderField field = savedColumns.iterator().next();
-            SortSpec sortSpec = new SortSpec(field.getAttributeName(), SortDirection.ASCENDING);
+            HeaderField field = savedColumns().iterator().next();
+            SortSpec sortSpec = new SortSpec(field.getSqlName(), SortDirection.ASCENDING);
             tableSorts = new ArrayList<SortSpec>();
             tableSorts.add(sortSpec);
         }
     }
 
     private boolean isAttributeSelected(String attribute) {
-        for (HeaderField field : savedColumns) {
-            if (field.getAttributeName().equals(attribute)) {
+        for (HeaderField field : savedColumns()) {
+            if (field.getSqlName().equals(attribute)) {
                 return true;
             }
         }
@@ -345,24 +310,16 @@
     public void refresh() {
         createTable();
         JSONObject condition = commonPanel.getConditionArgs();
-        for (HeaderField field : headerFields) {
-            field.addQueryParameters(condition);
-        }
         sqlConditionFilter.setAllParameters(condition);
         table.refresh();
     }
     
     @Override
     public void doQuery() {
-        if (columnSelect.getSelectedItems().isEmpty()) {
+        if (savedColumns().isEmpty()) {
             NotifyManager.getInstance().showError("You must select columns");
             return;
         }
-        if (!parameterizedFieldPresenter.areAllInputsFilled()) {
-            NotifyManager.getInstance().showError(
-                    "You must enter attributes for all iteration fields");
-            return;
-        }
         updateStateFromView();
         refresh();
     }
@@ -428,12 +385,12 @@
         }
 
         ConditionTestSet testSet = new ConditionTestSet(commonPanel.getConditionArgs());
-        for (HeaderField field : savedColumns) {
+        for (HeaderField field : savedColumns()) {
             if (isGroupField(field)) {
                 continue;
             }
 
-            String value = Utils.jsonToString(row.get(field.getAttributeName()));
+            String value = Utils.jsonToString(row.get(field.getSqlName()));
             testSet.addCondition(field.getSqlCondition(value));
         }
         return testSet;
@@ -456,13 +413,6 @@
     private void setCheckboxesEnabled() {
         assert !(groupCheckbox.getValue() && statusGroupCheckbox.getValue());
 
-        // grouping is not currently supported with iteration results
-        if (isIterationFieldPresentIn(headerFields)) {
-            groupCheckbox.setEnabled(false);
-            statusGroupCheckbox.setEnabled(false);
-            return;
-        }
-
         groupCheckbox.setEnabled(true);
         statusGroupCheckbox.setEnabled(true);
         if (groupCheckbox.getValue()) {
@@ -473,13 +423,13 @@
     }
 
     private void updateFieldsFromCheckboxes() {
-        ensureItemRemoved(COUNT_NAME);
-        ensureItemRemoved(STATUS_COUNTS_NAME);
+        columnSelect.deselectItemInView(groupCountField);
+        columnSelect.deselectItemInView(statusCountsField);
 
         if (groupCheckbox.getValue()) {
-            addSpecialItem(COUNT_NAME);
+            columnSelect.selectItemInView(groupCountField);
         } else if (statusGroupCheckbox.getValue()) {
-            addSpecialItem(STATUS_COUNTS_NAME);
+            columnSelect.selectItemInView(statusCountsField);
         }
     }
 
@@ -487,36 +437,15 @@
         groupCheckbox.setValue(false);
         statusGroupCheckbox.setValue(false);
 
-        Set<String> selectedNames = 
-            MultiListSelectPresenter.getItemNameSet(columnSelect.getSelectedItems());
-        if (selectedNames.contains(COUNT_NAME)) {
+        GroupingType grouping = getGroupingFromFields(
+            columnSelect.getStateFromView().getSelectedFields());
+        if (grouping == GroupingType.TEST_GROUPING) {
             groupCheckbox.setValue(true);
-        } 
-        if (selectedNames.contains(STATUS_COUNTS_NAME)) {
+        } else if (grouping == GroupingType.STATUS_COUNTS) {
             statusGroupCheckbox.setValue(true);
         }
 
         setCheckboxesEnabled();
-        updateIterationGenerator();
-    }
-    
-    private void updateIterationGenerator() {
-        ensureItemRemoved(iterationGenerator.name);
-        if (!groupCheckbox.getValue() && !statusGroupCheckbox.getValue()) {
-            columnSelect.addItem(iterationGenerator);
-        }
-    }
-
-    private void addSpecialItem(String itemName) {
-        HeaderField field = headerFields.getFieldByName(itemName);
-        assert isGroupField(field);
-        columnSelect.addItem(field.getItem());
-    }
-
-    private void ensureItemRemoved(String itemName) {
-        try {
-            columnSelect.removeItemByName(itemName);
-        } catch (IllegalArgumentException exc) {}
     }
 
     public ContextMenu getActionMenu() {
@@ -537,42 +466,21 @@
     public HistoryToken getHistoryArguments() {
         HistoryToken arguments = super.getHistoryArguments();
         if (table != null) {
-            arguments.put("columns", Utils.joinStrings(",", getSavedColumnNames()));
+            columnSelect.addHistoryArguments(arguments, "columns");
             arguments.put("sort", Utils.joinStrings(",", tableSorts));
             commonPanel.addHistoryArguments(arguments);
         }
-        headerFields.addHistoryArguments(arguments);
         return arguments;
     }
 
-    private List<String> getSavedColumnNames() {
-        List<String> names = new ArrayList<String>();
-        for (HeaderField field : savedColumns) {
-            names.add(field.getName());
-        }
-        return names;
-    }
-
     @Override
     public void handleHistoryArguments(Map<String, String> arguments) {
         super.handleHistoryArguments(arguments);
-        String[] columns = arguments.get("columns").split(",");
-        addParameterizedFields(columns);
-        selectColumnsByName(columns);
-        savedColumns.handleHistoryArguments(arguments);
+        columnSelect.handleHistoryArguments(arguments, "columns");
         handleSortString(arguments.get("sort"));
         updateViewFromState();
     }
 
-    private void addParameterizedFields(String[] columns) {
-        for (String name : columns) {
-            if (!headerFields.containsName(name)) {
-                ParameterizedField field = ParameterizedField.fromName(name);
-                parameterizedFieldPresenter.addField(field);
-            }
-        }
-    }
-
     @Override
     protected void fillDefaultHistoryValues(Map<String, String> arguments) {
         HeaderField defaultSortField = headerFields.getFieldByName(DEFAULT_COLUMNS[0]);
@@ -596,23 +504,11 @@
         } else if (event.getSource() == groupCheckbox || event.getSource() == statusGroupCheckbox) {
             updateFieldsFromCheckboxes();
             setCheckboxesEnabled();
-            updateIterationGenerator();
         }
     }
 
     @Override
-    public Item generateItem(Item generatorItem) {
-        Item item = parameterizedFieldPresenter.generateItem(generatorItem);
-        updateCheckboxesFromFields();
-        return item;
-    }
-
-    @Override
     public void onRemoveGeneratedItem(Item generatedItem) {
-        HeaderField field = headerFields.getFieldByName(generatedItem.name);
-        if (!isGroupField(field)) {
-            parameterizedFieldPresenter.onRemoveGeneratedItem(generatedItem);
-        }
         updateCheckboxesFromFields();
     }
 
@@ -620,8 +516,8 @@
         return getActiveGrouping() != GroupingType.NO_GROUPING;
     }
     
-    private GroupingType getActiveGrouping() {
-        for (HeaderField field : savedColumns) {
+    private GroupingType getGroupingFromFields(List<HeaderField> fields) {
+        for (HeaderField field : fields) {
             if (field.getName().equals(COUNT_NAME)) {
                 return GroupingType.TEST_GROUPING;
             }
@@ -631,6 +527,13 @@
         }
         return GroupingType.NO_GROUPING;
     }
+    
+    /**
+     * Get grouping currently active for displayed table.
+     */
+    private GroupingType getActiveGrouping() {
+        return getGroupingFromFields(savedColumns());
+    }
 
     public Widget createWidget(int row, int cell, JSONObject rowObject) {
         assert getActiveGrouping() == GroupingType.STATUS_COUNTS;
@@ -647,10 +550,16 @@
         return table != null;
     }
 
+    @Override
     public void onSetControlsVisible(boolean visible) {
         TkoUtils.setElementVisible("table_all_controls", visible);
     }
 
+    @Override
+    public void onFieldsChanged() {
+        columnSelect.refreshFields();
+    }
+
     public void onExportCsv() {
         JSONObject extraParams = new JSONObject();
         extraParams.put("columns", buildCsvColumnSpecs());
diff --git a/frontend/client/src/autotest/tko/TestAttributeField.java b/frontend/client/src/autotest/tko/TestAttributeField.java
index 2eb8dc4..f1ceb75 100644
--- a/frontend/client/src/autotest/tko/TestAttributeField.java
+++ b/frontend/client/src/autotest/tko/TestAttributeField.java
@@ -1,13 +1,8 @@
 package autotest.tko;
 
-import autotest.common.Utils;
 
-import com.google.gwt.json.client.JSONArray;
-import com.google.gwt.json.client.JSONObject;
-import com.google.gwt.json.client.JSONString;
-
-public class TestAttributeField extends StringParameterizedField {
-    public static final String BASE_NAME = "Test attribute";
+public class TestAttributeField extends AttributeField {
+    public static final String TYPE_NAME = "Test attribute";
 
     @Override
     protected ParameterizedField freshInstance() {
@@ -15,30 +10,17 @@
     }
 
     @Override
-    protected String getBaseName() {
-        return BASE_NAME;
+    public String getTypeName() {
+        return TYPE_NAME;
+    }
+
+    @Override
+    protected String getFieldParameterName() {
+        return "test_attribute_fields";
     }
 
     @Override
     public String getBaseSqlName() {
         return "attribute_";
     }
-
-    @Override
-    public String getAttributeName() {
-        return "attribute_" + getValue();
-    }
-
-    @Override
-    public void addQueryParameters(JSONObject parameters) {
-        JSONArray testAttributes = 
-            Utils.setDefaultValue(parameters, "test_attributes", new JSONArray()).isArray();
-        testAttributes.set(testAttributes.size(), new JSONString(getValue()));
-    }
-
-    @Override
-    public String getSqlCondition(String value) {
-        return getSimpleSqlCondition(getAttributeName(), value);
-    }
-
 }
diff --git a/frontend/client/src/autotest/tko/TestLabelField.java b/frontend/client/src/autotest/tko/TestLabelField.java
new file mode 100644
index 0000000..69eb009
--- /dev/null
+++ b/frontend/client/src/autotest/tko/TestLabelField.java
@@ -0,0 +1,26 @@
+package autotest.tko;
+
+public class TestLabelField extends LabelField {
+    public static final String TYPE_NAME = "Test label";
+
+    @Override
+    protected ParameterizedField freshInstance() {
+        return new TestLabelField();
+    }
+
+    @Override
+    protected String getBaseSqlName() {
+        return "label_";
+    }
+
+    @Override
+    protected String getFieldParameterName() {
+        return "test_label_fields";
+    }
+
+    @Override
+    public String getTypeName() {
+        return TYPE_NAME;
+    }
+
+}
diff --git a/new_tko/tko/models.py b/new_tko/tko/models.py
index daf1368..61b24af 100644
--- a/new_tko/tko/models.py
+++ b/new_tko/tko/models.py
@@ -2,6 +2,8 @@
 from django.utils import datastructures
 from autotest_lib.frontend.afe import model_logic, readonly_connection
 
+_quote_name = connection.ops.quote_name
+
 class TempManager(model_logic.ExtendedManager):
     _GROUP_COUNT_NAME = 'group_count'
 
@@ -324,39 +326,209 @@
 
 
     def _get_label_ids_from_names(self, label_names):
-        assert label_names
         label_ids = list( # listifying avoids a double query below
-                TestLabel.objects.filter(name__in=label_names).values('id'))
+                TestLabel.objects.filter(name__in=label_names)
+                .values_list('name', 'id'))
         if len(label_ids) < len(set(label_names)):
                 raise ValueError('Not all labels found: %s' %
                                  ', '.join(label_names))
-        return [str(label['id']) for label in label_ids]
+        return dict(name_and_id for name_and_id in label_ids)
 
 
     def _include_or_exclude_labels(self, query_set, label_names, exclude=False):
-        label_ids = self._get_label_ids_from_names(label_names)
+        label_ids = self._get_label_ids_from_names(label_names).itervalues()
         suffix = self._get_include_exclude_suffix(exclude)
         condition = ('tko_test_labels_tests%s.testlabel_id IN (%s)' %
-                     (suffix, ','.join(label_ids)))
+                     (suffix,
+                      ','.join(str(label_id) for label_id in label_ids)))
         return self._add_label_pivot_table_join(query_set,
                                                 join_condition=condition,
                                                 suffix=suffix,
                                                 exclude=exclude)
 
 
-    def get_query_set_with_joins(self, filter_data, include_host_labels=False):
-        include_labels = filter_data.pop('include_labels', [])
-        exclude_labels = filter_data.pop('exclude_labels', [])
+    def _add_custom_select(self, query_set, select_name, select_sql):
+        return query_set.extra(select={select_name: select_sql})
+
+
+    def _add_select_value(self, query_set, alias):
+        return self._add_custom_select(query_set, alias,
+                                       _quote_name(alias) + '.value')
+
+
+    def _add_select_ifnull(self, query_set, alias, non_null_value):
+        select_sql = "IF(%s.id IS NOT NULL, '%s', NULL)" % (_quote_name(alias),
+                                                            non_null_value)
+        return self._add_custom_select(query_set, alias, select_sql)
+
+
+    def _join_label_column(self, query_set, label_name, label_id):
+        table_name = TestLabel.tests.field.m2m_db_table()
+        alias = 'label_' + label_name
+        condition = "%s.testlabel_id = %s" % (_quote_name(alias), label_id)
+        query_set = self.add_join(query_set, table_name,
+                                  join_key='test_id', join_condition=condition,
+                                  alias=alias, force_left_join=True)
+
+        query_set = self._add_select_ifnull(query_set, alias, label_name)
+        return query_set
+
+
+    def _join_label_columns(self, query_set, label_names):
+        label_id_map = self._get_label_ids_from_names(label_names)
+        for label_name in label_names:
+            query_set = self._join_label_column(query_set, label_name,
+                                                label_id_map[label_name])
+        return query_set
+
+
+    def _join_attribute(self, test_view_query_set, attribute,
+                        alias=None, extra_join_condition=None):
+        """
+        Join the given TestView QuerySet to TestAttribute.  The resulting query
+        has an additional column for the given attribute named
+        "attribute_<attribute name>".
+        """
+        table_name = TestAttribute._meta.db_table
+        if not alias:
+            alias = 'attribute_' + attribute
+        condition = "%s.attribute = '%s'" % (_quote_name(alias),
+                                             self.escape_user_sql(attribute))
+        if extra_join_condition:
+            condition += ' AND (%s)' % extra_join_condition
+        query_set = self.add_join(test_view_query_set, table_name,
+                                  join_key='test_idx', join_condition=condition,
+                                  alias=alias, force_left_join=True)
+
+        query_set = self._add_select_value(query_set, alias)
+        return query_set
+
+
+    def _join_machine_label_columns(self, query_set, machine_label_names):
+        for label_name in machine_label_names:
+            alias = 'machine_label_' + label_name
+            condition = "FIND_IN_SET('%s', %s)" % (
+                    label_name, _quote_name(alias) + '.value')
+            query_set = self._join_attribute(query_set, 'host-labels',
+                                             alias=alias,
+                                             extra_join_condition=condition)
+            query_set = self._add_select_ifnull(query_set, alias, label_name)
+        return query_set
+
+
+    def _join_one_iteration_key(self, query_set, result_key, first_alias=None):
+        table_name = IterationResult._meta.db_table
+        alias = 'iteration_' + result_key
+        condition_parts = ["%s.attribute = '%s'" %
+                           (_quote_name(alias),
+                            self.escape_user_sql(result_key))]
+        if first_alias:
+            # after the first join, we need to match up iteration indices,
+            # otherwise each join will expand the query by the number of
+            # iterations and we'll have extraneous rows
+            condition_parts.append('%s.iteration = %s.iteration' %
+                                   (_quote_name(alias),
+                                    _quote_name(first_alias)))
+
+        condition = ' and '.join(condition_parts)
+        # add a join to IterationResult
+        query_set = self.add_join(query_set, table_name, join_key='test_idx',
+                                  join_condition=condition, alias=alias)
+        # select the iteration value and index for this join
+        query_set = self._add_select_value(query_set, alias)
+        if not first_alias:
+            # for first join, add iteration index select too
+            query_set = self._add_custom_select(
+                    query_set, 'iteration_index',
+                    _quote_name(alias) + '.iteration')
+
+        return query_set, alias
+
+
+    def _join_iterations(self, test_view_query_set, result_keys):
+        """Join the given TestView QuerySet to IterationResult for one result.
+
+        The resulting query looks like a TestView query but has one row per
+        iteration.  Each row includes all the attributes of TestView, an
+        attribute for each key in result_keys and an iteration_index attribute.
+
+        We accomplish this by joining the TestView query to IterationResult
+        once per result key.  Each join is restricted on the result key (and on
+        the test index, like all one-to-many joins).  For the first join, this
+        is the only restriction, so each TestView row expands to a row per
+        iteration (per iteration that includes the key, of course).  For each
+        subsequent join, we also restrict the iteration index to match that of
+        the initial join.  This makes each subsequent join produce exactly one
+        result row for each input row.  (This assumes each iteration contains
+        the same set of keys.  Results are undefined if that's not true.)
+        """
+        if not result_keys:
+            return test_view_query_set
+
+        query_set, first_alias = self._join_one_iteration_key(
+                test_view_query_set, result_keys[0])
+        for result_key in result_keys[1:]:
+            query_set, _ = self._join_one_iteration_key(query_set, result_key,
+                                                        first_alias=first_alias)
+        return query_set
+
+
+    def get_query_set_with_joins(self, filter_data):
+        """
+        Add joins for querying over test-related items.
+
+        These parameters are supported going forward:
+        * test_attribute_fields: list of attribute names.  Each attribute will
+                be available as a column attribute_<name>.value.
+        * test_label_fields: list of label names.  Each label will be available
+                as a column label_<name>.id, non-null iff the label is present.
+        * iteration_fields: list of iteration result names.  Each
+                result will be available as a column iteration_<name>.value.
+                Note that this changes the semantics to return iterations
+                instead of tests -- if a test has multiple iterations, a row
+                will be returned for each one.  The iteration index is also
+                available as iteration_<name>.iteration.
+        * machine_label_fields: list of machine label names.  Each will be
+                available as a column machine_label_<name>.id, non-null iff the
+                label is present on the machine used in the test.
+
+        These parameters are deprecated:
+        * include_labels
+        * exclude_labels
+        * include_attributes_where
+        * exclude_attributes_where
+
+        Additionally, this method adds joins if the following strings are
+        present in extra_where (this is also deprecated):
+        * test_labels
+        * test_attributes_host_labels
+        """
         query_set = self.get_query_set()
+
+        test_attributes = filter_data.pop('test_attribute_fields', [])
+        for attribute in test_attributes:
+            query_set = self._join_attribute(query_set, attribute)
+
+        test_labels = filter_data.pop('test_label_fields', [])
+        query_set = self._join_label_columns(query_set, test_labels)
+
+        machine_labels = filter_data.pop('machine_label_fields', [])
+        query_set = self._join_machine_label_columns(query_set, machine_labels)
+
+        iteration_keys = filter_data.pop('iteration_fields', [])
+        query_set = self._join_iterations(query_set, iteration_keys)
+
+        # everything that follows is deprecated behavior
+
         joined = False
 
-        # TODO: make this feature obsolete in favor of include_labels and
-        # exclude_labels
         extra_where = filter_data.get('extra_where', '')
         if 'tko_test_labels' in extra_where:
             query_set = self._add_label_joins(query_set)
             joined = True
 
+        include_labels = filter_data.pop('include_labels', [])
+        exclude_labels = filter_data.pop('exclude_labels', [])
         if include_labels:
             query_set = self._include_or_exclude_labels(query_set,
                                                         include_labels)
@@ -383,17 +555,10 @@
                 exclude=True)
             joined = True
 
-        test_attributes = filter_data.pop('tko_test_attributes', [])
-        for attribute in test_attributes:
-            query_set = self.join_attribute(query_set, attribute)
-            joined = True
-
         if not joined:
             filter_data['no_distinct'] = True
 
-        # TODO: make test_attributes_host_labels obsolete too
-        if (include_host_labels or
-                'tko_test_attributes_host_labels' in extra_where):
+        if 'tko_test_attributes_host_labels' in extra_where:
             query_set = self._add_attribute_join(
                 query_set, suffix='_host_labels',
                 join_condition='tko_test_attributes_host_labels.attribute = '
@@ -421,78 +586,6 @@
         return sql.replace('test_idx', self.get_key_on_this_table('test_idx'))
 
 
-    def _join_one_iteration_key(self, query_set, result_key, index):
-        suffix = '_%s' % index
-        table_name = IterationResult._meta.db_table
-        alias = table_name + suffix
-        condition_parts = ["%s.attribute = '%s'" %
-                           (alias, self.escape_user_sql(result_key))]
-        if index > 0:
-            # after the first join, we need to match up iteration indices,
-            # otherwise each join will expand the query by the number of
-            # iterations and we'll have extraneous rows
-            first_alias = table_name + '_0'
-            condition_parts.append('%s.iteration = %s.iteration' %
-                                   (alias, first_alias))
-
-        condition = ' and '.join(condition_parts)
-        # add a join to IterationResult
-        query_set = self.add_join(query_set, table_name, join_key='test_idx',
-                                  join_condition=condition, suffix=suffix)
-        # select the iteration value for this join
-        query_set = query_set.extra(select={result_key: '%s.value' % alias})
-        if index == 0:
-            # pull the iteration index from the first join
-            query_set = query_set.extra(
-                    select={'iteration_index': '%s.iteration' % alias})
-
-        return query_set
-
-
-    def join_iterations(self, test_view_query_set, result_keys):
-        """
-        Join the given TestView QuerySet to IterationResult.  The resulting
-        query looks like a TestView query but has one row per iteration.  Each
-        row includes all the attributes of TestView, an attribute for each key
-        in result_keys and an iteration_index attribute.
-
-        We accomplish this by joining the TestView query to IterationResult
-        once per result key.  Each join is restricted on the result key (and on
-        the test index, like all one-to-many joins).  For the first join, this
-        is the only restriction, so each TestView row expands to a row per
-        iteration (per iteration that includes the key, of course).  For each
-        subsequent join, we also restrict the iteration index to match that of
-        the initial join.  This makes each subsequent join produce exactly one
-        result row for each input row.  (This assumes each iteration contains
-        the same set of keys.)
-        """
-        query_set = test_view_query_set
-        for index, result_key in enumerate(result_keys):
-            query_set = self._join_one_iteration_key(query_set, result_key,
-                                                     index)
-        return query_set
-
-
-    def join_attribute(self, test_view_query_set, attribute):
-        """
-        Join the given TestView QuerySet to TestAttribute.  The resulting query
-        has an additional column for the given attribute named
-        "attribute_<attribute name>".
-        """
-        table_name = TestAttribute._meta.db_table
-        suffix = '_' + attribute
-        alias = table_name + suffix
-        condition = "%s.attribute = '%s'" % (alias,
-                                             self.escape_user_sql(attribute))
-        query_set = self.add_join(test_view_query_set, table_name,
-                                  join_key='test_idx', join_condition=condition,
-                                  suffix=suffix, force_left_join=True)
-
-        select_name = 'attribute_' + attribute
-        query_set = query_set.extra(select={select_name: '%s.value' % alias})
-        return query_set
-
-
 class TestView(dbmodels.Model, model_logic.ModelExtensions):
     extra_fields = {
             'DATE(job_queued_time)': 'job queued day',
diff --git a/new_tko/tko/rpc_interface.py b/new_tko/tko/rpc_interface.py
index 09518c5..719e9773 100644
--- a/new_tko/tko/rpc_interface.py
+++ b/new_tko/tko/rpc_interface.py
@@ -18,8 +18,7 @@
 
 
 def get_group_counts(group_by, header_groups=None, fixed_headers=None,
-                     machine_label_headers=None, extra_select_fields=None,
-                     **filter_data):
+                     extra_select_fields=None, **filter_data):
     """
     Queries against TestView grouping by the specified fields and computings
     counts for each group.
@@ -32,11 +31,6 @@
     * fixed_headers can map header fields to lists of values.  the header will
       guaranteed to return exactly those value.  this does not work together
       with header_groups.
-    * machine_label_headers can specify special headers to be constructed from
-      machine labels.  It should map arbitrary names to lists of machine labels.
-      a field will be created with the given name containing a comma-separated
-      list indicating which of the given machine labels are on each test.  this
-      field can then be grouped on.
 
     Returns a dictionary with two keys:
     * header_values contains a list of lists, one for each header group in
@@ -48,8 +42,7 @@
       The keys for the extra_select_fields are determined by the "AS" alias of
       the field.
     """
-    query = models.TestView.objects.get_query_set_with_joins(
-        filter_data, include_host_labels=bool(machine_label_headers))
+    query = models.TestView.objects.get_query_set_with_joins(filter_data)
     # don't apply presentation yet, since we have extra selects to apply
     query = models.TestView.query_objects(filter_data, initial_query=query,
                                           apply_presentation=False)
@@ -57,9 +50,6 @@
     query = query.extra(select={count_alias: count_sql})
     if extra_select_fields:
         query = query.extra(select=extra_select_fields)
-    if machine_label_headers:
-        query = tko_rpc_utils.add_machine_label_headers(machine_label_headers,
-                                                        query)
     query = models.TestView.apply_presentation(query, filter_data)
 
     group_processor = tko_rpc_utils.GroupDataProcessor(query, group_by,
@@ -79,7 +69,7 @@
 
 
 def get_status_counts(group_by, header_groups=[], fixed_headers={},
-                      machine_label_headers={}, **filter_data):
+                      **filter_data):
     """
     Like get_group_counts, but also computes counts of passed, complete (and
     valid), and incomplete tests, stored in keys "pass_count', 'complete_count',
@@ -87,13 +77,12 @@
     """
     return get_group_counts(group_by, header_groups=header_groups,
                             fixed_headers=fixed_headers,
-                            machine_label_headers=machine_label_headers,
                             extra_select_fields=tko_rpc_utils.STATUS_FIELDS,
                             **filter_data)
 
 
 def get_latest_tests(group_by, header_groups=[], fixed_headers={},
-                     machine_label_headers={}, extra_info=[], **filter_data):
+                     extra_info=[], **filter_data):
     """
     Similar to get_status_counts, but return only the latest test result per
     group.  It still returns the same information (i.e. with pass count etc.)
@@ -103,16 +92,13 @@
                       field of the return dictionary.
     """
     # find latest test per group
-    query = models.TestView.objects.get_query_set_with_joins(
-        filter_data, include_host_labels=bool(machine_label_headers))
+    query = models.TestView.objects.get_query_set_with_joins(filter_data)
     query = models.TestView.query_objects(filter_data, initial_query=query,
                                           apply_presentation=False)
     query = query.exclude(status__in=tko_rpc_utils._INVALID_STATUSES)
     query = query.extra(
             select={'latest_test_idx' : 'MAX(%s)' %
                     models.TestView.objects.get_key_on_this_table('test_idx')})
-    query = tko_rpc_utils.add_machine_label_headers(machine_label_headers,
-                                                    query)
     query = models.TestView.apply_presentation(query, filter_data)
 
     group_processor = tko_rpc_utils.GroupDataProcessor(query, group_by,
@@ -156,35 +142,6 @@
     return list(job_ids)
 
 
-# iteration support
-
-def get_iteration_views(result_keys, **test_filter_data):
-    """
-    Similar to get_test_views, but returns a dict for each iteration rather
-    than for each test.  Accepts the same filter data as get_test_views.
-
-    @param result_keys: list of iteration result keys to include.  Only
-            iterations contains all these keys will be included.
-    @returns a list of dicts, one for each iteration.  Each dict contains:
-            * all the same information as get_test_views()
-            * all the keys specified in result_keys
-            * an additional key 'iteration_index'
-    """
-    iteration_views = tko_rpc_utils.get_iteration_view_query(result_keys,
-                                                             test_filter_data)
-    iteration_views = models.TestView.apply_presentation(iteration_views,
-                                                         test_filter_data)
-    iteration_dicts = models.TestView.list_objects(
-            {}, initial_query=iteration_views)
-    return rpc_utils.prepare_for_serialization(iteration_dicts)
-
-
-def get_num_iteration_views(result_keys, **test_filter_data):
-    iteration_views = tko_rpc_utils.get_iteration_view_query(result_keys,
-                                                             test_filter_data)
-    return iteration_views.count()
-
-
 # test detail view
 
 def _attributes_to_dict(attribute_list):
diff --git a/new_tko/tko/rpc_interface_unittest.py b/new_tko/tko/rpc_interface_unittest.py
index 8c8f952..104b608 100755
--- a/new_tko/tko/rpc_interface_unittest.py
+++ b/new_tko/tko/rpc_interface_unittest.py
@@ -1,6 +1,6 @@
 #!/usr/bin/python
 
-import unittest
+import re, unittest
 import common
 from autotest_lib.new_tko import setup_django_environment
 from autotest_lib.frontend import setup_test_environment
@@ -94,6 +94,13 @@
                             self._get_column_names_for_sqlite3)
         self._god.stub_with(models.TempManager, '_cursor_rowcount',
                             self._cursor_rowcount_for_sqlite3)
+
+        # add some functions to SQLite for MySQL compatibility
+        connection.cursor() # ensure connection is alive
+        connection.connection.create_function('if', 3, self._sqlite_if)
+        connection.connection.create_function('find_in_set', 2,
+                                              self._sqlite_find_in_set)
+
         setup_test_environment.set_up()
         fix_iteration_tables()
         setup_test_view()
@@ -104,6 +111,34 @@
         return len(cursor.fetchall())
 
 
+    def _sqlite_find_in_set(self, needle, haystack):
+        return needle in haystack.split(',')
+
+
+    def _sqlite_if(self, condition, true_result, false_result):
+        if condition:
+            return true_result
+        return false_result
+
+
+    # sqlite takes any columns that don't have aliases and names them
+    # "table_name"."column_name".  we map these to just column_name.
+    _SQLITE_AUTO_COLUMN_ALIAS_RE = re.compile(r'".+"\."(.+)"')
+
+
+    def _get_column_names_for_sqlite3(self, cursor):
+        names = [column_info[0] for column_info in cursor.description]
+
+        # replace all "table_name"."column_name" constructs with just
+        # column_name
+        for i, name in enumerate(names):
+            match = self._SQLITE_AUTO_COLUMN_ALIAS_RE.match(name)
+            if match:
+                names[i] = match.group(1)
+
+        return names
+
+
     def tearDown(self):
         setup_test_environment.tear_down()
         self._god.unstub_all()
@@ -135,6 +170,7 @@
                                                 kernel=kernel1,
                                                 status=good_status,
                                                 machine=machine)
+        self.first_test = job1_test1
         job1_test2 = models.Test.objects.create(job=job1, test='mytest2',
                                                 kernel=kernel1,
                                                 status=failed_status,
@@ -275,19 +311,6 @@
             job_name='myjob1', test_name='mytest1'), 1)
 
 
-    def _get_column_names_for_sqlite3(self, cursor):
-        names = [column_info[0] for column_info in cursor.description]
-
-        # replace all "table_name"."column_name" constructs with just
-        # column_name
-        for i, name in enumerate(names):
-            if '.' in name:
-                field_name = name.split('.', 1)[1]
-                names[i] = field_name.strip('"')
-
-        return names
-
-
     def test_get_group_counts(self):
         self.assertEquals(rpc_interface.get_num_groups(['job_name']), 2)
 
@@ -381,38 +404,9 @@
         self._check_for_get_test_labels(label2, 2)
 
 
-    def test_get_iteration_views(self):
-        iterations = rpc_interface.get_iteration_views(['iresult', 'iresult2'],
-                                                       job_name='myjob1',
-                                                       test_name='mytest1')
-        self.assertEquals(len(iterations), 2)
-        for index, iteration in enumerate(iterations):
-            self._check_for_get_test_views(iterations[index])
-            # iterations a one-indexed, not zero-indexed
-            self.assertEquals(iteration['iteration_index'], index + 1)
-
-        self.assertEquals(iterations[0]['iresult'], 1)
-        self.assertEquals(iterations[0]['iresult2'], 2)
-        self.assertEquals(iterations[1]['iresult'], 3)
-        self.assertEquals(iterations[1]['iresult2'], 4)
-
-        self.assertEquals(
-                [], rpc_interface.get_iteration_views(['iresult'],
-                                                      hostname='fakehost'))
-        self.assertEquals(
-                [], rpc_interface.get_iteration_views(['fake']))
-
-
-    def test_get_num_iteration_views(self):
-        self.assertEquals(
-                rpc_interface.get_num_iteration_views(['iresult', 'iresult2']),
-                2)
-        self.assertEquals(rpc_interface.get_num_iteration_views(['fake']), 0)
-
-
-    def test_get_test_attributes(self):
+    def test_get_test_attribute_fields(self):
         tests = rpc_interface.get_test_views(
-                tko_test_attributes=['myattr', 'myattr2'])
+                test_attribute_fields=['myattr', 'myattr2'])
         self.assertEquals(len(tests), 3)
 
         self.assertEquals(tests[0]['attribute_myattr'], 'myval')
@@ -423,13 +417,20 @@
             self.assertEquals(tests[index]['attribute_myattr2'], None)
 
 
-    def test_grouping_with_test_attributes(self):
-        num_groups = rpc_interface.get_num_groups(['attribute_myattr'],
-                                                tko_test_attributes=['myattr'])
+    def test_filtering_on_test_attribute_fields(self):
+        tests = rpc_interface.get_test_views(
+                extra_where='attribute_myattr.value = "myval"',
+                test_attribute_fields=['myattr'])
+        self.assertEquals(len(tests), 1)
+
+
+    def test_grouping_with_test_attribute_fields(self):
+        num_groups = rpc_interface.get_num_groups(
+                ['attribute_myattr'], test_attribute_fields=['myattr'])
         self.assertEquals(num_groups, 2)
 
-        counts = rpc_interface.get_group_counts(['attribute_myattr'],
-                                                tko_test_attributes=['myattr'])
+        counts = rpc_interface.get_group_counts(
+                ['attribute_myattr'], test_attribute_fields=['myattr'])
         groups = counts['groups']
         self.assertEquals(len(groups), num_groups)
         self.assertEquals(groups[0]['attribute_myattr'], None)
@@ -438,5 +439,138 @@
         self.assertEquals(groups[1]['group_count'], 1)
 
 
+    def test_get_test_label_fields(self):
+        tests = rpc_interface.get_test_views(
+                test_label_fields=['testlabel1', 'testlabel2'])
+        self.assertEquals(len(tests), 3)
+
+        self.assertEquals(tests[0]['label_testlabel1'], 'testlabel1')
+        self.assert_(tests[0]['label_testlabel2'], 'testlabel2')
+
+        for index in (1, 2):
+            self.assertEquals(tests[index]['label_testlabel1'], None)
+            self.assertEquals(tests[index]['label_testlabel2'], None)
+
+
+    def test_filtering_on_test_label_fields(self):
+        tests = rpc_interface.get_test_views(
+                extra_where='label_testlabel1 = "testlabel1"',
+                test_label_fields=['testlabel1'])
+        self.assertEquals(len(tests), 1)
+
+
+    def test_grouping_on_test_label_fields(self):
+        num_groups = rpc_interface.get_num_groups(
+                ['label_testlabel1'], test_label_fields=['testlabel1'])
+        self.assertEquals(num_groups, 2)
+
+        counts = rpc_interface.get_group_counts(
+                ['label_testlabel1'], test_label_fields=['testlabel1'])
+        groups = counts['groups']
+        self.assertEquals(len(groups), 2)
+        self.assertEquals(groups[0]['label_testlabel1'], None)
+        self.assertEquals(groups[0]['group_count'], 2)
+        self.assertEquals(groups[1]['label_testlabel1'], 'testlabel1')
+        self.assertEquals(groups[1]['group_count'], 1)
+
+
+    def test_get_iteration_fields(self):
+        num_iterations = rpc_interface.get_num_test_views(
+                iteration_fields=['iresult', 'iresult2'])
+        self.assertEquals(num_iterations, 2)
+
+        iterations = rpc_interface.get_test_views(
+                iteration_fields=['iresult', 'iresult2'])
+        self.assertEquals(len(iterations), 2)
+
+        for index in (0, 1):
+            self.assertEquals(iterations[index]['test_idx'], 1)
+
+        self.assertEquals(iterations[0]['iteration_index'], 1)
+        self.assertEquals(iterations[0]['iteration_iresult'], 1)
+        self.assertEquals(iterations[0]['iteration_iresult2'], 2)
+
+        self.assertEquals(iterations[1]['iteration_index'], 2)
+        self.assertEquals(iterations[1]['iteration_iresult'], 3)
+        self.assertEquals(iterations[1]['iteration_iresult2'], 4)
+
+
+    def test_filtering_on_iteration_fields(self):
+        iterations = rpc_interface.get_test_views(
+                extra_where='iteration_iresult.value = 1',
+                iteration_fields=['iresult'])
+        self.assertEquals(len(iterations), 1)
+
+
+    def test_grouping_with_iteration_fields(self):
+        num_groups = rpc_interface.get_num_groups(['iteration_iresult'],
+                                                  iteration_fields=['iresult'])
+        self.assertEquals(num_groups, 2)
+
+        counts = rpc_interface.get_group_counts(['iteration_iresult'],
+                                                iteration_fields=['iresult'])
+        groups = counts['groups']
+        self.assertEquals(len(groups), 2)
+        self.assertEquals(groups[0]['iteration_iresult'], 1)
+        self.assertEquals(groups[0]['group_count'], 1)
+        self.assertEquals(groups[1]['iteration_iresult'], 3)
+        self.assertEquals(groups[1]['group_count'], 1)
+
+
+    def _setup_machine_labels(self):
+        models.TestAttribute.objects.create(test=self.first_test,
+                                            attribute='host-labels',
+                                            value='label1,label2')
+
+
+    def test_get_machine_label_fields(self):
+        self._setup_machine_labels()
+
+        tests = rpc_interface.get_test_views(
+                machine_label_fields=['label1', 'otherlabel'])
+        self.assertEquals(len(tests), 3)
+
+        self.assertEquals(tests[0]['machine_label_label1'], 'label1')
+        self.assertEquals(tests[0]['machine_label_otherlabel'], None)
+
+        for index in (1, 2):
+            self.assertEquals(tests[index]['machine_label_label1'], None)
+            self.assertEquals(tests[index]['machine_label_otherlabel'], None)
+
+
+    def test_grouping_with_machine_label_fields(self):
+        self._setup_machine_labels()
+
+        counts = rpc_interface.get_group_counts(['machine_label_label1'],
+                                                machine_label_fields=['label1'])
+        groups = counts['groups']
+        self.assertEquals(len(groups), 2)
+        self.assertEquals(groups[0]['machine_label_label1'], None)
+        self.assertEquals(groups[0]['group_count'], 2)
+        self.assertEquals(groups[1]['machine_label_label1'], 'label1')
+        self.assertEquals(groups[1]['group_count'], 1)
+
+
+    def test_filtering_on_machine_label_fields(self):
+        self._setup_machine_labels()
+
+        tests = rpc_interface.get_test_views(
+                extra_where='machine_label_label1 = "label1"',
+                machine_label_fields=['label1'])
+        self.assertEquals(len(tests), 1)
+
+
+    def test_quoting_fields(self):
+        # ensure fields with special characters are properly quoted throughout
+        rpc_interface.add_test_label('hyphen-label')
+        rpc_interface.get_group_counts(
+                ['attribute_hyphen-attr', 'label_hyphen-label',
+                 'machine_label_hyphen-label', 'iteration_hyphen-result'],
+                test_attribute_fields=['hyphen-attr'],
+                test_label_fields=['hyphen-label'],
+                machine_label_fields=['hyphen-label'],
+                iteration_fields=['hyphen-result'])
+
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/new_tko/tko/tko_rpc_utils.py b/new_tko/tko/tko_rpc_utils.py
index be6e754..063e28a 100644
--- a/new_tko/tko/tko_rpc_utils.py
+++ b/new_tko/tko/tko_rpc_utils.py
@@ -94,13 +94,6 @@
     return 'CONCAT_WS(",", %s)' % ', '.join(if_clauses)
 
 
-def add_machine_label_headers(machine_label_headers, query):
-    for field_name, machine_labels in machine_label_headers.iteritems():
-        field_sql = _construct_machine_label_header_sql(machine_labels)
-        query = query.extra(select={field_name: field_sql})
-    return query
-
-
 class GroupDataProcessor(object):
     _MAX_GROUP_RESULTS = 80000
 
@@ -222,11 +215,3 @@
     def get_info_dict(self):
         return {'groups' : self._group_dicts,
                 'header_values' : self._header_values}
-
-
-def get_iteration_view_query(result_keys, test_filter_data):
-    test_views = models.TestView.query_objects(test_filter_data,
-                                               apply_presentation=False)
-    iteration_views = models.TestView.objects.join_iterations(test_views,
-                                                              result_keys)
-    return iteration_views