Skip to content

Commit

Permalink
Merge pull request #522 from pipermerriam/piper/issue-495-non-numeric…
Browse files Browse the repository at this point in the history
…-expressions

Piper/issue 495 non numeric expressions
  • Loading branch information
mmclark committed Dec 11, 2015
2 parents 713573a + 598b1a7 commit de5e76d
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 17 deletions.
11 changes: 8 additions & 3 deletions seed/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,21 @@ def filter_other_params(queryset, other_params, db_columns):
exact_match = search_utils.is_exact_match(v)
empty_match = search_utils.is_empty_match(v)
not_empty_match = search_utils.is_not_empty_match(v)
is_expression = search_utils.is_expression(v)
is_numeric_expression = search_utils.is_numeric_expression(v)
is_string_expression = search_utils.is_string_expression(v)

if exact_match:
query_filters &= Q(**{"%s__exact" % k: exact_match.group(2)})
elif empty_match:
query_filters &= Q(**{"%s__exact" % k: ''}) | Q(**{"%s__isnull" % k: True})
elif not_empty_match:
query_filters &= ~Q(**{"%s__exact" % k: ''}) & Q(**{"%s__isnull" % k: False})
elif is_expression:
query_filters &= search_utils.parse_expression(k, v)
elif is_numeric_expression:
parts = search_utils.NUMERIC_EXPRESSION_REGEX.findall(v)
query_filters &= search_utils.parse_expression(k, parts)
elif is_string_expression:
parts = search_utils.STRING_EXPRESSION_REGEX.findall(v)
query_filters &= search_utils.parse_expression(k, parts)
elif ('__lt' in k or
'__lte' in k or
'__gt' in k or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
from django.test import TestCase

from seed.utils.search import (
is_expression,
is_numeric_expression,
parse_expression,
NUMERIC_EXPRESSION_REGEX,
)


Expand All @@ -28,17 +29,17 @@ def __new__(cls, name, bases, attrs):
return super(TestCaseFactory, cls).__new__(cls, name, bases, attrs)


def make_is_expression_method(value, expected):
def make_is_numeric_expression_method(value, expected):
def run(self):
result = is_expression(value)
result = is_numeric_expression(value)
self.assertEquals(bool(expected), bool(result))
return run


class IsExpressionTests(TestCase):
class IsNumericExpressionTests(TestCase):
__metaclass__ = TestCaseFactory
method_maker = make_is_expression_method
prefix = "test_is_expression"
method_maker = make_is_numeric_expression_method
prefix = "test_is_numeric_expression"

# test name, input, expected output
cases = [
Expand Down Expand Up @@ -103,7 +104,8 @@ def query_to_child_tuples(query):

def make_parse_expression_method(value, expected):
def run(self):
result = parse_expression("field", value)
parts = NUMERIC_EXPRESSION_REGEX.findall(value)
result = parse_expression("field", parts)
query_children = query_to_child_tuples(result)
self.assertEquals(expected, query_children)
return run
Expand All @@ -112,7 +114,7 @@ def run(self):
class ExpressionParserTests(TestCase):
__metaclass__ = TestCaseFactory
method_maker = make_parse_expression_method
prefix = "test_expression_parser"
prefix = "test_numeric_expression_parser"

# test name, input, expected output
cases = [
Expand Down
143 changes: 143 additions & 0 deletions seed/tests/test_string_expression_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""
:copyright: (c) 2014 Building Energy Inc
"""
import itertools
from django.test import TestCase

from seed.utils.search import (
is_string_expression,
parse_expression,
STRING_EXPRESSION_REGEX,
)


# Metaclass to create individual test methods per test case.
class TestCaseFactory(type):
def __new__(cls, name, bases, attrs):
cases = attrs['cases']
method_maker = attrs['method_maker']
prefix = attrs['prefix']

for doc, value, expected in cases:
test = method_maker(value, expected)
test_name = '{0}_{1}'.format(prefix, doc.lower().replace(' ', '_'))
if test_name in attrs:
raise KeyError("Test name {0} duplicated".format(test_name))
test.__name__ = test_name
test.__doc__ = doc
attrs[test_name] = test
return super(TestCaseFactory, cls).__new__(cls, name, bases, attrs)


def make_is_string_expression_method(value, expected):
def run(self):
result = is_string_expression(value)
self.assertEquals(expected, bool(result), (expected, result, value))
return run


class IsStringExpressionTests(TestCase):
__metaclass__ = TestCaseFactory
method_maker = make_is_string_expression_method
prefix = "test_is_string_expression"

# test name, input, expected output
cases = [
# Non expressions
('not_expression_1', 'abcd', False),
('not_expression_2', '', False),
('not_expression_3', None, False),
# Invalid operators
('not_expression_9', "<abc", False),
('not_expression_10', "<=abc", False),
('not_expression_11', ">abc", False),
('not_expression_12', ">=abc", False),
# Incomplete expressions
('not_expression_4', "=", False),
('not_expression_5', "==", False),
('not_expression_6', "!=", False),
('not_expression_7', "!", False),
('not_expression_8', "<>", False),
# Basic expressions
('equality_1', "=abcd", True),
('equality_2', "==abcd", True),
('inequality_1', "!=abcd", True),
('inequality_2', "!abcd", True),
('inequality_3', "<>abcd", True),
# Empty string expressions
('empty_string_expression_1', "==''", True),
('empty_string_expression_2', '=""', True),
# Whitespace
('whitespace_1', "= abcd", True),
('whitespace_2', " == abcd ", True),
# Internal whitespace
('internal_whitespace_1', "= ab cd", True),
('internal_whitespace_2', "= 123 abcd", True),
# Nulls checks
('is_null_1', "=null", True),
('is_null_2', "==null", True),
('is_not_null_1', "!=null", True),
('is_not_null_2', "!null", True),
('is_not_null_3', "<>null", True),
# Complex Expressions
('complex_1', "!=abc,<>xyz", True),
('complex_2', "!abc, !xyz", True),
('complex_3', "!=abc , !=xyz", True),
('complex_4', "!abc,!xyz,!null", True),
('complex_5', "!abc,==", True),
]


def query_to_child_tuples(query):
"""
Takes a Q object and extracts the underlying queries. Returns an iterable
of 3-tuples who's values are (negated, field_lookup, value)
"""
if isinstance(query, tuple):
return query
return list(itertools.chain.from_iterable((
(
[tuple(itertools.chain.from_iterable(([query.negated], c)))]
if isinstance(c, tuple)
else query_to_child_tuples(c)
)
for c in query.children
)))


def make_parse_expression_method(value, expected):
def run(self):
parts = STRING_EXPRESSION_REGEX.findall(value)
result = parse_expression("field", parts)
query_children = query_to_child_tuples(result)
self.assertEquals(expected, query_children)
return run


class ExpressionParserTests(TestCase):
__metaclass__ = TestCaseFactory
method_maker = make_parse_expression_method
prefix = "test_string_expression_parser"

# test name, input, expected output
cases = [
("equality_1", "=abcd", [(False, "field", "abcd")]),
("equality_2", "==abcd", [(False, "field", "abcd")]),
("inequality_1", "!=abcd", [(True, "field", "abcd")]),
("inequality_2", "!abcd", [(True, "field", "abcd")]),
("inequality_3", "<>abcd", [(True, "field", "abcd")]),
# null
("is_null_1", "=null", [(False, "field__isnull", True)]),
("is_null_2", "==null", [(False, "field__isnull", True)]),
("is_not_null_1", "!null", [(False, "field__isnull", False)]),
("is_not_null_2", "!=null", [(False, "field__isnull", False)]),
("is_not_null_3", "<>null", [(False, "field__isnull", False)]),
# complex expressions
("complex_1", "!=abcd,<>wxyz", [(True, "field", "abcd"), (True, "field", "wxyz")]),
("complex_2", "!null,!=wxyz", [(False, "field__isnull", False), (True, "field", "wxyz")]),
# invalid
("invalid_null_1", ">null", []),
("invalid_null_2", ">=null", []),
("invalid_null_3", "<null", []),
("invalid_null_4", "<=null", []),
]
31 changes: 25 additions & 6 deletions seed/utils/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,43 @@ def is_not_empty_match(q):
return False


EXPRESSION_REGEX = re.compile((
NUMERIC_EXPRESSION_REGEX = re.compile((
r'(' # open expression grp
r'(?P<operator>==|=|>|>=|<|<=|<>|!|!=)' # operator
r'\s*' # whitespace
r'(?P<value>(?:-?[0-9]+)|(?:null))' # numeric value or the string null
r'(?P<value>(?:-?[0-9]+)|(?:null))\s*(?:,|$)' # numeric value or the string null
r')' # close expression grp
))


def is_expression(q):
def is_numeric_expression(q):
"""
Checks whether a value looks like an expression, meaning that it contains a
substring that begins with a comparison operator followed by a numeric
value, optionally separated by whitespace.
"""
if is_string_query(q):
return EXPRESSION_REGEX.findall(q)
return NUMERIC_EXPRESSION_REGEX.findall(q)
return False


STRING_EXPRESSION_REGEX = re.compile((
r'(' # open expression grp
r'(?P<operator>==|(?<!<|>)=|<>|!|!=)' # operator
r'\s*' # whitespace
r'(?P<value>\'\'|""|null|[a-zA-Z0-9\s]+)\s*(?:,|$)' # open value grp
r')' # close expression grp
))


def is_string_expression(q):
"""
Checks whether a value looks like an expression, meaning that it contains a
substring that begins with a comparison operator followed by a numeric
value, optionally separated by whitespace.
"""
if is_string_query(q):
return STRING_EXPRESSION_REGEX.findall(q)
return False


Expand Down Expand Up @@ -109,12 +129,11 @@ def _translate_expression_parts(op, val):
return suffix, val, is_negated


def parse_expression(k, v):
def parse_expression(k, parts):
"""
Parse a complex expression into a Q object.
"""
query_filters = []
parts = EXPRESSION_REGEX.findall(v)

for src, op, val in parts:
try:
Expand Down

0 comments on commit de5e76d

Please sign in to comment.