Commits

ltnooy committed 26b022b

initial pyrqlate parser / deparser.

Comments (0)

Files changed (3)

-"""Parses Resource Query Language (RQL) expressions into logic.Expressions.
+#encoding: utf-8
+"""PYRQLATE - Python interpretation of RQL queries."""
 
-An implementation of RQL Expression for python.
-
-[1] - https://github.com/kriszyp/rql
-
-
-TODO: Evaluate if we want to use to implement the jsonQueryCompatible
-      branches in the js rql implementation?
-"""
 import datetime
-import json
-import re
-import rfc822
-import time
+import decimal
 import urllib
 
-converters = {}
 
-class InvalidExpression(Exception):
-    """Thrown when the expression is invalid.
-    """
+#-------------------------------------------------------------------------------
+class Converters(object):
 
-class Timezone(datetime.tzinfo):
-    """Timezone class based on a constant."""
+    #---------------------------------------------------------------------------
+    class Timezone(datetime.tzinfo):
+        """Timezone class based on a constant offset."""
 
-    def __init__(self, offset=0):
-        """Timezone[offset=<minutes>]"""
-        self._offset = offset
-        self._timedelta = datetime.timedelta(seconds = offset * 60)
+        #-----------------------------------------------------------------------
+        def __init__(self, offset=0):
+            """Timezone[offset=<minutes>]"""
+            self._offset = offset
+            self._timedelta = datetime.timedelta(seconds=offset*60)
 
-    def utcoffset(self, dt):
-        return self._timedelta
+        #-----------------------------------------------------------------------
+        def utcoffset(self, dt):
+            return self._timedelta
 
-    def dst(self, dt):
-        return datetime.timedelta(0)
+        #-----------------------------------------------------------------------
+        def dst(self, dt):
+            return datetime.timedelta(0)
 
-    def tzname(self, dt):
-        minutes = self._offset
-        sign = 1
-        if minutes < 0:
-            sign = -1
-        minutes = minutes * sign
+        #-----------------------------------------------------------------------
+        def tzname(self, dt):
+            minutes = self._offset
+            sign = 1
+            if minutes < 0:
+                sign = -1
+            minutes = minutes * sign
 
-        hours = minutes / 60
-        minutes = minutes % 60
-        return u'UTC%+03d:%02d' % (hours * sign, minutes)
+            hours = minutes / 60
+            minutes = minutes % 60
+            return u'UTC%+03d:%02d' % (hours * sign, minutes)
 
-def string_converter(value):
-    return urllib.unquote(value)
-converters['string'] = string_converter
+    #-----------------------------------------------------------------------
+    """Runtime cache of Timezones, see get_timezone for usage."""
+    zones = {
+        0: Timezone(0) # UTC
+    }
 
-def boolean_converter(value):
-    return value == "true"
-converters['boolean'] = boolean_converter
+    #---------------------------------------------------------------------------
+    @classmethod
+    def get_timezone(cls, offset=0):
+        """
+        Returns a tzinfo instance representing the timezone matching offset.
 
-def number_converter(value):
-    try:
-        num_type = int
-        if "." in value:
-            num_type = float
+        Params:
+            -- offset: The number of minutes from GMT. [-720, 720]
+        """
+        
+        if offset < -720:
+            offset = -720
+        if offset > 720:
+            offset = 720
 
-        return num_type(value)
-    except (ValueError, TypeError), e:
-        raise InvalidExpression("%s is not a valid number" % value)
-converters['number'] = number_converter
+        zone = cls.zones.get(offset, None)
+        if zone is None:
+            zone = cls.Timezone(offset)
+            cls.zones[offset] = zone
+        return zone
+    
+    #---------------------------------------------------------------------------
+    @classmethod
+    def datetime_from_iso(cls, time):
+        """Produces a datetime and zone from a string.
 
-auto_converter_lookup = {
-        "true": True,
-        "false": False,
-        "null": None,
-        "undefined": None, # TODO: Is this correct, we don't have an equivalent in python.
-        "Infinity": float("inf"),
-        "-Infinity": float("-inf")
-}
-def auto_converter(value):
-    # if this is a predefined value - just return it directly.
-    if auto_converter_lookup.has_key(value):
-        return auto_converter_lookup[value]
-
-    # Otherwise see if it's a number.
-    # TODO: in the js rql version - they check to see that the string representation
-    #       of the parsed number was exactly the same as the passed in string - which
-    #       is a bit wierd considering that we're using a floating point representation.
-    number = None
-    try:
-        number = number_converter(value)
-    except InvalidExpression:
-        pass
-
-    if "%s" % number != value or number is None:
-        # Now we'll treat it like a string, so unquote it.
-        value = urllib.unquote(value)
-
-        if value[0] == "'" and value[-1] == "'":
-            # Using json at this point will properly deal with unicode.
-            value = json.loads(value[1:-1])
-        return value
-    return number
-converters['auto'] = auto_converter
-converters['default'] = auto_converter
-
-def epoch_converter(value):
-    try:
-        value = number_converter(value)
-        return datetime.datetime.fromtimestamp(value)
-    except (ValueError, InvalidExpression):
-        raise InvalidExpression("%s is not a valid time since the epoch" % value)
-converters['epoch'] = epoch_converter
-
-def date_converter(value):
-    if not value:
-        raise InvalidExpression("%s is not a valid date and time" % value)
-
-    iso_date_pattern_tz = r"^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d).(\d\d\d)([-+]{1})(\d{2}):(\d{2})$"
-    iso_date_pattern_utc = r"^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d).(\d\d\d)Z$"
-
-    # Try parsing it with no timezone.
-    for pattern in (iso_date_pattern_tz, iso_date_pattern_utc):
-        match = re.match(pattern, value)
-        if match:
-            value = str(value)
-            offset = value[23:29]
-            sign = offset[:1]
-
-            if sign in ('-', '+'):
+        Allowed formats:
+            YYYY-MM-DDThh:mm:ss.mmmZ
+            YYYY-MM-DDThh:mm:ss.mmm-HH:MM
+            YYYY-MM-DDThh:mm:ss.mmm+HH:MM
+        """
+       
+        try:
+            time = str(time)
+            offset = time[23:29]
+           
+            if offset[:1] == '-':
                 offset = offset[1:].split(':')
+                offset = -(int(offset[0]) * 60 + int(offset[1]))
+                if offset < -720:
+                    offset = -720
+            elif offset[:1] == '+':
+                offset = offset[1:].split(':') 
                 offset = int(offset[0]) * 60 + int(offset[1])
-                if sign == '-':
-                    offset = -offset
-
-                # Clamp it to [-720, 720]
-                offset = min(max(offset, -720), 720)
-
-            else:
+                if offset > 720:
+                    offset = 720
+            else: 
                 offset = 0
+           
+            zone = cls.get_timezone(offset) 
 
             # Extract the date & time component
-            value = value[0:23]
-            date = datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
+            time = time[0:23]
+            date = datetime.datetime.strptime(time, "%Y-%m-%dT%H:%M:%S.%f")
 
             # Make the time, zone aware.
-            date = date.replace(tzinfo=Timezone(offset))
-            return date
+            date = date.replace(tzinfo=zone)
+        
+        except Exception, e:
+            raise ValueError(str(e))
 
-    try:
-        time_tuple = rfc822.parsedate_tz(value)
-        if time_tuple is None:
-            raise InvalidExpression("%s is not a valid date and time" % value)
-        tzinfo = Timezone(time_tuple[-1]/60)
-        time_value = datetime.datetime.fromtimestamp(time.mktime(time_tuple[:9]))
-        return time_value.replace(tzinfo=tzinfo)
-    except (OverflowError, ValueError, TypeError):
-        raise InvalidExpression("%s is not a valid date and time" % value)
-converters['date'] = date_converter
+        return date
 
+    #---------------------------------------------------------------------------
+    @classmethod
+    def datetime_to_iso(cls, time):
+        """Produces an iso representation from a datetime object.
 
+        Format:
+            YYYY-MM-DDTMM:HH:SS.mmm±HH:MM
+        """
+
+        if time.tzinfo is None:
+            time = time.astimezone(cls.get_timezone(0))
+
+        string = time.strftime("%Y-%m-%dT%H:%M:%S")
+
+        # We use milliseconds, not microseconds for wider compatibility.
+        milliseconds = int(time.strftime("%f")) / 1000
+        string = "%s.%03d" % (string, milliseconds)
+
+        # Slap the timezone at the end of the string.
+        offset = time.tzinfo.utcoffset(time)
+        minutes = (offset.days * 1440) + (offset.seconds / 60)
+        string = "%s%+03d:%02d" % (string, (minutes / 60), abs(minutes) % 60)
+        return string
+
+    #---------------------------------------------------------------------------    
+    @classmethod
+    def to_python(cls, type, value):
+        lookup = {
+            'string': lambda x: unicode(x),
+            'boolean': lambda x: bool(x),
+            'number': lambda x: decimal.Decimal(x),
+            'datetime': lambda x: cls.datetime_from_iso(x) 
+        }
+        converter = lookup.get(type)
+        if not converter:
+            raise ParseError(u'Unrecognized variable type %s' % type)
+        return converter(value)
+
+    #---------------------------------------------------------------------------
+    @classmethod
+    def from_python(cls, value):
+        lookup = {
+            unicode: lambda x: urllib.quote(x.encode('utf-8')),
+            bool: lambda x: unicode(x).lower(),
+            decimal.Decimal: lambda x: unicode(x),
+            datetime.datetime: lambda x: cls.datetime_to_iso(x),
+            type(None): lambda x: 'null'
+        }
+       
+        converter = lookup.get(type(value))
+        if not converter:
+            raise ParseError(u'Unrecognized variable type %s' % type(value))
+        return converter(value)
+
+
+#-------------------------------------------------------------------------------
+class Query(object):
+
+    operations = ['eq', 'lt', 'le', 'gt', 'ge', 'ne', 'in', 'out']
+    conjunctions = ['and', 'or']
+
+    #--------------------------------------------------------------------------- 
+    def __init__(self, name='and', args=None, cache=None, parent=None):
+        self.name = name
+
+        self.args = args
+        if self.args is None:
+            self.args = []
+
+        self.cache = cache
+        if self.cache is None:
+            self.cache = {}
+
+        self.parent = parent
+
+    #---------------------------------------------------------------------------
+    def ops(self):
+        """Return a flattened list of the all operations performed by this query.
+
+        Operations are composed of ('eq', 'lt', 'le', 'gt', 'ge', 'ne', 'in', 'out')
+        """
+
+        ops = [x for x in self.args if type(x) == Query and x.name in self.operations]
+        for x in self.args:
+            if type(x) == Query:
+                ops += x.ops()
+        return ops
+
+    #---------------------------------------------------------------------------
+    def expand(self):
+        return self.cache.get('expand')
+   
+    #--------------------------------------------------------------------------- 
+    def sort(self):
+        return self.cache.get('sort')
+
+    #---------------------------------------------------------------------------
+    def __unicode__(self):
+        return u'%s(%s)' % (self.name, u','.join([unicode(arg) for arg in self.args]))
+
+    #---------------------------------------------------------------------------
+    def eq(self, var, val):
+        self.args.append(Query(name='eq', args=[var, val], parent=self))
+        return self
+
+    #---------------------------------------------------------------------------
+    def aand(self, query):
+        parent = Query(name='and', args=[self, query], parent=self.parent)
+        self.parent = parent
+        query.parent = parent
+        return parent
+
+    #---------------------------------------------------------------------------
+    def oor(self, query):
+        parent = Query(name='or', args=[self, query], parent=self.parent)
+        self.parent = parent
+        query.parent = parent
+        return parent
+
+    #---------------------------------------------------------------------------
+    def walk(self, visitor):
+        if self.name == 'and':
+            return visitor.visit_and(self)
+        elif self.name == 'or':
+            return visitor.visit_or(self)
+        elif self.name == 'eq':
+            return visitor.visit_eq(self)
+        elif self.name == 'lt':
+            return visitor.visit_lt(self)
+        elif self.name == 'le':
+            return visitor.visit_le(self)
+        elif self.name == 'gt':
+            return visitor.visit_gt(self)
+        elif self.name == 'ge':
+            return visitor.visit_ge(self)
+        elif self.name == 'ne':
+            return visitor.visit_ne(self)
+        elif self.name == 'in':
+            return visitor.visit_in(self)
+        elif self.name == 'out':
+            return visitor.visit_out(self)
+        elif self.name == 'expand':
+            return visitor.visit_expand(self)
+        elif self.name == 'sort':
+            return visitor.visit_sort(self)
+        elif self.name == 'select':
+            return visitor.visit_select(self)
+        elif self.name == 'array':
+            return visitor.visit_array(self)
+        elif self.name == 'limit':
+            return visitor.visit_limit(self)
+
+
+#-------------------------------------------------------------------------------
+class ParseError(Exception):
+    pass
+
+
+#-------------------------------------------------------------------------------
+def __pre_parse(rql):
+    """Converts the RQL url format into a normalized format.
+
+    Example:
+        id=10 => eq(id,10)
+        id=lt=10 => lt(id,10)
+        id=10&title=eagles => eq(id,10)&eq(title,eagles)
+    """
+    
+    operators = [
+        (u'=lt=', u'lt'), 
+        (u'=le=', u'le'),
+        (u'=ge=', u'ge'), 
+        (u'=gt=', u'gt'),
+        (u'=ne=', u'ne'),
+        (u'=eq=', u'eq'),
+        (u'=in=', u'in'),
+        (u'=out=', u'out'),
+        (u'<=', u'le'),
+        (u'>=', u'ge'),
+        (u'!=', u'ne'),
+        (u'==', u'eq'),
+        (u'<', u'lt'),
+        (u'>', u'gt'),
+        (u'=', u'eq')
+    ]
+
+    if not isinstance(rql, unicode):
+        # Note that we are assuming unicode characters are encoded as utf-8.
+        rql = rql.decode('utf-8')
+ 
+    # Search left to right for an appropriate value.
+    def find_value(uni, start):
+        next = start
+        while next < len(uni):
+            if uni[next] in u'|&=':
+                break
+            next += 1
+        return uni[start:next]
+
+    # Search right to left for an appropriate variable.
+    def find_variable(uni, start):
+        prev = start
+        while prev > 0:
+            prev -= 1
+            if uni[prev] in u'()&|,=':
+                prev += 1
+                break
+
+        return uni[prev:start]
+        
+    for op in operators:
+        current = 0
+        while True:
+            current = rql.find(op[0], current + 1)
+            if current < 0:
+                break
+
+            # Find the variable name before this operation.
+            variable = find_variable(rql, current)
+
+            # Find the value after the operation.
+            current += len(op[0])
+            value = find_value(rql, current)
+
+            if op[1] == u'in' or op[1] == u'out':
+                if value[0] != u'(':
+                    raise ParseError('The in operator requires an array as its only argument.')
+
+            rql = rql.replace(
+                    u'%s%s%s' % (variable, op[0], value),
+                    u'%s(%s,%s)' % (op[1], variable, value)
+                )
+
+            # Reset the search for the operator as the string has changed length.
+            current = 0
+
+    return rql
+
+
+#-------------------------------------------------------------------------------
+def __convert_to_python(value):
+    """Covert an RQL parsed value into a python equivalent"""
+
+    pieces = value.split(':')
+    if len(pieces) == 2:
+        type, value = pieces
+    else:
+        type = None
+
+    value = urllib.unquote(value)
+
+    if type:
+        return Converters.to_python(type, value)
+    else:
+        if value == 'Infinity':
+            return decimal.Decimal(value)
+        elif value == '-Infinity':
+            return decimal.Decimal(value)
+        elif value == 'true':
+            return True
+        elif value == 'false':
+            return False
+        elif value == 'null':
+            return None
+        else:
+            try:
+                return Converters.to_python('number', value)
+            except Exception, e:
+                pass
+
+            try:
+                return Converters.to_python('datetime', value)
+            except Exception:
+                pass
+
+    return value
+ 
+
+#-------------------------------------------------------------------------------
+def parse(rql):
+
+    rql = __pre_parse(rql)
+    term = Query(name=None)
+    root = term
+
+    property_or_value = u''
+    for current in range(len(rql)):
+
+        # Conjunction delimiters 
+        if rql[current] == u'&':
+            if not term.name:
+                term.name = u'and'
+            elif term.name != u'and':
+                raise ParseError('Cannot mix conjunctions within a group, use parentheses to break up expression.')
+            continue
+
+        if rql[current] == u'|':
+            if not term.name:
+                term.name = u'or'
+            elif term.name != u'or':
+                raise ParseError('Cannot mix conjunctions within a group, use parentheses to break up expression.')
+            continue
+
+        # Opening parenthesis
+        if rql[current] == u'(':
+            new_term = Query(name=property_or_value, args=[])
+            property_or_value = u''
+            new_term.parent = term
+            term = new_term
+
+            if (term.name == u'sort' or 
+                term.name == u'select' or
+                term.name == u'expand' or
+                term.name == u'limit'):
+                root.cache[term.name] = term
+            continue
+
+        # Closing parenthesis
+        elif rql[current] == u')':
+            if not term.name:
+                term.name = u'array'
+                if term.parent and (term.parent.name == u'and' or term.parent.name == u'or'):
+                    raise ParseError('Conjunctions require operations as arguments, not an array.')
+           
+            if property_or_value:
+                if term.name == u'and' or term.name == u'or':
+                    raise ParseError('Conjunctions require operations as arguments, not values.')
+                term.args.append(__convert_to_python(property_or_value))
+                property_or_value = u''
+
+            if not term.parent:
+                raise ParseError('Closing parenthesis without an opening parenthesis is not allowed.') 
+            term.parent.args.append(term)
+            term = term.parent
+            continue
+        
+        if rql[current] in u',=':
+            if property_or_value:
+                term.args.append(__convert_to_python(property_or_value))
+                property_or_value = u''
+        else:
+            property_or_value += rql[current]
+
+    if term.parent:
+        raise ParseError('Opening parenthesis without a closing parenthesis.')
+    if not term.name:
+        term.name = u'and'
+
+    # Fix some degenerate conjunction cases.
+    # ex: id=10&(|people=>20) => id=10|people=20
+    outer = {'term': term}
+    def fix(local):
+        for arg in local.args:
+            if local.name != u'and' and local.name != u'or':
+                continue
+
+            if len(arg.args) == 1 and (arg.name == u'and' or arg.name == u'or'):
+                local.args.pop(local.args.index(arg))
+                arg.parent = local.parent
+                local.parent = arg
+
+                if len(local.args) == 1:
+                    arg.args.insert(0, local.args[0])
+                else:
+                    arg.args.insert(0, local)
+
+                if not arg.parent:
+                    outer['term'] = arg
+                return True
+            
+            if fix(arg):
+                return True
+        return False
+
+    while fix(term):
+        pass
+    term = outer['term']
+
+    return term
+
+#-------------------------------------------------------------------------------
+class DeparseVisitor(object):
+
+    #---------------------------------------------------------------------------  
+    def visit_and(self, query):
+        sub = []
+        for s in query.args:
+            sub.append(s.walk(self))
+       
+        if query.parent:
+            return u'(%s)' % (u'&'.join(sub))
+        else:
+            return u'&'.join(sub)
+
+    #---------------------------------------------------------------------------
+    def visit_or(self, query):
+        sub = []
+        for s in query.args:
+            sub.append(s.walk(self))
+
+        if query.parent:
+            return u'(%s)' % (u'|'.join(sub))
+        else:
+            return u'|'.join(sub)
+ 
+    #--------------------------------------------------------------------------- 
+    def visit_eq(self, query):
+        return u'%s=%s' % (query.args[0], Converters.from_python(query.args[1]))
+    
+    #--------------------------------------------------------------------------- 
+    def visit_ne(self, query):
+        return u'%s=ne=%s' % (query.args[0], Converters.from_python(query.args[1]))
+
+    #---------------------------------------------------------------------------
+    def visit_lt(self, query):
+        return u'%s=lt=%s' % (query.args[0], Converters.from_python(query.args[1]))
+    
+    #---------------------------------------------------------------------------
+    def visit_le(self, query):
+        return u'%s=le=%s' % (query.args[0], Converters.from_python(query.args[1]))
+
+    #---------------------------------------------------------------------------
+    def visit_gt(self, query):
+        return u'%s=gt=%s' % (query.args[0], Converters.from_python(query.args[1]))
+
+    #---------------------------------------------------------------------------
+    def visit_ge(self, query):
+        return u'%s=ge=%s' % (query.args[0], Converters.from_python(query.args[1]))
+
+#-------------------------------------------------------------------------------
+def deparse(query):
+    """Deparse an RQL query into its url encoding form."""
+
+    return query.walk(DeparseVisitor())
+
+#encoding: utf-8
+
+import unittest
+import pyrqlate
+
+#-------------------------------------------------------------------------------
+class TestParse(unittest.TestCase):
+
+    #---------------------------------------------------------------------------
+    def test_eq_parse(self):
+
+        q = pyrqlate.parse('id=1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=1')
+        q = pyrqlate.parse('id=eagles')
+        self.assertEqual(pyrqlate.deparse(q), 'id=eagles')
+        q = pyrqlate.parse('id=true')
+        self.assertEqual(pyrqlate.deparse(q), 'id=true')
+        q = pyrqlate.parse('id=false')
+        self.assertEqual(pyrqlate.deparse(q), 'id=false')
+        q = pyrqlate.parse('id=null')
+        self.assertEqual(pyrqlate.deparse(q), 'id=null')
+        q = pyrqlate.parse('id=2011-01-01T00:00:00.000-07:00')
+        self.assertEqual(pyrqlate.deparse(q), 'id=2011-01-01T00:00:00.000-07:00')
+        q = pyrqlate.parse('id=1.56')
+        self.assertEqual(pyrqlate.deparse(q), 'id=1.56')
+
+        # UTF-8 encoded unicode.
+        q = pyrqlate.parse('id=орлов')
+        self.assertEqual(pyrqlate.deparse(q), 'id=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+        # Pre decoded unicode.
+        q = pyrqlate.parse(u'id=орлов')
+        self.assertEqual(pyrqlate.deparse(q), u'id=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+    
+    #---------------------------------------------------------------------------
+    def test_ne_parse(self):
+        
+        q = pyrqlate.parse('id!=1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ne=1')
+
+        q = pyrqlate.parse('id=ne=1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ne=1')
+        q = pyrqlate.parse('id=ne=eagles')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ne=eagles')
+        q = pyrqlate.parse('id=ne=true')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ne=true')
+        q = pyrqlate.parse('id=ne=false')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ne=false')
+        q = pyrqlate.parse('id=ne=null')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ne=null')
+        q = pyrqlate.parse('id=ne=2011-01-01T00:00:00.000-07:00')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ne=2011-01-01T00:00:00.000-07:00')
+        q = pyrqlate.parse('id=ne=1.56')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ne=1.56')
+
+        # UTF-8 encoded unicode.
+        q = pyrqlate.parse('id=ne=орлов')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ne=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+        # Pre decoded unicode.
+        q = pyrqlate.parse(u'id=ne=орлов')
+        self.assertEqual(pyrqlate.deparse(q), u'id=ne=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+    #---------------------------------------------------------------------------
+    def test_lt_parse(self):
+       
+        q = pyrqlate.parse('id<1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=lt=1')
+ 
+        q = pyrqlate.parse('id=lt=1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=lt=1')
+        q = pyrqlate.parse('id=lt=eagles')
+        self.assertEqual(pyrqlate.deparse(q), 'id=lt=eagles')
+        q = pyrqlate.parse('id=lt=true')
+        self.assertEqual(pyrqlate.deparse(q), 'id=lt=true')
+        q = pyrqlate.parse('id=lt=false')
+        self.assertEqual(pyrqlate.deparse(q), 'id=lt=false')
+        q = pyrqlate.parse('id=lt=null')
+        self.assertEqual(pyrqlate.deparse(q), 'id=lt=null')
+        q = pyrqlate.parse('id=lt=2011-01-01T00:00:00.000-07:00')
+        self.assertEqual(pyrqlate.deparse(q), 'id=lt=2011-01-01T00:00:00.000-07:00')
+        q = pyrqlate.parse('id=lt=1.56')
+        self.assertEqual(pyrqlate.deparse(q), 'id=lt=1.56')
+
+        # UTF-8 encoded unicode.
+        q = pyrqlate.parse('id=lt=орлов')
+        self.assertEqual(pyrqlate.deparse(q), 'id=lt=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+        # Pre decoded unicode.
+        q = pyrqlate.parse(u'id=lt=орлов')
+        self.assertEqual(pyrqlate.deparse(q), u'id=lt=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+    #---------------------------------------------------------------------------
+    def test_le_parse(self):
+        
+        q = pyrqlate.parse('id<=1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=le=1')
+        
+        q = pyrqlate.parse('id=le=1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=le=1')
+        q = pyrqlate.parse('id=le=eagles')
+        self.assertEqual(pyrqlate.deparse(q), 'id=le=eagles')
+        q = pyrqlate.parse('id=le=true')
+        self.assertEqual(pyrqlate.deparse(q), 'id=le=true')
+        q = pyrqlate.parse('id=le=false')
+        self.assertEqual(pyrqlate.deparse(q), 'id=le=false')
+        q = pyrqlate.parse('id=le=null')
+        self.assertEqual(pyrqlate.deparse(q), 'id=le=null')
+        q = pyrqlate.parse('id=le=2011-01-01T00:00:00.000-07:00')
+        self.assertEqual(pyrqlate.deparse(q), 'id=le=2011-01-01T00:00:00.000-07:00')
+        q = pyrqlate.parse('id=le=1.56')
+        self.assertEqual(pyrqlate.deparse(q), 'id=le=1.56')
+
+        # UTF-8 encoded unicode.
+        q = pyrqlate.parse('id=le=орлов')
+        self.assertEqual(pyrqlate.deparse(q), 'id=le=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+        # Pre decoded unicode.
+        q = pyrqlate.parse(u'id=le=орлов')
+        self.assertEqual(pyrqlate.deparse(q), u'id=le=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+    #---------------------------------------------------------------------------
+    def test_gt_parse(self):
+        
+        q = pyrqlate.parse('id>1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=gt=1')
+        
+        q = pyrqlate.parse('id=gt=1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=gt=1')
+        q = pyrqlate.parse('id=gt=eagles')
+        self.assertEqual(pyrqlate.deparse(q), 'id=gt=eagles')
+        q = pyrqlate.parse('id=gt=true')
+        self.assertEqual(pyrqlate.deparse(q), 'id=gt=true')
+        q = pyrqlate.parse('id=gt=false')
+        self.assertEqual(pyrqlate.deparse(q), 'id=gt=false')
+        q = pyrqlate.parse('id=gt=null')
+        self.assertEqual(pyrqlate.deparse(q), 'id=gt=null')
+        q = pyrqlate.parse('id=gt=2011-01-01T00:00:00.000-07:00')
+        self.assertEqual(pyrqlate.deparse(q), 'id=gt=2011-01-01T00:00:00.000-07:00')
+        q = pyrqlate.parse('id=gt=1.56')
+        self.assertEqual(pyrqlate.deparse(q), 'id=gt=1.56')
+
+        # UTF-8 encoded unicode.
+        q = pyrqlate.parse('id=gt=орлов')
+        self.assertEqual(pyrqlate.deparse(q), 'id=gt=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+        # Pre decoded unicode.
+        q = pyrqlate.parse(u'id=gt=орлов')
+        self.assertEqual(pyrqlate.deparse(q), u'id=gt=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+    #---------------------------------------------------------------------------
+    def test_ge_parse(self):
+        
+        q = pyrqlate.parse('id>=1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ge=1')
+        
+        q = pyrqlate.parse('id=ge=1')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ge=1')
+        q = pyrqlate.parse('id=ge=eagles')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ge=eagles')
+        q = pyrqlate.parse('id=ge=true')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ge=true')
+        q = pyrqlate.parse('id=ge=false')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ge=false')
+        q = pyrqlate.parse('id=ge=null')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ge=null')
+        q = pyrqlate.parse('id=ge=2011-01-01T00:00:00.000-07:00')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ge=2011-01-01T00:00:00.000-07:00')
+        q = pyrqlate.parse('id=ge=1.56')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ge=1.56')
+
+        # UTF-8 encoded unicode.
+        q = pyrqlate.parse('id=ge=орлов')
+        self.assertEqual(pyrqlate.deparse(q), 'id=ge=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+        # Pre decoded unicode.
+        q = pyrqlate.parse(u'id=ge=орлов')
+        self.assertEqual(pyrqlate.deparse(q), u'id=ge=%D0%BE%D1%80%D0%BB%D0%BE%D0%B2')
+
+    #---------------------------------------------------------------------------
+    def test_and_parse(self):
+
+        q = pyrqlate.parse('id=1&name=eagle')
+        self.assertEqual(pyrqlate.deparse(q), 'id=1&name=eagle')
+
+        q = pyrqlate.parse('id=1&(name=eagle&age=1)')
+        self.assertEqual(pyrqlate.deparse(q), 'id=1&(name=eagle&age=1)')
+
+    #---------------------------------------------------------------------------
+    def test_or_parse(self):
+
+        q = pyrqlate.parse('id=1|name=eagle')
+        self.assertEqual(pyrqlate.deparse(q), 'id=1|name=eagle')
+
+        q = pyrqlate.parse('id=1|(name=eagle|age=1)')
+        self.assertEqual(pyrqlate.deparse(q), 'id=1|(name=eagle|age=1)')
+        

tests/test_converters.py

-import datetime
-import unittest
-
-import pyrqlate
-
-class ConvertorTestCase(unittest.TestCase):
-
-    def test_string_converter(self):
-        self.assertEqual(pyrqlate.string_converter, pyrqlate.converters['string'])
-        self.assertEqual(pyrqlate.string_converter("%20"), " ")
-        self.assertEqual(pyrqlate.string_converter("%21"), "!")
-        self.assertEqual(pyrqlate.string_converter("%21%20"), "! ")
-        self.assertEqual(pyrqlate.string_converter("%28%21%20%29"), "(! )")
-
-    def test_boolean_converter(self):
-        self.assertEqual(pyrqlate.boolean_converter, pyrqlate.converters['boolean'])
-        self.assertTrue(pyrqlate.boolean_converter("true"))
-        self.assertFalse(pyrqlate.boolean_converter("false"))
-        self.assertFalse(pyrqlate.boolean_converter("ads"))
-        self.assertFalse(pyrqlate.boolean_converter(""))
-
-    def test_number_converter(self):
-        self.assertEqual(pyrqlate.number_converter, pyrqlate.converters['number'])
-
-        self.assertEqual(pyrqlate.number_converter("20.0"), 20.0)
-        self.assertEqual(pyrqlate.number_converter("20.5"), 20.5)
-        self.assertEqual(pyrqlate.number_converter("20"), 20)
-
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.number_converter, "asdf")
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.number_converter, None)
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.number_converter, False)
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.number_converter, True)
-
-    def test_auto_converter(self):
-        self.assertEqual(pyrqlate.auto_converter, pyrqlate.converters['auto'])
-        self.assertEqual(pyrqlate.auto_converter, pyrqlate.converters['default'])
-
-        # First test the lookup values.
-        self.assertEqual(pyrqlate.auto_converter("true"), True)
-        self.assertEqual(pyrqlate.auto_converter("false"), False)
-        self.assertEqual(pyrqlate.auto_converter("null"), None)
-        self.assertEqual(pyrqlate.auto_converter("undefined"), None)
-        self.assertEqual(pyrqlate.auto_converter("Infinity"), float("inf"))
-        self.assertEqual(pyrqlate.auto_converter("-Infinity"), float("-inf"))
-
-        # Now test the numbers
-        self.assertEqual(pyrqlate.auto_converter("45.0"), 45.0)
-        self.assertEqual(pyrqlate.auto_converter("45.7"), 45.7)
-        self.assertEqual(pyrqlate.auto_converter("45"), 45)
-        self.assertEqual(pyrqlate.auto_converter("450"), 450)
-
-        # Now test some straight strings.
-        self.assertEqual(pyrqlate.auto_converter("%21%20"), "! ")
-        self.assertEqual(pyrqlate.auto_converter("%28%21%20%29"), "(! )")
-
-        # Now test some strings that can be converted to numbers, but shouldn't be
-        self.assertEqual(pyrqlate.auto_converter("20asdf"), "20asdf")
-        self.assertEqual(pyrqlate.auto_converter("45ixi3"), "45ixi3")
-
-    def test_epoch_converter(self):
-        self.assertEqual(pyrqlate.epoch_converter, pyrqlate.converters['epoch'])
-
-        self.assertEqual(pyrqlate.epoch_converter("1319520900"), datetime.datetime(2011, 10, 24, 23, 35))
-        self.assertEqual(pyrqlate.epoch_converter("1319522400"), datetime.datetime(2011, 10, 25, 0, 0))
-
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.epoch_converter, "asdf")
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.epoch_converter, None)
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.epoch_converter, False)
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.epoch_converter, True)
-
-    def test_date_converter(self):
-        self.assertEqual(pyrqlate.date_converter, pyrqlate.converters['date'])
-
-        # Test that bad data is caught
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.date_converter, "")
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.date_converter, None)
-        self.assertRaises(pyrqlate.InvalidExpression, pyrqlate.date_converter, "asdfasdfadsf")
-
-        # First test the lookup values.
-        value = pyrqlate.date_converter("Sat, 12 Nov 2011 17:29:51 -0700")
-        expected_value = datetime.datetime(2011, 11, 12, 17, 29, 51, 0, tzinfo=pyrqlate.Timezone(-7*60))
-        self.assertEqual(expected_value, value)
-
-        value = pyrqlate.date_converter("Sat, 12 Nov 2011 17:29:51 -0800")
-        expected_value = datetime.datetime(2011, 11, 12, 17, 29, 51, 0, tzinfo=pyrqlate.Timezone(-8*60))
-        self.assertEqual(expected_value, value)
-
-        value = pyrqlate.date_converter("Sat, 13 Nov 2001 17:29:51 -0800")
-        expected_value = datetime.datetime(2001, 11, 13, 17, 29, 51, 0, tzinfo=pyrqlate.Timezone(-8*60))
-        self.assertEqual(expected_value, value)
-
-        value = pyrqlate.date_converter("2011-11-13T17:29:51.000-08:00")
-        expected_value = datetime.datetime(2011, 11, 13, 17, 29, 51, 0, tzinfo=pyrqlate.Timezone(-8*60))
-        self.assertEqual(expected_value, value)
-
-        value = pyrqlate.date_converter("2011-11-13T17:29:51.000Z")
-        expected_value = datetime.datetime(2011, 11, 13, 17, 29, 51, 0, tzinfo=pyrqlate.Timezone(0*60))
-        self.assertEqual(expected_value, value)
-
-        value = pyrqlate.date_converter("2001-11-13T17:29:51.000-08:00")
-        expected_value = datetime.datetime(2001, 11, 13, 17, 29, 51, 0, tzinfo=pyrqlate.Timezone(-8*60))
-        self.assertEqual(expected_value, value)
-
-        value = pyrqlate.date_converter("2001-11-13T17:29:51.000Z")
-        expected_value = datetime.datetime(2001, 11, 13, 17, 29, 51, 0, tzinfo=pyrqlate.Timezone(0*60))
-        self.assertEqual(expected_value, value)
-
-