changeset 106:0d259fd52ca0

Implement validation of advanced query parameters. While at it also rework the query infrastructure, I'm almost happy with it now. Named queries are broken by this change and will be fixed soon. Add dependency to `dateutil` package used for datetimes parsing.
author Daniele Nicolodi <daniele@grinta.net>
date Tue, 23 Aug 2011 14:38:17 +0200
parents f4cbab388ed0
children 22b950fee548
files buildout.cfg src/ltpdarepo/static/querywidget.js src/ltpdarepo/static/style.css src/ltpdarepo/templates/query.html src/ltpdarepo/templates/timerange.html src/ltpdarepo/tests/test_objs.py src/ltpdarepo/views/browse.py
diffstat 7 files changed, 372 insertions(+), 228 deletions(-) [+]
line wrap: on
line diff
--- a/buildout.cfg	Tue Aug 23 14:34:44 2011 +0200
+++ b/buildout.cfg	Tue Aug 23 14:38:17 2011 +0200
@@ -1,4 +1,5 @@
 [buildout]
+include-site-packages = false
 parts = flask
 develop = src
 
@@ -10,6 +11,7 @@
      ordereddict
      argparse
      itsdangerous
+     python-dateutil < 2.0
      ltpdarepo
 interpreter = python
 scripts = run admin
--- a/src/ltpdarepo/static/querywidget.js	Tue Aug 23 14:34:44 2011 +0200
+++ b/src/ltpdarepo/static/querywidget.js	Tue Aug 23 14:38:17 2011 +0200
@@ -16,7 +16,7 @@
           var id = val;
           var name = val;
           if (typeof(val.name) !== 'undefined') {
-            id = val.val;
+            id = val.name;
             name = val.name;
           }
           // create options
@@ -37,7 +37,7 @@
 
     // create field name
     $.querywidget.createQueryField = function (index) {
-      var value = $.querywidget.config.indexes[index].val;
+      var value = $.querywidget.config.indexes[index].value;
       return $(document.createElement('input')).attr({
             'type': 'hidden',
             'name': 'field',
@@ -88,6 +88,10 @@
       // configuration
       $.querywidget.config.indexes = criteria;
 
+      $("input.error").live('focus', function () {
+          $(this).removeClass('error');
+        });
+
       $('#add').live('change', function () {
           var index = $(this).find(':selected')[0].value;
           if (! index)
--- a/src/ltpdarepo/static/style.css	Tue Aug 23 14:34:44 2011 +0200
+++ b/src/ltpdarepo/static/style.css	Tue Aug 23 14:38:17 2011 +0200
@@ -558,21 +558,21 @@
     background: #CFF09E;
 }
 
-/* timespan selector */
+/* timerange selector */
 
-input.timespan-lo {
+input.timerange-t1 {
     width: 197px;
     padding: 5px 10px;
     margin: 0;
 }
 
-input.timespan-hi {
+input.timerange-t2 {
     width: 197px;
     padding: 5px 10px;
     margin: 0;
 }
 
-.timespan-label {
+#timerange span {
     padding: 5px 10px;
     width: 60px;
     border-right: none;
@@ -580,7 +580,6 @@
     text-align: center;
 }
 
-
 /* object display */
 
 .obj {
--- a/src/ltpdarepo/templates/query.html	Tue Aug 23 14:34:44 2011 +0200
+++ b/src/ltpdarepo/templates/query.html	Tue Aug 23 14:38:17 2011 +0200
@@ -6,7 +6,7 @@
     <script type="text/javascript" src="{{ url_for('static', filename='dropdown.js') }}"></script>
     <script type="text/javascript" src="{{ url_for('static', filename='querywidget.js') }}"></script>
     <script type="text/javascript">
-      var criteria = {{ criteria|tojson|safe }};
+      var criteria = {{ indexes|tojson|safe }};
       $(function () {
         $.dropdown.replace($("select"));
         $("#save").live('click', function () {
@@ -36,12 +36,14 @@
   <form method="GET" action="">
     {% block extra %}{% endblock %}
     <div id="criteria" class="wrapper">
-      {% for field, op, value in query %}
+      {% for field, op, value, error in query %}
+      {% set cssclass = '' %}
+      {% if error %}{% set cssclass = cssclass + 'error' %}{% endif %}
       <div class="criteria wrapper">
-        <span class="querywidget field">{{ criteria[field]['name'] }}</span>
+        <span class="querywidget field">{{ indexes[field]['name'] }}</span>
         <input type="hidden" name="field" value="{{ field }}" />
         <select class="querywidget operator" name="operator">
-          {% for name in criteria[field]['operators'] %}
+          {% for name in indexes[field]['operators'] %}
           {% if op == name %}
           <option value="{{ name }}" selected="selected">{{ name }}</option>
           {% else %}
@@ -49,9 +51,9 @@
           {% endif %}
           {% endfor %}
         </select>
-        {% if criteria[field]['type'] == 'enum' %}
+        {% if indexes[field]['type'] == 'enum' %}
         <select class="querywidget value" name="value">
-          {% for name in criteria[field]['values'] %}
+          {% for name in indexes[field]['values'] %}
           {% if value == name %}
           <option value="{{ name }}" selected="selected">{{ name }}</option>
           {% else %}
@@ -60,7 +62,7 @@
           {% endfor %}
         </select>
         {% else %}
-        <input class="querywidget value" autocomplete="off" type="text" name="value" value="{{ value }}" />
+        <input class="querywidget value {{ cssclass }}" autocomplete="off" type="text" name="value" value="{{ value }}" />
         {% endif %}
         <input class="querywidget remove" type="button" name="remove" value="&times;" />
       </div>
@@ -69,8 +71,8 @@
 
     <select id="add">
       <option value="" selected="selected">Add criteria&hellip;</option>
-      {% for c in criteria.values() %}
-      <option value="{{ c['val'] }}">{{ c['name'] }}</option>
+      {% for key, index in indexes.iteritems() %}
+      <option value="{{ key }}">{{ index['name'] }}</option>
       {% endfor %}
     </select>
 
--- a/src/ltpdarepo/templates/timerange.html	Tue Aug 23 14:34:44 2011 +0200
+++ b/src/ltpdarepo/templates/timerange.html	Tue Aug 23 14:38:17 2011 +0200
@@ -1,10 +1,18 @@
 {% extends "query.html" %}
 {% block extra %}
-    <div id="timespan" class="wrapper">
+    <div id="timerange" class="wrapper">
       <div class="criteria wrapper">
-        <input class="timespan-lo" type="text" name="from" value="{{ lo|default('', True) }}" autocomplete="off" placeholder="FROM" />
-        <span class="timespan-label">&#x2264; TIME &#x2264;</span>
-        <input class="timespan-hi" type="text" name="to" value="{{ hi|default('', True) }}" autocomplete="off" placeholder="TO" />
+        {% if timerange.t1.errors -%}
+        {{ timerange.t1(class_='timerange-t1 error', autocomplete='off', placeholder='FROM') }}
+        {% else -%}
+        {{ timerange.t1(class_='timerange-t1', autocomplete='off', placeholder='FROM') }}
+        {%- endif %}
+        <span>&#x2264; TIME &#x2264;</span>
+        {% if timerange.t2.errors -%}
+        {{ timerange.t2(class_='timerange-t2 error', autocomplete='off', placeholder='TO') }}
+        {% else -%}
+        {{ timerange.t2(class_='timerange-t2', autocomplete='off', placeholder='TO') }}
+        {%- endif %}
       </div>
     </div>
 {% endblock %}
--- a/src/ltpdarepo/tests/test_objs.py	Tue Aug 23 14:34:44 2011 +0200
+++ b/src/ltpdarepo/tests/test_objs.py	Tue Aug 23 14:38:17 2011 +0200
@@ -1,15 +1,14 @@
-import unittest2 as unittest
-
 from ltpdarepo.tests.utils import RequestContextTestCase
 from ltpdarepo.views.browse import Objs
 
+
 class TestCase(RequestContextTestCase):
 
     @classmethod
     def setUpClass(self):
         from ltpdarepo.admin import wipe, setup
         wipe()
-        setup()        
+        setup()
 
     def test_simple(self):
         objs = Objs(database='db1')
@@ -22,7 +21,7 @@
         objs = Objs(database='db1').limit(20, 2)
         self.assertEqual(objs.all()[0]['id'], 21)
         self.assertEqual(len(objs.all()), 2)
-        
+
     def test_orderby(self):
         objs = Objs(database='db1').orderby('obj_id')
         self.assertEqual(objs[0]['id'], 1)
@@ -31,6 +30,5 @@
         self.assertEqual(objs[0]['id'], 30)
 
     def test_where(self):
-        objs = Objs(database='db1').filter('obj_id > %s AND obj_id < %s', 10, 20)
+        objs = Objs(database='db1').filter('obj_id > %s AND obj_id < %s', (10, 20))
         self.assertEqual(objs[0]['id'], 11)
-        
--- a/src/ltpdarepo/views/browse.py	Tue Aug 23 14:34:44 2011 +0200
+++ b/src/ltpdarepo/views/browse.py	Tue Aug 23 14:38:17 2011 +0200
@@ -1,8 +1,15 @@
 import re
-from operator import itemgetter
+from datetime import datetime
+
+import dateutil.tz
+import dateutil.parser
 
 from flask import Blueprint, abort, g, request, render_template, json, make_response
 from MySQLdb.cursors import DictCursor
+from wtforms import Form
+from wtforms.fields import Field
+from wtforms.widgets import TextInput
+from wtforms.validators import ValidationError, Optional
 
 from ltpdarepo.security import require, view
 from ltpdarepo.database import Database
@@ -15,61 +22,32 @@
     from ordereddict import OrderedDict
 
 
-PAGESIZE = 20
-
-FIELDS = ('id',
-          'name',
-          'type',
-          'quantity',
-          'keywords',
-          'submitted',
-          'title',
-          'description',)
-EXTRA = ( 'analysis',
-          'author',
-          'additional authors',
-          'comments',
-          'reference ids',
-          'created', )
-
+FIELDS = ('id', 'name', 'type', 'quantity', 'keywords', 'submitted', 'title', 'description',)
+EXTRA = ('analysis', 'author', 'additional_authors', 'comments', 'reference_ids', 'created', )
+MORE = ('version', 'ip', 'hostname', 'os', 'validated', 'vdate')
+TIMESERIESFIELDS = ('id', 'name', 't0', 'nsecs', 'quantity', 'keywords', 'title', 'description')
 ALLFIELDS = FIELDS + EXTRA
 
-# 'version', 'ip', 'hostname', 'os', 'validated', 'vdate'
-
-RMAPPING = { 'id':                 'objmeta.obj_id',
-             'name':               'objmeta.name',
-             'type':               'objmeta.obj_type',
-             'quantity':           'objmeta.quantity',
-             'keywords':           'objmeta.keywords',
-             'submitted':          'objmeta.submitted',
-             'title':              'objmeta.experiment_title',
-             'description':        'objmeta.experiment_desc',
-             'analysis':           'objmeta.analysis_desc',
-             'author':             'objmeta.author',
-             'additional authors': 'objmeta.additional_authors',
-             'comments':           'objmeta.additional_comments',
-             'reference ids':      'objmeta.reference_ids',
-             'created':            'objmeta.created',
-             't0':                 'tsdata.t0',
-             'nsecs':              'tsdata.nsecs', }
-
-MAPPING = dict(((v.split('.')[1], k) for k, v in RMAPPING.iteritems()))
-
-
-def _current_page():
-    """return the current page in a paginated view"""
-    return int(request.args.get('p', 1))
-
-
-def _current_ordering():
-    """return the current page in a paginated view"""
-    order = request.args.get('o', 'id')
-    order = RMAPPING[order]
-    reverse = int(request.args.get('r', 0))
-    return (order, reverse)
+PAGESIZE = 20
 
 
 class Objs(object):
+    columns = OrderedDict([
+        ('id',                 'objmeta.obj_id'),
+        ('name',               'objmeta.name'),
+        ('type',               'objmeta.obj_type'),
+        ('quantity',           'objmeta.quantity'),
+        ('keywords',           'objmeta.keywords'),
+        ('submitted',          'objmeta.submitted'),
+        ('title',              'objmeta.experiment_title'),
+        ('description',        'objmeta.experiment_desc'),
+        ('analysis',           'objmeta.analysis_desc'),
+        ('author',             'objmeta.author'),
+        ('additional_authors', 'objmeta.additional_authors'),
+        ('comments',           'objmeta.additional_comments'),
+        ('reference_ids',      'objmeta.reference_ids'),
+        ('created',            'objmeta.created')])
+
     def __init__(self, database):
         self._database = database
         self._orderby = None
@@ -80,7 +58,7 @@
 
     @property
     def _query(self):
-        columns = ", ".join("%s AS `%s`" % (RMAPPING[x], x) for x in FIELDS)
+        columns = ", ".join("%s AS `%s`" % (col, name) for name, col in self.columns.iteritems())
         query = "SELECT " + columns + " FROM `%s`.objmeta" % self._database
         if self._where:
             query += " WHERE %s" % self._where
@@ -101,7 +79,7 @@
             query += " LIMIT %d,%d" % self._limit
         return query
 
-    def filter(self, where, *values):
+    def filter(self, where, values):
         self._where = where
         self._values = values
         return self
@@ -116,7 +94,7 @@
             return self.limit(0, start)
         self._limit = (start, count)
         return self
-        
+
     def all(self):
         curs = g.db.cursor(DictCursor)
         curs.execute(self._query, self._values)
@@ -137,6 +115,250 @@
             return objs[0]
 
 
+class Timeseries(Objs):
+    columns = Objs.columns.copy()
+    columns.update([
+        ('t0',                 'tsdata.t0'),
+        ('nsecs',              'tsdata.nsecs')])
+
+    def __init__(self, *args, **kwargs):
+        super(Timeseries, self).__init__(*args, **kwargs)
+        self._start = None
+        self._end = None
+
+    def timespan(self, start=None, end=None):
+        self._start = start
+        self._end = end
+        return self
+
+    @property
+    def _query(self):
+        columns = ", ".join("%s AS `%s`" % (col, name) for name, col in self.columns.iteritems())
+        query = "SELECT " + columns + " FROM `%s`.objmeta, `%s`.tsdata" % (self._database, self._database)
+        query += " WHERE objmeta.obj_id=tsdata.obj_id"
+        if self._end:
+            query += " AND tsdata.t0 <= '%s'" % self._end
+        if self._start:
+            query += " AND tsdata.t0 + INTERVAL tsdata.nsecs SECOND >= '%s'" % self._start
+        if self._where:
+            query += " AND %s" % self._where
+        if self._orderby:
+            query += " ORDER BY %s" % self._orderby
+        if self._reverse:
+            query += " DESC"
+        if self._limit:
+            query += " LIMIT %d,%d" % self._limit
+        return query
+
+    @property
+    def _count(self):
+        query = "SELECT COUNT(*) FROM `%s`.objmeta, `%s`.tsdata" % (self._database, self._database)
+        query += " WHERE objmeta.obj_id=tsdata.obj_id"
+        if self._end:
+            query += " AND tsdata.t0 <= '%s'" % self._end
+        if self._start:
+            query += " AND tsdata.t0 + INTERVAL tsdata.nsecs SECOND >= '%s'" % self._start
+        if self._where:
+            query += " AND %s" % self._where
+        if self._limit:
+            query += " LIMIT %d,%d" % self._limit
+        return query
+
+
+class Index(dict):
+    def __init__(self, **kwargs):
+        self.__dict__ = self
+        self.update(**kwargs)
+
+
+class Indexes(OrderedDict):
+
+    operators = {'int':      ('=', '>', '<'),
+                 'tinyint':  ('=', '>', '<'),
+                 'double':   ('=', '>', '<'),
+                 'text':     ('LIKE', '='),
+                 'enum':     ('=',),
+                 'datetime': ('=', '>', '<')}
+
+    def __init__(self, database, columns):
+        super(Indexes, self).__init__()
+        indexes = []
+
+        # map column name to index name
+        mapping = dict(((v.split('.')[1], k) for k, v in columns.iteritems()))
+        # map index name to ordering position
+        order = dict((v, i) for i, v in enumerate(columns.keys()))
+
+        curs = g.db.cursor()
+        curs.execute("""DESCRIBE `%s`.`%s`""" % (database, 'objmeta'))
+
+        for column, kind, values in [self.parse(x) for x in curs.fetchall()]:
+            name = mapping.get(column)
+            if name:
+                indexes.append(Index(name=name.replace('_', ' '),
+                                     value=name,
+                                     column='.'.join(('objmeta', column)),
+                                     type=kind,
+                                     operators=Indexes.operators[kind],
+                                     values=values))
+
+        for index in sorted(indexes, key=lambda v: order.get(v.name, 100)):
+            self[index.name] = index
+
+    def parse(self, desc):
+        column = desc[0]
+        # extract column type
+        kind = re.match(r'(\w+)', desc[1]).group(1)
+        values = None
+        if kind == 'enum':
+            # extract enum values
+            values = re.match(r'(\w+)\((.*)\)', desc[1]).group(2).split(',')
+            values = [v.strip("'") for v in values]
+        return column, kind, values
+
+    def column(self, name):
+        return self[name].column
+
+
+class datetimefield(datetime):
+    """Timezone aware subclass of `datetime.datetime` with a new
+    constructor that parses strings and a default string
+    representation including the timezone name."""
+
+    def __new__(cls, string):
+        # parsing default is midnight today in UTC timezone
+        default = datetime.utcnow().replace(
+            tzinfo=dateutil.tz.tzutc(), hour=0, minute=0, second=0, microsecond=0)
+
+        value = dateutil.parser.parse(string, dayfirst=True, yearfirst=True, default=default)
+        return datetime.__new__(cls, value.year, value.month, value.day, value.hour,
+                                    value.minute, value.second, value.microsecond, value.tzinfo)
+
+    def __str__(self):
+        return self.strftime('%Y-%m-%d %H:%M:%S %Z')
+
+
+class Request(object):
+    """Retrieves and validates query parameters from the request"""
+
+    validators = {'int': int,
+                  'tinyint': int,
+                  'double': float,
+                  'text': unicode,
+                  'enum': unicode,
+                  'datetime': datetimefield}
+
+    def __init__(self, request, columns, indexes={}):
+        # incoming data
+        self.formdata = request.args
+        # column names mapping used to validate orderby parameter
+        self.columns = columns
+        # indexes description used to validate query parameters
+        self.indexes = indexes
+        # process incoming data
+        self.process(self.formdata)
+
+    def process(self, formdata):
+        fields = formdata.getlist('field')
+        ops = formdata.getlist('operator')
+        values = formdata.getlist('value')
+        self.where, self.vals, self.criteria = self.parsequery(fields, ops, values)
+
+    def parsequery(self, fields, ops, values):
+
+        query, vals, parsed = [], [], []
+
+        for field, op, value in zip(fields, ops, values):
+            # index description
+            index = self.indexes[field]
+            validate = self.validators[index['type']]
+            # validate operator
+            if op not in index['operators']: op = '='
+            # validate value
+            error = False
+            try:
+                value = validate(value)
+                # add to query parameters
+                vals.append(value)
+                query.append('%s %s %%s' % (index.column, op))
+            except ValueError:
+                error = True
+            finally:
+                # append to parse results
+                parsed.append((field, op, value, error))
+
+        return ' AND '.join(query), vals, parsed
+
+    @property
+    def order(self):
+        """Current ordering in an ordered view"""
+        # validation by dict lookup
+        order = self.formdata.get('o', 'id')
+        order = self.columns[order]
+        # validation by conversion to int
+        reverse = self.formdata.get('r', 0, type=int)
+        return order, reverse
+
+    @property
+    def page(self):
+        """Current page in a paginated view"""
+        # validation by conversion to int
+        return self.formdata.get('p', 1, type=int)
+
+    @property
+    def query(self):
+        return self.where, self.vals
+
+
+class DateTimeField(Field):
+    """Text input and that uses `dateutil.parser.parse` to parse the
+    entered value into a `datetime.datetime` object. This differs from
+    `wtforms.ext.dateutil.field.DateTimeField` in parameters used for
+    the datetime parsing and in the display format of the field value."""
+
+    widget = TextInput()
+
+    def __init__(self, label=None, validators=None, **kwargs):
+        super(DateTimeField, self).__init__(label, validators, **kwargs)
+        self.parseargs = dict(dayfirst=True, yearfirst=True)
+
+    def _value(self):
+        if self.data:
+            return self.data.strftime('%Y-%m-%d %H:%M:%S %Z')
+        return self.raw_data and u' '.join(self.raw_data) or u''
+
+    def process_formdata(self, valuelist):
+        if valuelist:
+            datestr = u' '.join(valuelist)
+            if not datestr:
+                self.data = None
+                raise ValidationError(self.gettext(u'Input a datetime value'))
+
+            # parsing default is midnight today in UTC timezone
+            default = datetime.utcnow().replace(
+                tzinfo=dateutil.tz.tzutc(), hour=0, minute=0, second=0, microsecond=0)
+            try:
+                self.data = dateutil.parser.parse(datestr, default=default, **self.parseargs)
+            except ValueError:
+                self.data = None
+                raise ValidationError(self.gettext(u'Invalid datetime input'))
+
+
+class Timerange(Form):
+    """Form used to specify time ranges in timeseries search query"""
+
+    t1 = DateTimeField(validators=[Optional()])
+    t2 = DateTimeField(validators=[Optional()])
+
+    def __init__(self, *args, **kwargs):
+        super(Timerange, self).__init__(*args, **kwargs)
+        self.validate()
+
+    @property
+    def range(self):
+        return self.t1.data, self.t2.data
+
+
 app = Blueprint('browse', __name__)
 
 
@@ -144,7 +366,7 @@
 @require('user')
 def database(database):
     with view('database', database):
-        db = Database().load(database)
+        db = Database.load(database)
         if db is None:
             # not found
             abort(404)
@@ -158,38 +380,42 @@
 @require('user')
 def browse(database):
     with view('database', database):
-        db = Database().load(database)
+        db = Database.load(database)
         if db is None:
             # not found
             abort(404)
 
-        count = Objs(database=database).count()
-        batch = Pagination(_current_page(), size=PAGESIZE, count=count)
-        objs  = Objs(database=database).orderby(*_current_ordering()).limit(*batch.limits).all()
+        # parse request
+        r = Request(request, Objs.columns)
 
-        return render_template('browse.html', objs=objs, fields=FIELDS, database=db, batch=batch)
+        count = Objs(database).count()
+        batch = Pagination(r.page, size=PAGESIZE, count=count)
+        objs  = Objs(database).orderby(*r.order).limit(*batch.limits).all()
+
+        return render_template('browse.html', objs=objs, batch=batch,
+                               fields=FIELDS, database=db)
 
 
 @app.route('/<database>/<int:objid>')
 @require('user')
 def obj(database, objid):
     with view('database', database):
-        db = Database().load(database)
+        db = Database.load(database)
         if db is None:
             # not found
             abort(404)
-        obj = Objs(database=database).filter('obj_id=%s', objid)[0]
+        obj = Objs(database).filter('obj_id=%s', objid)[0]
         if obj is None:
             # not found
             abort(404)
-        return render_template('obj.html', obj=obj, database=db, fields=ALLFIELDS)
+        return render_template('obj.html', obj=obj, database=db, fields=FIELDS+EXTRA+MORE)
 
 
 @app.route('/<database>/<int:objid>/<frmt>')
 @require('user')
 def download(database, objid, frmt):
     with view('database', database):
-        db = Database().load(database)
+        db = Database.load(database)
         if db is None:
             # not found
             abort(404)
@@ -225,93 +451,54 @@
 @require('user')
 def search(database):
     with view('database', database):
-        db = Database().load(database)
+        db = Database.load(database)
         if db is None:
             # not found
             abort(404)
 
-        # search criteria
-        q = request.args.get('q', '')
-        q = '%%%s%%' % q
-
-        # query
-        count = Objs(database=database).filter('name LIKE %s', q).count()
-        batch = Pagination(_current_page(), size=PAGESIZE, count=count)
-        objs  = Objs(database=database).filter('name LIKE %s', q).orderby(*_current_ordering()).limit(*batch.limits).all()
-
-        return render_template('browse.html', objs=objs, fields=FIELDS, database=db, batch=batch)
-
-
-def _column_desc(desc):
-    val = desc[0]
-    kind = re.match(r'(\w+)', desc[1]).group(1)
-    values = None
-    if kind == 'enum':
-        values = re.match(r'(\w+)\((.*)\)', desc[1]).group(2).split(',')
-        values = [v.strip("'") for v in values]
+        # parse request
+        r = Request(request, Objs.columns)
+        q = '%' + request.args.get('q', '') + '%'
 
-    name = MAPPING.get(val)
-    if name is None:
-        return None
-
-    ops = {'int':      ('=', '>', '<'),
-           'tinyint':  ('=', '>', '<'),
-           'text':     ('LIKE', '='),
-           'enum':     ('=',),
-           'datetime': ('=', '>', '<')}.get(kind)
-
-    order = dict((v, i) for i, v in enumerate(ALLFIELDS)).get(name, 100)
+        # collect objects
+        count = Objs(database).filter('name LIKE %s', q).count()
+        batch = Pagination(r.page, size=PAGESIZE, count=count)
+        objs  = Objs(database).filter('name LIKE %s', q).orderby(*r.order).limit(*batch.limits).all()
 
-    return {'val': val,
-            'name': name,
-            'type': kind,
-            'operators': ops,
-            'values': values,
-            'order': order}
-
-
-def _indexes(database, table):
-    curs = g.db.cursor()
-    curs.execute("""DESCRIBE `%s`.`%s`""" % (database, table))
-    indexes = filter(None, [_column_desc(desc) for desc in curs.fetchall()])
-    return OrderedDict((x['val'], x) for x in sorted(indexes, key=itemgetter('order')))
+        return render_template('browse.html', objs=objs, batch=batch,
+                               fields=FIELDS, database=db)
 
 
 @app.route('/<database>/query')
 @require('user')
 def query(database):
     with view('database', database):
-        db = Database().load(database)
+        db = Database.load(database)
         if db is None:
             # not found
             abort(404)
 
-        fields = request.args.getlist('field')
-        ops = request.args.getlist('operator')
-        values = request.args.getlist('value')
-        query = zip(fields, ops, values)
+        # applicable search criteria
+        indexes = Indexes(database, Objs.columns)
 
-        # build query string
-        q = ['`%s` %s %%s' % v for v in zip(fields, ops)]
-        where = ' AND '.join(q)
+        # parse request
+        r = Request(request, Objs.columns, indexes)
 
-        # get objects
-        count = Objs(database=database).filter(where, *values).count()
-        batch = Pagination(_current_page(), size=PAGESIZE, count=count)
-        objs  = Objs(database=database).filter(where, *values).orderby(*_current_ordering()).limit(*batch.limits).all()
+        # collect objects
+        count = Objs(database).filter(*r.query).count()
+        batch = Pagination(r.page, size=PAGESIZE, count=count)
+        objs  = Objs(database).filter(*r.query).orderby(*r.order).limit(*batch.limits).all()
 
-        # collect search critaeria
-        criteria = _indexes(database, 'objmeta')
-
-        return render_template('query.html', query=query, objs=objs,
-                               criteria=criteria, database=db, fields=FIELDS, batch=batch)
+        return render_template('query.html', objs=objs, batch=batch,
+                               fields=FIELDS, database=db,
+                               indexes=indexes, query=r.criteria)
 
 
 @app.route('/<database>/query/<int:id>')
 @require('user')
 def namedquery(database, id):
     with view('database', database):
-        db = Database().load(database)
+        db = Database.load(database)
         if db is None:
             # not found
             abort(404)
@@ -332,95 +519,39 @@
         querystring = ' AND '.join("%s %s %s" % tuple(v) for v in params)
 
         # get objects
-        count = Objs(database=database).filter(where, *values).count()
-        batch = Pagination(_current_page(), size=PAGESIZE, count=count)
-        objs  = Objs(database=database).filter(where, *values).orderby(*_current_ordering()).limit(*batch.limits).all()
-
-        return render_template('namedquery.html', objs=objs, database=db, fields=FIELDS, batch=batch,
-                               query=query, querystring=querystring)
-
-
-class Timeseries(Objs):
-    def __init__(self, database):
-        super(Timeseries, self).__init__(database)
-        self._start = None
-        self._end = None
-
-    def timespan(self, start=None, end=None):
-        self._start = start
-        self._end = end
-        return self
+        r = Request()
+        count = Objs(database).filter(where, *values).count()
+        batch = Pagination(r.page, size=PAGESIZE, count=count)
+        objs  = Objs(database).filter(where, *values).orderby(*r.order).limit(*batch.limits).all()
 
-    @property
-    def _query(self):
-        columns = ", ".join("%s AS `%s`" % (RMAPPING[x], x) for x in FIELDS + ('t0', 'nsecs'))
-        query = "SELECT " + columns + " FROM `%s`.objmeta, `%s`.tsdata" % (self._database, self._database)
-        query += " WHERE objmeta.obj_id=tsdata.obj_id"
-        if self._end is not None:
-            query += " AND tsdata.t0 <= '%s'" % self._end
-        if self._start is not None:
-            query += " AND tsdata.t0 + INTERVAL tsdata.nsecs SECOND >= '%s'" % self._start
-        if self._where:
-            query += " AND %s" % self._where
-        if self._orderby:
-            query += " ORDER BY %s" % self._orderby
-        if self._reverse:
-            query += " DESC"
-        if self._limit:
-            query += " LIMIT %d,%d" % self._limit
-        return query
-
-    @property
-    def _count(self):
-        query = "SELECT COUNT(*) FROM `%s`.objmeta, `%s`.tsdata" % (self._database, self._database)
-        query += " WHERE objmeta.obj_id=tsdata.obj_id"
-        if self._end is not None:
-            query += " AND tsdata.t0 <= '%s'" % self._end
-        if self._start is not None:
-            query += " AND tsdata.t0 + INTERVAL tsdata.nsecs SECOND >= '%s'" % self._start
-        if self._where:
-            query += " AND %s" % self._where
-        if self._limit:
-            query += " LIMIT %d,%d" % self._limit
-        return query
+        return render_template('namedquery.html', objs=objs, batch=batch,
+                               fields=FIELDS, database=db,
+                               query=query, querystring=querystring)
 
 
 @app.route('/<database>/timeseries')
 @require('user')
 def timeseries(database):
-    db = Database().load(database)
+    db = Database.load(database)
     if db is None:
         # not found
         abort(404)
 
-    fields = request.args.getlist('field')
-    ops = request.args.getlist('operator')
-    values = request.args.getlist('value')
-    query = zip(fields, ops, values)
+    # applicable search criteria
+    indexes = Indexes(database, Timeseries.columns)
 
-    # build query string
-    q = ['`%s` %s %%s' % v for v in zip(fields, ops)]
-    where = ' AND '.join(q)
-
-    start = request.args.get('from', None) or None
-    end = request.args.get('to', None) or None
+    # parse reuest
+    r = Request(request, Timeseries.columns, indexes)
+    t = Timerange(request.args)
 
-    # get objects
-    count = Timeseries(database=database).filter(where, *values).timespan(start, end).count()
-    batch = Pagination(_current_page(), size=PAGESIZE, count=count)
-    objs  = Timeseries(database=database).filter(where, *values).orderby(*_current_ordering()).timespan(start, end).limit(*batch.limits).all()
-
-    # collect search critaeria
-    criteria = _indexes(database, 'objmeta')
+    # collect objects
+    count = Timeseries(database).filter(*r.query).timespan(*t.range).count()
+    batch = Pagination(r.page, size=PAGESIZE, count=count)
+    objs  = Timeseries(database).filter(*r.query).timespan(*t.range).orderby(*r.order).limit(*batch.limits).all()
 
-    fields = list(FIELDS)
-    fields.pop(2)
-    fields.pop(4)
-    fields.insert(2, 't0')
-    fields.insert(3, 'nsecs')
-
-    return render_template('timerange.html', query=query, objs=objs, lo=start, hi=end,
-                           criteria=criteria, database=db, fields=fields, batch=batch)
+    return render_template('timerange.html', objs=objs, batch=batch,
+                           fields=TIMESERIESFIELDS, database=db,
+                           indexes=indexes, query=r.criteria, timerange=t)
 
 
 module = app