Skip to content

Commit c1813e8

Browse files
committed
fix: issue2551374. Error handling for filter expressions.
Errors in filter expressions are now reported. The UI needs some work but even the current code is helpful when debugging filter expressions. mlink_expr: defines/raises ExpressionError(error string template, context=dict()) raises ExpressionError when it detects errors when popping arguments off stack raises ExpressionError when more than one element left on the stack before returning also ruff fix to group boolean expression with parens back_anydbm.py, rdbms_common.py: catches ExpressionError, augments context with class and attribute being searched. raises the exception for both link and multilink relations client.py catches ExpressionError returning a basic error page. The page is a dead end. There are no links or anything for the user to move forward. The user has to go back, possibly refresh the page (because the submit button may be disalbled) re-enter the query and try again. This needs to be improved. test_liveserver.py test the error page generated by client.py db_test_base unit tests for filter with too few arguments, too many arguments, check all repr and str formats.
1 parent 579bcb9 commit c1813e8

File tree

7 files changed

+252
-30
lines changed

7 files changed

+252
-30
lines changed

CHANGES.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ Fixed:
5858
it the default. (John Rouillard)
5959
- fixed a crash with roundup-admin perftest password when rounds not set
6060
on command line. (John Rouillard)
61+
- issue2551374 - Add error handling for filter expressions. Filter
62+
expression errors are now reported. The UI needs some work but
63+
even the current code is helpful when debugging filter
64+
expressions. (John Rouillard)
6165

6266
Features:
6367

roundup/backends/back_anydbm.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from roundup.anypy.strings import b2s, bs2b, repr_export, eval_import, is_us
3535

3636
from roundup import hyperdb, date, password, roundupdb, security, support
37-
from roundup.mlink_expr import Expression
37+
from roundup.mlink_expr import Expression, ExpressionError
3838
from roundup.backends import locking
3939
from roundup.i18n import _
4040

@@ -1880,7 +1880,12 @@ def _filter(self, search_matches, filterspec, proptree,
18801880
if t == LINK:
18811881
# link - if this node's property doesn't appear in the
18821882
# filterspec's nodeid list, skip it
1883-
expr = Expression(v, is_link=True)
1883+
try:
1884+
expr = Expression(v, is_link=True)
1885+
except ExpressionError as e:
1886+
e.context['class'] = cn
1887+
e.context['attr'] = k
1888+
raise
18841889
if expr.evaluate(nv):
18851890
match = 1
18861891
elif t == MULTILINK:
@@ -1895,7 +1900,12 @@ def _filter(self, search_matches, filterspec, proptree,
18951900
else:
18961901
# otherwise, make sure this node has each of the
18971902
# required values
1898-
expr = Expression(v)
1903+
try:
1904+
expr = Expression(v)
1905+
except ExpressionError as e:
1906+
e.context['class'] = cn
1907+
e.context['attr'] = k
1908+
raise
18991909
if expr.evaluate(nv):
19001910
match = 1
19011911
elif t == STRING:

roundup/backends/rdbms_common.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
from roundup.hyperdb import String, Password, Date, Interval, Link, \
7373
Multilink, DatabaseError, Boolean, Number, Integer
7474
from roundup.i18n import _
75-
from roundup.mlink_expr import compile_expression
75+
from roundup.mlink_expr import compile_expression, ExpressionError
7676

7777
# dummy value meaning "argument not passed"
7878
_marker = []
@@ -2594,6 +2594,10 @@ def collect_values(n):
25942594
values.append(n.x)
25952595
expr.visit(collect_values)
25962596
return w, values
2597+
except ExpressionError as e:
2598+
e.context['class'] = pln
2599+
e.context['attr'] = prp
2600+
raise
25972601
except:
25982602
pass
25992603
# Fallback to original code
@@ -2671,6 +2675,10 @@ def collect_values(n):
26712675
expr.visit(collect_values)
26722676

26732677
return intron, values
2678+
except ExpressionError as e:
2679+
e.context['class'] = classname
2680+
e.context['attr'] = proptree.name
2681+
raise
26742682
except:
26752683
# fallback behavior when expression parsing above fails
26762684
orclause = ''

roundup/cgi/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class SysCallError(Exception):
5454
Unauthorised,
5555
UsageError,
5656
)
57+
58+
from roundup.mlink_expr import ExpressionError
59+
5760
from roundup.mailer import Mailer, MessageSendError
5861

5962
logger = logging.getLogger('roundup')
@@ -2216,6 +2219,9 @@ def renderContext(self):
22162219
result = pt.render(self, None, None, **args)
22172220
except IndexerQueryError as e:
22182221
result = self.renderError(e.args[0])
2222+
except ExpressionError as e:
2223+
self.add_error_message(str(e))
2224+
result = self.renderError(str(e))
22192225

22202226
if 'Content-Type' not in self.additional_headers:
22212227
self.additional_headers['Content-Type'] = pt.content_type

roundup/mlink_expr.py

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,57 @@
77
# see the COPYING.txt file coming with Roundup.
88
#
99

10+
from roundup.exceptions import RoundupException
11+
from roundup.i18n import _
12+
13+
opcode_names = {
14+
-2: "not",
15+
-3: "and",
16+
-4: "or",
17+
}
18+
19+
20+
class ExpressionError(RoundupException):
21+
"""Takes two arguments.
22+
23+
ExpressionError(template, context={})
24+
25+
The repr of ExpressionError is:
26+
27+
template % context
28+
29+
"""
30+
31+
# only works on python 3
32+
#def __init__(self, *args, context=None):
33+
# super().__init__(*args)
34+
# self.context = context if isinstance(context, dict) else {}
35+
36+
# works python 2 and 3
37+
def __init__(self, *args, **kwargs):
38+
super(RoundupException, self).__init__(*args)
39+
self.context = {}
40+
if 'context' in kwargs and isinstance(kwargs['context'], dict):
41+
self.context = kwargs['context']
42+
43+
# Skip testing for a bad call to ExpressionError
44+
# keywords = [x for x in list(kwargs) if x != "context"]
45+
#if len(keywords) != 0:
46+
# raise ValueError("unknown keyword argument(s) passed to ExpressionError: %s" % keywords)
47+
48+
def __str__(self):
49+
try:
50+
return self.args[0] % self.context
51+
except KeyError:
52+
return "%s: context=%s" % (self.args[0], self.context)
53+
54+
def __repr__(self):
55+
try:
56+
return self.args[0] % self.context
57+
except KeyError:
58+
return "%s: context=%s" % (self.args[0], self.context)
59+
60+
1061
class Binary:
1162

1263
def __init__(self, x, y):
@@ -38,6 +89,9 @@ def evaluate(self, v):
3889
def visit(self, visitor):
3990
visitor(self)
4091

92+
def __repr__(self):
93+
return "Value %s" % self.x
94+
4195

4296
class Empty(Unary):
4397

@@ -47,6 +101,9 @@ def evaluate(self, v):
47101
def visit(self, visitor):
48102
visitor(self)
49103

104+
def __repr__(self):
105+
return "ISEMPTY(-1)"
106+
50107

51108
class Not(Unary):
52109

@@ -56,6 +113,9 @@ def evaluate(self, v):
56113
def generate(self, atom):
57114
return "NOT(%s)" % self.x.generate(atom)
58115

116+
def __repr__(self):
117+
return "NOT(%s)" % self.x
118+
59119

60120
class Or(Binary):
61121

@@ -67,6 +127,9 @@ def generate(self, atom):
67127
self.x.generate(atom),
68128
self.y.generate(atom))
69129

130+
def __repr__(self):
131+
return "(%s OR %s)" % (self.y, self.x)
132+
70133

71134
class And(Binary):
72135

@@ -78,17 +141,44 @@ def generate(self, atom):
78141
self.x.generate(atom),
79142
self.y.generate(atom))
80143

144+
def __repr__(self):
145+
return "(%s AND %s)" % (self.y, self.x)
146+
81147

82148
def compile_expression(opcodes):
83149

84150
stack = []
85151
push, pop = stack.append, stack.pop
86-
for opcode in opcodes:
87-
if opcode == -1: push(Empty(opcode)) # noqa: E271,E701
88-
elif opcode == -2: push(Not(pop())) # noqa: E701
89-
elif opcode == -3: push(And(pop(), pop())) # noqa: E701
90-
elif opcode == -4: push(Or(pop(), pop())) # noqa: E701
91-
else: push(Equals(opcode)) # noqa: E701
152+
try:
153+
for position, opcode in enumerate(opcodes): # noqa: B007
154+
if opcode == -1: push(Empty(opcode)) # noqa: E271,E701
155+
elif opcode == -2: push(Not(pop())) # noqa: E701
156+
elif opcode == -3: push(And(pop(), pop())) # noqa: E701
157+
elif opcode == -4: push(Or(pop(), pop())) # noqa: E701
158+
else: push(Equals(opcode)) # noqa: E701
159+
except IndexError:
160+
raise ExpressionError(
161+
_("There was an error searching %(class)s by %(attr)s using: "
162+
"%(opcodes)s. "
163+
"The operator %(opcode)s (%(opcodename)s) at position "
164+
"%(position)d has too few arguments."),
165+
context={
166+
"opcode": opcode,
167+
"opcodename": opcode_names[opcode],
168+
"position": position + 1,
169+
"opcodes": opcodes,
170+
})
171+
if len(stack) != 1:
172+
# Too many arguments - I don't think stack can be zero length
173+
raise ExpressionError(
174+
_("There was an error searching %(class)s by %(attr)s using: "
175+
"%(opcodes)s. "
176+
"There are too many arguments for the existing operators. The "
177+
"values on the stack are: %(stack)s"),
178+
context={
179+
"opcodes": opcodes,
180+
"stack": stack,
181+
})
92182

93183
return pop()
94184

@@ -104,7 +194,7 @@ def __init__(self, v, is_link=False):
104194
compiled = compile_expression(opcodes)
105195
if is_link:
106196
self.evaluate = lambda x: compiled.evaluate(
107-
x and [int(x)] or [])
197+
(x and [int(x)]) or [])
108198
else:
109199
self.evaluate = lambda x: compiled.evaluate([int(y) for y in x])
110200
except (ValueError, TypeError):

test/db_test_base.py

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from roundup.cgi.engine_zopetal import RoundupPageTemplate
4242
from roundup.cgi.templating import HTMLItem
4343
from roundup.exceptions import UsageError, Reject
44+
from roundup.mlink_expr import ExpressionError
4445

4546
from roundup.anypy.strings import b2s, s2b, u2s
4647
from roundup.anypy.cmp_ import NoneAndDictComparable
@@ -2048,6 +2049,43 @@ def testFilteringLink(self):
20482049
ae(filt(None, {a: ['-1', None]}, ('+','id'), grp), ['3','4'])
20492050
ae(filt(None, {a: ['1', None]}, ('+','id'), grp), ['1', '3','4'])
20502051

2052+
def testFilteringBrokenLinkExpression(self):
2053+
ae, iiter = self.filteringSetup()
2054+
a = 'assignedto'
2055+
for filt in iiter():
2056+
with self.assertRaises(ExpressionError) as e:
2057+
filt(None, {a: ['1', '-3']}, ('+','status'))
2058+
self.assertIn("position 2", str(e.exception))
2059+
# verify all tokens are consumed
2060+
self.assertNotIn("%", str(e.exception))
2061+
self.assertNotIn("%", repr(e.exception))
2062+
2063+
with self.assertRaises(ExpressionError) as e:
2064+
filt(None, {a: ['0', '1', '2', '3', '-3', '-4']},
2065+
('+','status'))
2066+
self.assertIn("values on the stack are: [Value 0,",
2067+
str(e.exception))
2068+
self.assertNotIn("%", str(e.exception))
2069+
self.assertNotIn("%", repr(e.exception))
2070+
2071+
with self.assertRaises(ExpressionError) as e:
2072+
# expression tests _repr_ for every operator and
2073+
# three values
2074+
filt(None, {a: ['-1', '1', '2', '3', '-3', '-2', '-4']},
2075+
('+','status'))
2076+
result = str(e.exception)
2077+
self.assertIn(" AND ", result)
2078+
self.assertIn(" OR ", result)
2079+
self.assertIn("NOT(", result)
2080+
self.assertIn("ISEMPTY(-1)", result)
2081+
# trigger a KeyError and verify fallback format
2082+
# is correct. It includes the template with %(name)s tokens,
2083+
del(e.exception.context['class'])
2084+
self.assertIn("values on the stack are: %(stack)s",
2085+
str(e.exception))
2086+
self.assertIn("values on the stack are: %(stack)s",
2087+
repr(e.exception))
2088+
20512089
def testFilteringLinkExpression(self):
20522090
ae, iiter = self.filteringSetup()
20532091
a = 'assignedto'
@@ -2074,25 +2112,6 @@ def testFilteringLinkExpression(self):
20742112
ae(filt(None, {a: ['1','-2','2','-2','-4']}, ('+',a)),
20752113
['3','4','1','2'])
20762114

2077-
def NOtestFilteringLinkInvalid(self):
2078-
"""Test invalid filter expressions.
2079-
Code must be written to generate an exception for these.
2080-
Then this code must be changed to test the exception.
2081-
See issue 2551374 in the roundup tracker.
2082-
"""
2083-
ae, iiter = self.filteringSetup()
2084-
a = 'assignedto'
2085-
# filt(??, filter expression, sort order)
2086-
# ae = self.assertEqual(val1, val2)
2087-
for filt in iiter():
2088-
# -2 is missing an operand
2089-
ae(filt(None, {a: ['-2','2','-2','-4']},
2090-
('+',a)), [])
2091-
# 3 is left on the stack
2092-
ae(filt(None, {a: ['3','2','-2']},
2093-
('+',a)), [])
2094-
2095-
20962115
def testFilteringRevLink(self):
20972116
ae, iiter = self.filteringSetupTransitiveSearch('user')
20982117
# We have
@@ -2228,6 +2247,56 @@ def testFilteringMultilink(self):
22282247
ae(filt(None, {'nosy': ['1','2']}, ('+', 'status'),
22292248
('-', 'deadline')), ['4', '3'])
22302249

2250+
def testFilteringBrokenMultilinkExpression(self):
2251+
ae, iiter = self.filteringSetup()
2252+
kw1 = self.db.keyword.create(name='Key1')
2253+
kw2 = self.db.keyword.create(name='Key2')
2254+
kw3 = self.db.keyword.create(name='Key3')
2255+
kw4 = self.db.keyword.create(name='Key4')
2256+
self.db.issue.set('1', keywords=[kw1, kw2])
2257+
self.db.issue.set('2', keywords=[kw1, kw3])
2258+
self.db.issue.set('3', keywords=[kw2, kw3, kw4])
2259+
self.db.issue.set('4', keywords=[kw1, kw2, kw4])
2260+
self.db.commit()
2261+
kw = 'keywords'
2262+
for filt in iiter():
2263+
with self.assertRaises(ExpressionError) as e:
2264+
filt(None, {kw: ['1', '-3']}, ('+','status'))
2265+
self.assertIn("searching issue by keywords", str(e.exception))
2266+
self.assertIn("position 2", str(e.exception))
2267+
# verify all tokens are consumed
2268+
self.assertNotIn("%", str(e.exception))
2269+
self.assertNotIn("%", repr(e.exception))
2270+
2271+
with self.assertRaises(ExpressionError) as e:
2272+
filt(None, {kw: ['0', '1', '2', '3', '-3', '-4']},
2273+
('+','status'))
2274+
self.assertIn("searching issue by keywords", str(e.exception))
2275+
self.assertIn("values on the stack are: [Value 0,",
2276+
str(e.exception))
2277+
self.assertNotIn("%", str(e.exception))
2278+
self.assertNotIn("%", repr(e.exception))
2279+
2280+
with self.assertRaises(ExpressionError) as e:
2281+
# expression tests _repr_ for every operator and
2282+
# three values
2283+
filt(None, {kw: ['-1', '1', '2', '3', '-3', '-2', '-4']},
2284+
('+','status'))
2285+
result = str(e.exception)
2286+
self.assertIn("searching issue by keywords", result)
2287+
self.assertIn(" AND ", result)
2288+
self.assertIn(" OR ", result)
2289+
self.assertIn("NOT(", result)
2290+
self.assertIn("ISEMPTY(-1)", result)
2291+
# trigger a KeyError and verify fallback format
2292+
# is correct. It includes the template with %(name)s tokens,
2293+
del(e.exception.context['class'])
2294+
self.assertIn("values on the stack are: %(stack)s",
2295+
str(e.exception))
2296+
self.assertIn("values on the stack are: %(stack)s",
2297+
repr(e.exception))
2298+
2299+
22312300
def testFilteringMultilinkExpression(self):
22322301
ae, iiter = self.filteringSetup()
22332302
kw1 = self.db.keyword.create(name='Key1')

0 commit comments

Comments
 (0)