changeset 8241:741ea8a86012

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.
author John Rouillard <rouilj@ieee.org>
date Mon, 30 Dec 2024 20:22:55 -0500
parents 1189c742e4b3
children 393dfc750d8b
files CHANGES.txt roundup/backends/back_anydbm.py roundup/backends/rdbms_common.py roundup/cgi/client.py roundup/mlink_expr.py test/db_test_base.py test/test_liveserver.py
diffstat 7 files changed, 252 insertions(+), 30 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES.txt	Mon Dec 30 02:59:27 2024 -0500
+++ b/CHANGES.txt	Mon Dec 30 20:22:55 2024 -0500
@@ -58,6 +58,10 @@
   it the default. (John Rouillard)
 - fixed a crash with roundup-admin perftest password when rounds not set
   on command line. (John Rouillard)
+- issue2551374 - Add error handling for filter expressions. Filter
+  expression errors are now reported. The UI needs some work but
+  even the current code is helpful when debugging filter
+  expressions. (John Rouillard)
 
 Features:
 
--- a/roundup/backends/back_anydbm.py	Mon Dec 30 02:59:27 2024 -0500
+++ b/roundup/backends/back_anydbm.py	Mon Dec 30 20:22:55 2024 -0500
@@ -34,7 +34,7 @@
 from roundup.anypy.strings import b2s, bs2b, repr_export, eval_import, is_us
 
 from roundup import hyperdb, date, password, roundupdb, security, support
-from roundup.mlink_expr import Expression
+from roundup.mlink_expr import Expression, ExpressionError
 from roundup.backends import locking
 from roundup.i18n import _
 
@@ -1880,7 +1880,12 @@
                     if t == LINK:
                         # link - if this node's property doesn't appear in the
                         # filterspec's nodeid list, skip it
-                        expr = Expression(v, is_link=True)
+                        try:
+                            expr = Expression(v, is_link=True)
+                        except ExpressionError as e:
+                            e.context['class'] = cn
+                            e.context['attr'] = k
+                            raise
                         if expr.evaluate(nv):
                             match = 1
                     elif t == MULTILINK:
@@ -1895,7 +1900,12 @@
                         else:
                             # otherwise, make sure this node has each of the
                             # required values
-                            expr = Expression(v)
+                            try:
+                                expr = Expression(v)
+                            except ExpressionError as e:
+                                e.context['class'] = cn
+                                e.context['attr'] = k
+                                raise
                             if expr.evaluate(nv):
                                 match = 1
                     elif t == STRING:
--- a/roundup/backends/rdbms_common.py	Mon Dec 30 02:59:27 2024 -0500
+++ b/roundup/backends/rdbms_common.py	Mon Dec 30 20:22:55 2024 -0500
@@ -72,7 +72,7 @@
 from roundup.hyperdb import String, Password, Date, Interval, Link, \
     Multilink, DatabaseError, Boolean, Number, Integer
 from roundup.i18n import _
-from roundup.mlink_expr import compile_expression
+from roundup.mlink_expr import compile_expression, ExpressionError
 
 # dummy value meaning "argument not passed"
 _marker = []
@@ -2594,6 +2594,10 @@
                     values.append(n.x)
             expr.visit(collect_values)
             return w, values
+        except ExpressionError as e:
+            e.context['class'] = pln
+            e.context['attr'] = prp
+            raise
         except:
             pass
         # Fallback to original code
@@ -2671,6 +2675,10 @@
             expr.visit(collect_values)
 
             return intron, values
+        except ExpressionError as e:
+            e.context['class'] = classname
+            e.context['attr'] = proptree.name
+            raise
         except:
             # fallback behavior when expression parsing above fails
             orclause = ''
--- a/roundup/cgi/client.py	Mon Dec 30 02:59:27 2024 -0500
+++ b/roundup/cgi/client.py	Mon Dec 30 20:22:55 2024 -0500
@@ -54,6 +54,9 @@
     Unauthorised,
     UsageError,
 )
+
+from roundup.mlink_expr import ExpressionError
+
 from roundup.mailer import Mailer, MessageSendError
 
 logger = logging.getLogger('roundup')
@@ -2216,6 +2219,9 @@
                 result = pt.render(self, None, None, **args)
             except IndexerQueryError as e:
                 result = self.renderError(e.args[0])
+            except ExpressionError as e:
+                self.add_error_message(str(e))
+                result = self.renderError(str(e))
 
             if 'Content-Type' not in self.additional_headers:
                 self.additional_headers['Content-Type'] = pt.content_type
--- a/roundup/mlink_expr.py	Mon Dec 30 02:59:27 2024 -0500
+++ b/roundup/mlink_expr.py	Mon Dec 30 20:22:55 2024 -0500
@@ -7,6 +7,57 @@
 # see the COPYING.txt file coming with Roundup.
 #
 
+from roundup.exceptions import RoundupException
+from roundup.i18n import _
+
+opcode_names = {
+    -2: "not",
+    -3: "and",
+    -4: "or",
+}
+
+
+class ExpressionError(RoundupException):
+    """Takes two arguments.
+
+        ExpressionError(template, context={})
+
+    The repr of ExpressionError is:
+
+        template % context
+
+    """
+
+    # only works on python 3
+    #def __init__(self, *args, context=None):
+    #    super().__init__(*args)
+    #    self.context = context if isinstance(context, dict) else {}
+
+    # works python 2 and 3
+    def __init__(self, *args, **kwargs):
+        super(RoundupException, self).__init__(*args)
+        self.context = {}
+        if 'context' in kwargs and isinstance(kwargs['context'], dict):
+            self.context = kwargs['context']
+
+        # Skip testing for a bad call to ExpressionError
+        # keywords = [x for x in list(kwargs) if x != "context"]
+        #if len(keywords) != 0:
+        #        raise ValueError("unknown keyword argument(s) passed to ExpressionError: %s" % keywords)
+
+    def __str__(self):
+        try:
+            return self.args[0] % self.context
+        except KeyError:
+            return "%s: context=%s" % (self.args[0], self.context)
+
+    def __repr__(self):
+        try:
+            return self.args[0] % self.context
+        except KeyError:
+            return "%s: context=%s" % (self.args[0], self.context)
+
+
 class Binary:
 
     def __init__(self, x, y):
@@ -38,6 +89,9 @@
     def visit(self, visitor):
         visitor(self)
 
+    def __repr__(self):
+        return "Value %s" % self.x
+
 
 class Empty(Unary):
 
@@ -47,6 +101,9 @@
     def visit(self, visitor):
         visitor(self)
 
+    def __repr__(self):
+        return "ISEMPTY(-1)"
+
 
 class Not(Unary):
 
@@ -56,6 +113,9 @@
     def generate(self, atom):
         return "NOT(%s)" % self.x.generate(atom)
 
+    def __repr__(self):
+        return "NOT(%s)" % self.x
+
 
 class Or(Binary):
 
@@ -67,6 +127,9 @@
             self.x.generate(atom),
             self.y.generate(atom))
 
+    def __repr__(self):
+        return "(%s OR %s)" % (self.y, self.x)
+
 
 class And(Binary):
 
@@ -78,17 +141,44 @@
             self.x.generate(atom),
             self.y.generate(atom))
 
+    def __repr__(self):
+        return "(%s AND %s)" % (self.y, self.x)
+
 
 def compile_expression(opcodes):
 
     stack = []
     push, pop = stack.append, stack.pop
-    for opcode in opcodes:
-        if   opcode == -1: push(Empty(opcode))      # noqa: E271,E701
-        elif opcode == -2: push(Not(pop()))         # noqa: E701
-        elif opcode == -3: push(And(pop(), pop()))  # noqa: E701
-        elif opcode == -4: push(Or(pop(), pop()))   # noqa: E701
-        else:              push(Equals(opcode))     # noqa: E701
+    try:
+        for position, opcode in enumerate(opcodes):     # noqa: B007
+            if   opcode == -1: push(Empty(opcode))      # noqa: E271,E701
+            elif opcode == -2: push(Not(pop()))         # noqa: E701
+            elif opcode == -3: push(And(pop(), pop()))  # noqa: E701
+            elif opcode == -4: push(Or(pop(), pop()))   # noqa: E701
+            else:              push(Equals(opcode))     # noqa: E701
+    except IndexError:
+        raise ExpressionError(
+            _("There was an error searching %(class)s by %(attr)s using: "
+              "%(opcodes)s. "
+              "The operator %(opcode)s (%(opcodename)s) at position "
+              "%(position)d has too few arguments."),
+            context={
+                "opcode": opcode,
+                "opcodename": opcode_names[opcode],
+                "position": position + 1,
+                "opcodes": opcodes,
+            })
+    if len(stack) != 1:
+        # Too many arguments - I don't think stack can be zero length
+        raise ExpressionError(
+            _("There was an error searching %(class)s by %(attr)s using: "
+              "%(opcodes)s. "
+              "There are too many arguments for the existing operators. The "
+              "values on the stack are: %(stack)s"),
+            context={
+                "opcodes": opcodes,
+                "stack": stack,
+            })
 
     return pop()
 
@@ -104,7 +194,7 @@
             compiled = compile_expression(opcodes)
             if is_link:
                 self.evaluate = lambda x: compiled.evaluate(
-                    x and [int(x)] or [])
+                    (x and [int(x)]) or [])
             else:
                 self.evaluate = lambda x: compiled.evaluate([int(y) for y in x])
         except (ValueError, TypeError):
--- a/test/db_test_base.py	Mon Dec 30 02:59:27 2024 -0500
+++ b/test/db_test_base.py	Mon Dec 30 20:22:55 2024 -0500
@@ -41,6 +41,7 @@
 from roundup.cgi.engine_zopetal import RoundupPageTemplate
 from roundup.cgi.templating import HTMLItem
 from roundup.exceptions import UsageError, Reject
+from roundup.mlink_expr import ExpressionError
 
 from roundup.anypy.strings import b2s, s2b, u2s
 from roundup.anypy.cmp_ import NoneAndDictComparable
@@ -2048,6 +2049,43 @@
             ae(filt(None, {a: ['-1', None]}, ('+','id'), grp), ['3','4'])
             ae(filt(None, {a: ['1', None]}, ('+','id'), grp), ['1', '3','4'])
 
+    def testFilteringBrokenLinkExpression(self):
+        ae, iiter = self.filteringSetup()
+        a = 'assignedto'
+        for filt in iiter():
+            with self.assertRaises(ExpressionError) as e:
+                filt(None, {a: ['1', '-3']}, ('+','status'))
+            self.assertIn("position 2", str(e.exception))
+            # verify all tokens are consumed
+            self.assertNotIn("%", str(e.exception))
+            self.assertNotIn("%", repr(e.exception))
+
+            with self.assertRaises(ExpressionError) as e:
+                filt(None, {a: ['0', '1', '2', '3', '-3', '-4']},
+                     ('+','status'))
+            self.assertIn("values on the stack are: [Value 0,",
+                          str(e.exception))
+            self.assertNotIn("%", str(e.exception))
+            self.assertNotIn("%", repr(e.exception))
+
+            with self.assertRaises(ExpressionError) as e:
+                # expression tests _repr_ for every operator and
+                # three values
+                filt(None, {a: ['-1', '1', '2', '3', '-3', '-2', '-4']},
+                     ('+','status'))
+            result = str(e.exception)
+            self.assertIn(" AND ", result)
+            self.assertIn(" OR ", result)
+            self.assertIn("NOT(", result)
+            self.assertIn("ISEMPTY(-1)", result)
+            # trigger a KeyError and verify fallback format
+            # is correct. It includes the template with %(name)s tokens,
+            del(e.exception.context['class'])
+            self.assertIn("values on the stack are: %(stack)s",
+                          str(e.exception))
+            self.assertIn("values on the stack are: %(stack)s",
+                          repr(e.exception))
+
     def testFilteringLinkExpression(self):
         ae, iiter = self.filteringSetup()
         a = 'assignedto'
@@ -2074,25 +2112,6 @@
             ae(filt(None, {a: ['1','-2','2','-2','-4']}, ('+',a)),
                 ['3','4','1','2'])
 
-    def NOtestFilteringLinkInvalid(self):
-        """Test invalid filter expressions.
-           Code must be written to generate an exception for these.
-           Then this code must be changed to test the exception.
-           See issue 2551374 in the roundup tracker.
-        """
-        ae, iiter = self.filteringSetup()
-        a = 'assignedto'
-        # filt(??, filter expression, sort order)
-        # ae = self.assertEqual(val1, val2)
-        for filt in iiter():
-            # -2 is missing an operand
-            ae(filt(None, {a: ['-2','2','-2','-4']},
-                    ('+',a)), [])
-            # 3 is left on the stack
-            ae(filt(None, {a: ['3','2','-2']},
-                    ('+',a)), [])
-
-
     def testFilteringRevLink(self):
         ae, iiter = self.filteringSetupTransitiveSearch('user')
         # We have
@@ -2228,6 +2247,56 @@
             ae(filt(None, {'nosy': ['1','2']}, ('+', 'status'),
                 ('-', 'deadline')), ['4', '3'])
 
+    def testFilteringBrokenMultilinkExpression(self):
+        ae, iiter = self.filteringSetup()
+        kw1 = self.db.keyword.create(name='Key1')
+        kw2 = self.db.keyword.create(name='Key2')
+        kw3 = self.db.keyword.create(name='Key3')
+        kw4 = self.db.keyword.create(name='Key4')
+        self.db.issue.set('1', keywords=[kw1, kw2])
+        self.db.issue.set('2', keywords=[kw1, kw3])
+        self.db.issue.set('3', keywords=[kw2, kw3, kw4])
+        self.db.issue.set('4', keywords=[kw1, kw2, kw4])
+        self.db.commit()
+        kw = 'keywords'
+        for filt in iiter():
+            with self.assertRaises(ExpressionError) as e:
+                filt(None, {kw: ['1', '-3']}, ('+','status'))
+            self.assertIn("searching issue by keywords", str(e.exception))
+            self.assertIn("position 2", str(e.exception))
+            # verify all tokens are consumed
+            self.assertNotIn("%", str(e.exception))
+            self.assertNotIn("%", repr(e.exception))
+
+            with self.assertRaises(ExpressionError) as e:
+                filt(None, {kw: ['0', '1', '2', '3', '-3', '-4']},
+                     ('+','status'))
+            self.assertIn("searching issue by keywords", str(e.exception))
+            self.assertIn("values on the stack are: [Value 0,",
+                          str(e.exception))
+            self.assertNotIn("%", str(e.exception))
+            self.assertNotIn("%", repr(e.exception))
+
+            with self.assertRaises(ExpressionError) as e:
+                # expression tests _repr_ for every operator and
+                # three values
+                filt(None, {kw: ['-1', '1', '2', '3', '-3', '-2', '-4']},
+                     ('+','status'))
+            result = str(e.exception)
+            self.assertIn("searching issue by keywords", result)
+            self.assertIn(" AND ", result)
+            self.assertIn(" OR ", result)
+            self.assertIn("NOT(", result)
+            self.assertIn("ISEMPTY(-1)", result)
+            # trigger a KeyError and verify fallback format
+            # is correct. It includes the template with %(name)s tokens,
+            del(e.exception.context['class'])
+            self.assertIn("values on the stack are: %(stack)s",
+                          str(e.exception))
+            self.assertIn("values on the stack are: %(stack)s",
+                          repr(e.exception))
+
+
     def testFilteringMultilinkExpression(self):
         ae, iiter = self.filteringSetup()
         kw1 = self.db.keyword.create(name='Key1')
--- a/test/test_liveserver.py	Mon Dec 30 02:59:27 2024 -0500
+++ b/test/test_liveserver.py	Mon Dec 30 20:22:55 2024 -0500
@@ -351,6 +351,41 @@
         # of the footer is missing.
         self.assertNotIn('out of', f.text)
 
+    def test_broken_query(self):
+        # query link item
+        current_user_query = (
+            "@columns=title,id,activity,status,assignedto&"
+            "@sort=activity&@group=priority&@filter=creator&"
+            "@pagesize=50&@startwith=0&creator=-2&"
+            "@dispname=Test1")
+
+        session, _response = self.create_login_session()
+        f = session.get(self.url_base()+'/issue?' + current_user_query)
+
+        # verify the query has run by looking for the query name
+        # print(f.text)
+        self.assertIn('Error when searching issue by creator using: '
+                      '[-2]. The operator -2 (not) at position 1 has '
+                      'too few arguments.',
+                      f.text)
+
+    def test_broken_multiink_query(self):
+        # query multilink item
+        current_user_query = (
+            "@columns=title,id,activity,status,assignedto"
+            "&keyword=-3&@sort=activity&@group=priority"
+            "&@pagesize=50&@startwith=0&@template=index|search"
+            "&@action=search")
+        session, _response = self.create_login_session()
+        f = session.get(self.url_base()+'/issue?' + current_user_query)
+
+        # verify the query has run by looking for the query name
+        print(f.text)
+        self.assertIn('Error when searching issue by keyword using: '
+                      '[-3]. The operator -3 (and) at position 1 has '
+                      'too few arguments.',
+                      f.text)
+
     def test_start_page(self):
         """ simple test that verifies that the server can serve a start page.
         """

Roundup Issue Tracker: http://roundup-tracker.org/