(core) Add dropdown conditions

Summary:
Dropdown conditions let you specify a predicate formula that's used to filter
choices and references in their respective autocomplete dropdown menus.

Test Plan: Python and browser tests (WIP).

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: dsagal, paulfitz

Differential Revision: https://phab.getgrist.com/D4235
This commit is contained in:
George Gevoian
2024-04-26 16:34:16 -04:00
parent 34c85757f1
commit 3112433a58
86 changed files with 4221 additions and 1060 deletions

View File

@@ -5,7 +5,8 @@
import json
import logging
from acl_formula import parse_acl_grist_entities, parse_acl_formula_json
from acl_formula import parse_acl_grist_entities
from predicate_formula import parse_predicate_formula_json
import action_obj
import textbuilder
@@ -130,7 +131,7 @@ def prepare_acl_col_renames(docmodel, useractions, col_renames_dict):
replacer = textbuilder.Replacer(textbuilder.Text(formula), patches)
txt = replacer.get_text()
rule_updates.append((rule_rec, {'aclFormula': txt,
'aclFormulaParsed': parse_acl_formula_json(txt)}))
'aclFormulaParsed': parse_predicate_formula_json(txt)}))
def do_renames():
useractions.doBulkUpdateFromPairs('_grist_ACLResources', resource_updates)

View File

@@ -1,63 +1,19 @@
import ast
import io
import json
import tokenize
from collections import namedtuple
import asttokens
import six
from codebuilder import replace_dollar_attrs
from predicate_formula import NamedEntity, parse_predicate_formula_json, TreeConverter
def parse_acl_formula(acl_formula):
def parse_acl_formulas(col_values):
"""
Parse an ACL formula expression into a parse tree that we can interpret in JS, e.g.
"rec.office == 'Seattle' and user.email in ['sally@', 'xie@']".
The idea is to support enough to express ACL rules flexibly, but we don't need to support too
much, since rules should be reasonably simple.
The returned tree has the form [NODE_TYPE, arguments...], with these NODE_TYPEs supported:
And|Or ...values
Add|Sub|Mult|Div|Mod left, right
Not operand
Eq|NotEq|Lt|LtE|Gt|GtE left, right
Is|IsNot|In|NotIn left, right
List ...elements
Const value (number, string, bool)
Name name (string)
Attr node, attr_name
Comment node, comment
Populates `aclFormulaParsed` by parsing `aclFormula` for all `col_values`.
"""
if isinstance(acl_formula, six.binary_type):
acl_formula = acl_formula.decode('utf8')
try:
acl_formula = replace_dollar_attrs(acl_formula)
tree = ast.parse(acl_formula, mode='eval')
result = _TreeConverter().visit(tree)
for part in tokenize.generate_tokens(io.StringIO(acl_formula).readline):
if part[0] == tokenize.COMMENT and part[1].startswith('#'):
result = ['Comment', result, part[1][1:].strip()]
break
return result
except SyntaxError as err:
# In case of an error, include line and offset.
raise SyntaxError("%s on line %s col %s" % (err.args[0], err.lineno, err.offset))
if 'aclFormula' not in col_values:
return
def parse_acl_formula_json(acl_formula):
"""
As parse_acl_formula(), but stringifies the result, and converts empty string to empty string.
"""
return json.dumps(parse_acl_formula(acl_formula)) if acl_formula else ""
# Entities encountered in ACL formulas, which may get renamed.
# type : 'recCol'|'userAttr'|'userAttrCol',
# start_pos: number, # start position of the token in the code.
# name: string, # the name that may be updated by a rename.
# extra: string|None, # name of userAttr in case of userAttrCol; otherwise None.
NamedEntity = namedtuple('NamedEntity', ('type', 'start_pos', 'name', 'extra'))
col_values['aclFormulaParsed'] = [parse_predicate_formula_json(v)
for v
in col_values['aclFormula']]
def parse_acl_grist_entities(acl_formula):
"""
@@ -72,69 +28,7 @@ def parse_acl_grist_entities(acl_formula):
except SyntaxError as err:
return []
named_constants = {
'True': True,
'False': False,
'None': None,
}
class _TreeConverter(ast.NodeVisitor):
# AST nodes are documented here: https://docs.python.org/2/library/ast.html#abstract-grammar
# pylint:disable=no-self-use
def visit_Expression(self, node):
return self.visit(node.body)
def visit_BoolOp(self, node):
return [node.op.__class__.__name__] + [self.visit(v) for v in node.values]
def visit_BinOp(self, node):
if not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod)):
return self.generic_visit(node)
return [node.op.__class__.__name__, self.visit(node.left), self.visit(node.right)]
def visit_UnaryOp(self, node):
if not isinstance(node.op, (ast.Not)):
return self.generic_visit(node)
return [node.op.__class__.__name__, self.visit(node.operand)]
def visit_Compare(self, node):
# We don't try to support chained comparisons like "1 < 2 < 3" (though it wouldn't be hard).
if len(node.ops) != 1 or len(node.comparators) != 1:
raise ValueError("Can't use chained comparisons")
return [node.ops[0].__class__.__name__, self.visit(node.left), self.visit(node.comparators[0])]
def visit_Name(self, node):
if node.id in named_constants:
return ["Const", named_constants[node.id]]
return ["Name", node.id]
def visit_Constant(self, node):
return ["Const", node.value]
visit_NameConstant = visit_Constant
def visit_Attribute(self, node):
return ["Attr", self.visit(node.value), node.attr]
def visit_Num(self, node):
return ["Const", node.n]
def visit_Str(self, node):
return ["Const", node.s]
def visit_List(self, node):
return ["List"] + [self.visit(e) for e in node.elts]
def visit_Tuple(self, node):
return self.visit_List(node) # We don't distinguish tuples and lists
def generic_visit(self, node):
raise ValueError("Unsupported syntax at %s:%s" % (node.lineno, node.col_offset + 1))
class _EntityCollector(_TreeConverter):
class _EntityCollector(TreeConverter):
def __init__(self):
self.entities = [] # NamedEntity list

View File

@@ -0,0 +1,43 @@
import json
import logging
from predicate_formula import parse_predicate_formula_json
log = logging.getLogger(__name__)
def parse_dropdown_conditions(col_values):
"""
Parses any unparsed dropdown conditions in `col_values`.
"""
if 'widgetOptions' not in col_values:
return
col_values['widgetOptions'] = [parse_dropdown_condition(widget_options_json)
for widget_options_json
in col_values['widgetOptions']]
def parse_dropdown_condition(widget_options_json):
"""
Parses `dropdownCondition.text` in `widget_options_json` and stores the parsed
representation in `dropdownCondition.parsed`.
If `dropdownCondition.parsed` is already set, parsing is skipped (as an optimization).
Clients are responsible for including just `dropdownCondition.text` when creating new
(or updating existing) dropdown conditions.
Returns an updated copy of `widget_options_json` or the original widget_options_json
if parsing was skipped.
"""
try:
widget_options = json.loads(widget_options_json)
if 'dropdownCondition' not in widget_options:
return widget_options_json
dropdown_condition = widget_options['dropdownCondition']
if 'parsed' in dropdown_condition:
return widget_options_json
dropdown_condition['parsed'] = parse_predicate_formula_json(dropdown_condition['text'])
return json.dumps(widget_options)
except (TypeError, ValueError):
return widget_options_json

View File

@@ -23,7 +23,7 @@ import migrations
import schema
import useractions
import objtypes
from acl_formula import parse_acl_formula
from predicate_formula import parse_predicate_formula
from sandbox import get_default_sandbox
from imports.register import register_import_parsers
@@ -174,7 +174,7 @@ def run(sandbox):
def get_timings():
return eng._timing.get()
export(parse_acl_formula)
export(parse_predicate_formula)
export(eng.load_empty)
export(eng.load_done)

View File

@@ -0,0 +1,118 @@
import ast
import io
import json
import tokenize
from collections import namedtuple
import six
from codebuilder import replace_dollar_attrs
# Entities encountered in predicate formulas, which may get renamed.
# type : 'recCol'|'userAttr'|'userAttrCol',
# start_pos: number, # start position of the token in the code.
# name: string, # the name that may be updated by a rename.
# extra: string|None, # name of userAttr in case of userAttrCol; otherwise None.
NamedEntity = namedtuple('NamedEntity', ('type', 'start_pos', 'name', 'extra'))
def parse_predicate_formula(formula):
"""
Parse a predicate formula expression into a parse tree that we can interpret in JS, e.g.
"rec.office == 'Seattle' and user.email in ['sally@', 'xie@']".
The idea is to support enough to express ACL rules and dropdown conditions flexibly, but we
don't need to support too much, since expressions should be reasonably simple.
The returned tree has the form [NODE_TYPE, arguments...], with these NODE_TYPEs supported:
And|Or ...values
Add|Sub|Mult|Div|Mod left, right
Not operand
Eq|NotEq|Lt|LtE|Gt|GtE left, right
Is|IsNot|In|NotIn left, right
List ...elements
Const value (number, string, bool)
Name name (string)
Attr node, attr_name
Comment node, comment
"""
if isinstance(formula, six.binary_type):
formula = formula.decode('utf8')
try:
formula = replace_dollar_attrs(formula)
tree = ast.parse(formula, mode='eval')
result = TreeConverter().visit(tree)
for part in tokenize.generate_tokens(io.StringIO(formula).readline):
if part[0] == tokenize.COMMENT and part[1].startswith('#'):
result = ['Comment', result, part[1][1:].strip()]
break
return result
except SyntaxError as err:
# In case of an error, include line and offset.
raise SyntaxError("%s on line %s col %s" % (err.args[0], err.lineno, err.offset))
def parse_predicate_formula_json(formula):
"""
As parse_predicate_formula(), but stringifies the result, and converts falsy
values to empty string.
"""
return json.dumps(parse_predicate_formula(formula)) if formula else ""
named_constants = {
'True': True,
'False': False,
'None': None,
}
class TreeConverter(ast.NodeVisitor):
# AST nodes are documented here: https://docs.python.org/2/library/ast.html#abstract-grammar
# pylint:disable=no-self-use
def visit_Expression(self, node):
return self.visit(node.body)
def visit_BoolOp(self, node):
return [node.op.__class__.__name__] + [self.visit(v) for v in node.values]
def visit_BinOp(self, node):
if not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod)):
return self.generic_visit(node)
return [node.op.__class__.__name__, self.visit(node.left), self.visit(node.right)]
def visit_UnaryOp(self, node):
if not isinstance(node.op, (ast.Not)):
return self.generic_visit(node)
return [node.op.__class__.__name__, self.visit(node.operand)]
def visit_Compare(self, node):
# We don't try to support chained comparisons like "1 < 2 < 3" (though it wouldn't be hard).
if len(node.ops) != 1 or len(node.comparators) != 1:
raise ValueError("Can't use chained comparisons")
return [node.ops[0].__class__.__name__, self.visit(node.left), self.visit(node.comparators[0])]
def visit_Name(self, node):
if node.id in named_constants:
return ["Const", named_constants[node.id]]
return ["Name", node.id]
def visit_Constant(self, node):
return ["Const", node.value]
visit_NameConstant = visit_Constant
def visit_Attribute(self, node):
return ["Attr", self.visit(node.value), node.attr]
def visit_Num(self, node):
return ["Const", node.n]
def visit_Str(self, node):
return ["Const", node.s]
def visit_List(self, node):
return ["List"] + [self.visit(e) for e in node.elts]
def visit_Tuple(self, node):
return self.visit_List(node) # We don't distinguish tuples and lists
def generic_visit(self, node):
raise ValueError("Unsupported syntax at %s:%s" % (node.lineno, node.col_offset + 1))

View File

@@ -1,151 +1,8 @@
# -*- coding: utf-8 -*-
# pylint:disable=line-too-long
import unittest
from acl_formula import parse_acl_formula
import test_engine
class TestACLFormula(unittest.TestCase):
def test_basic(self):
# Test a few basic formulas and structures, hitting everything we expect to support
self.assertEqual(parse_acl_formula(
"user.Email == 'X@'"),
["Eq", ["Attr", ["Name", "user"], "Email"],
["Const", "X@"]])
self.assertEqual(parse_acl_formula(
"user.Role in ('editors', 'owners')"),
["In", ["Attr", ["Name", "user"], "Role"],
["List", ["Const", "editors"], ["Const", "owners"]]])
self.assertEqual(parse_acl_formula(
"user.Role not in ('editors', 'owners')"),
["NotIn", ["Attr", ["Name", "user"], "Role"],
["List", ["Const", "editors"], ["Const", "owners"]]])
self.assertEqual(parse_acl_formula(
"rec.office == 'Seattle' and user.email in ['sally@', 'xie@']"),
['And',
['Eq', ['Attr', ['Name', 'rec'], 'office'], ['Const', 'Seattle']],
['In',
['Attr', ['Name', 'user'], 'email'],
['List', ['Const', 'sally@'], ['Const', 'xie@']]
]])
self.assertEqual(parse_acl_formula(
"$office == 'Seattle' and user.email in ['sally@', 'xie@']"),
['And',
['Eq', ['Attr', ['Name', 'rec'], 'office'], ['Const', 'Seattle']],
['In',
['Attr', ['Name', 'user'], 'email'],
['List', ['Const', 'sally@'], ['Const', 'xie@']]
]])
self.assertEqual(parse_acl_formula(
"user.IsAdmin or rec.assigned is None or (not newRec.HasDuplicates and rec.StatusIndex <= newRec.StatusIndex)"),
['Or',
['Attr', ['Name', 'user'], 'IsAdmin'],
['Is', ['Attr', ['Name', 'rec'], 'assigned'], ['Const', None]],
['And',
['Not', ['Attr', ['Name', 'newRec'], 'HasDuplicates']],
['LtE', ['Attr', ['Name', 'rec'], 'StatusIndex'], ['Attr', ['Name', 'newRec'], 'StatusIndex']]
]
])
self.assertEqual(parse_acl_formula(
"user.IsAdmin or $assigned is None or (not newRec.HasDuplicates and $StatusIndex <= newRec.StatusIndex)"),
['Or',
['Attr', ['Name', 'user'], 'IsAdmin'],
['Is', ['Attr', ['Name', 'rec'], 'assigned'], ['Const', None]],
['And',
['Not', ['Attr', ['Name', 'newRec'], 'HasDuplicates']],
['LtE', ['Attr', ['Name', 'rec'], 'StatusIndex'], ['Attr', ['Name', 'newRec'], 'StatusIndex']]
]
])
self.assertEqual(parse_acl_formula(
"r.A <= n.A + 1 or r.A >= n.A - 1 or r.B < n.B * 2.5 or r.B > n.B / 2.5 or r.C % 2 != 0"),
['Or',
['LtE',
['Attr', ['Name', 'r'], 'A'],
['Add', ['Attr', ['Name', 'n'], 'A'], ['Const', 1]]],
['GtE',
['Attr', ['Name', 'r'], 'A'],
['Sub', ['Attr', ['Name', 'n'], 'A'], ['Const', 1]]],
['Lt',
['Attr', ['Name', 'r'], 'B'],
['Mult', ['Attr', ['Name', 'n'], 'B'], ['Const', 2.5]]],
['Gt',
['Attr', ['Name', 'r'], 'B'],
['Div', ['Attr', ['Name', 'n'], 'B'], ['Const', 2.5]]],
['NotEq',
['Mod', ['Attr', ['Name', 'r'], 'C'], ['Const', 2]],
['Const', 0]]
])
self.assertEqual(parse_acl_formula(
"rec.A is True or rec.A is not False"),
['Or',
['Is', ['Attr', ['Name', 'rec'], 'A'], ['Const', True]],
['IsNot', ['Attr', ['Name', 'rec'], 'A'], ['Const', False]]
])
self.assertEqual(parse_acl_formula(
"$A is True or $A is not False"),
['Or',
['Is', ['Attr', ['Name', 'rec'], 'A'], ['Const', True]],
['IsNot', ['Attr', ['Name', 'rec'], 'A'], ['Const', False]]
])
self.assertEqual(parse_acl_formula(
"user.Office.City == 'Seattle' and user.Status.IsActive"),
['And',
['Eq',
['Attr', ['Attr', ['Name', 'user'], 'Office'], 'City'],
['Const', 'Seattle']],
['Attr', ['Attr', ['Name', 'user'], 'Status'], 'IsActive']
])
self.assertEqual(parse_acl_formula(
"True # Comment! "),
['Comment', ['Const', True], 'Comment!'])
self.assertEqual(parse_acl_formula(
"\"#x\" == \" # Not a comment \"#Comment!"),
['Comment',
['Eq', ['Const', '#x'], ['Const', ' # Not a comment ']],
'Comment!'
])
self.assertEqual(parse_acl_formula(
"# Allow owners\nuser.Access == 'owners' # ignored\n# comment ignored"),
['Comment',
['Eq', ['Attr', ['Name', 'user'], 'Access'], ['Const', 'owners']],
'Allow owners'
])
def test_unsupported(self):
# Test a few constructs we expect to fail
# Not an expression
self.assertRaises(SyntaxError, parse_acl_formula, "return 1")
self.assertRaises(SyntaxError, parse_acl_formula, "def foo(): pass")
# Unsupported node type
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_acl_formula, "max(rec)")
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_acl_formula, "user.id in {1, 2, 3}")
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_acl_formula, "1 if user.IsAnon else 2")
# Unsupported operation
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_acl_formula, "1 | 2")
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_acl_formula, "1 << 2")
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_acl_formula, "~test")
# Syntax error
self.assertRaises(SyntaxError, parse_acl_formula, "[(]")
self.assertRaises(SyntaxError, parse_acl_formula, "user.id in (1,2))")
self.assertRaisesRegex(SyntaxError, r'invalid syntax on line 1 col 9', parse_acl_formula, "foo and !bar")
class TestACLFormulaUserActions(test_engine.EngineTestCase):
def test_acl_actions(self):
# Adding or updating ACLRules automatically includes aclFormula compilation.

View File

@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
# pylint:disable=line-too-long
import json
import test_engine
class TestDropdownConditionUserActions(test_engine.EngineTestCase):
def test_dropdown_condition_col_actions(self):
self.apply_user_action(['AddTable', 'Table1', [
{'id': 'A', 'type': 'Text'},
{'id': 'B', 'type': 'Text'},
{'id': 'C', 'type': 'Text'},
]])
# Check that setting dropdownCondition.text automatically sets a parsed version.
out_actions = self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 1, {
"widgetOptions": json.dumps({
"dropdownCondition": {
"text": 'choice.Role == "Manager"',
},
}),
}])
self.assertPartialOutActions(out_actions, { "stored": [
["UpdateRecord", "_grist_Tables_column", 1, {
"widgetOptions": "{\"dropdownCondition\": {\"text\": "
+ "\"choice.Role == \\\"Manager\\\"\", \"parsed\": "
+ "\"[\\\"Eq\\\", [\\\"Attr\\\", [\\\"Name\\\", \\\"choice\\\"], "
+ "\\\"Role\\\"], [\\\"Const\\\", \\\"Manager\\\"]]\"}}"
}]
]})
out_actions = self.apply_user_action(['BulkUpdateRecord', '_grist_Tables_column', [2, 3], {
"widgetOptions": [
json.dumps({
"dropdownCondition": {
"text": 'choice == "Manager"',
},
}),
json.dumps({
"dropdownCondition": {
"text": '$Role == "Manager"',
},
}),
],
}])
self.assertPartialOutActions(out_actions, { "stored": [
["BulkUpdateRecord", "_grist_Tables_column", [2, 3], {
"widgetOptions": [
"{\"dropdownCondition\": {\"text\": \"choice == "
+ "\\\"Manager\\\"\", \"parsed\": \"[\\\"Eq\\\", "
+ "[\\\"Name\\\", \\\"choice\\\"], [\\\"Const\\\", \\\"Manager\\\"]]\"}}",
"{\"dropdownCondition\": {\"text\": \"$Role == "
+ "\\\"Manager\\\"\", \"parsed\": \"[\\\"Eq\\\", "
+ "[\\\"Attr\\\", [\\\"Name\\\", \\\"rec\\\"], \\\"Role\\\"], "
+ "[\\\"Const\\\", \\\"Manager\\\"]]\"}}",
]
}]
]})
def test_dropdown_condition_field_actions(self):
self.apply_user_action(['AddTable', 'Table1', [
{'id': 'A', 'type': 'Text'},
{'id': 'B', 'type': 'Text'},
{'id': 'C', 'type': 'Text'},
]])
# Check that setting dropdownCondition.text automatically sets a parsed version.
out_actions = self.apply_user_action(['UpdateRecord', '_grist_Views_section_field', 1, {
"widgetOptions": json.dumps({
"dropdownCondition": {
"text": 'choice.Role == "Manager"',
},
}),
}])
self.assertPartialOutActions(out_actions, { "stored": [
["UpdateRecord", "_grist_Views_section_field", 1, {
"widgetOptions": "{\"dropdownCondition\": {\"text\": "
+ "\"choice.Role == \\\"Manager\\\"\", \"parsed\": "
+ "\"[\\\"Eq\\\", [\\\"Attr\\\", [\\\"Name\\\", \\\"choice\\\"], "
+ "\\\"Role\\\"], [\\\"Const\\\", \\\"Manager\\\"]]\"}}"
}]
]})
out_actions = self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section_field', [2, 3], {
"widgetOptions": [
json.dumps({
"dropdownCondition": {
"text": 'choice == "Manager"',
},
}),
json.dumps({
"dropdownCondition": {
"text": '$Role == "Manager"',
},
}),
],
}])
self.assertPartialOutActions(out_actions, { "stored": [
["BulkUpdateRecord", "_grist_Views_section_field", [2, 3], {
"widgetOptions": [
"{\"dropdownCondition\": {\"text\": \"choice == "
+ "\\\"Manager\\\"\", \"parsed\": \"[\\\"Eq\\\", "
+ "[\\\"Name\\\", \\\"choice\\\"], [\\\"Const\\\", \\\"Manager\\\"]]\"}}",
"{\"dropdownCondition\": {\"text\": \"$Role == "
+ "\\\"Manager\\\"\", \"parsed\": \"[\\\"Eq\\\", "
+ "[\\\"Attr\\\", [\\\"Name\\\", \\\"rec\\\"], \\\"Role\\\"], "
+ "[\\\"Const\\\", \\\"Manager\\\"]]\"}}",
]
}]
]})

View File

@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
# pylint:disable=line-too-long
import unittest
from predicate_formula import parse_predicate_formula
class TestPredicateFormula(unittest.TestCase):
def test_basic(self):
# Test a few basic formulas and structures, hitting everything we expect to support
# in ACL formulas and dropdown conditions.
self.assertEqual(parse_predicate_formula(
"user.Email == 'X@'"),
["Eq", ["Attr", ["Name", "user"], "Email"],
["Const", "X@"]])
self.assertEqual(parse_predicate_formula(
"user.Role in ('editors', 'owners')"),
["In", ["Attr", ["Name", "user"], "Role"],
["List", ["Const", "editors"], ["Const", "owners"]]])
self.assertEqual(parse_predicate_formula(
"user.Role not in ('editors', 'owners')"),
["NotIn", ["Attr", ["Name", "user"], "Role"],
["List", ["Const", "editors"], ["Const", "owners"]]])
self.assertEqual(parse_predicate_formula(
"rec.office == 'Seattle' and user.email in ['sally@', 'xie@']"),
['And',
['Eq', ['Attr', ['Name', 'rec'], 'office'], ['Const', 'Seattle']],
['In',
['Attr', ['Name', 'user'], 'email'],
['List', ['Const', 'sally@'], ['Const', 'xie@']]
]])
self.assertEqual(parse_predicate_formula(
"$office == 'Seattle' and user.email in ['sally@', 'xie@']"),
['And',
['Eq', ['Attr', ['Name', 'rec'], 'office'], ['Const', 'Seattle']],
['In',
['Attr', ['Name', 'user'], 'email'],
['List', ['Const', 'sally@'], ['Const', 'xie@']]
]])
self.assertEqual(parse_predicate_formula(
"user.IsAdmin or rec.assigned is None or (not newRec.HasDuplicates and rec.StatusIndex <= newRec.StatusIndex)"),
['Or',
['Attr', ['Name', 'user'], 'IsAdmin'],
['Is', ['Attr', ['Name', 'rec'], 'assigned'], ['Const', None]],
['And',
['Not', ['Attr', ['Name', 'newRec'], 'HasDuplicates']],
['LtE', ['Attr', ['Name', 'rec'], 'StatusIndex'], ['Attr', ['Name', 'newRec'], 'StatusIndex']]
]
])
self.assertEqual(parse_predicate_formula(
"user.IsAdmin or $assigned is None or (not newRec.HasDuplicates and $StatusIndex <= newRec.StatusIndex)"),
['Or',
['Attr', ['Name', 'user'], 'IsAdmin'],
['Is', ['Attr', ['Name', 'rec'], 'assigned'], ['Const', None]],
['And',
['Not', ['Attr', ['Name', 'newRec'], 'HasDuplicates']],
['LtE', ['Attr', ['Name', 'rec'], 'StatusIndex'], ['Attr', ['Name', 'newRec'], 'StatusIndex']]
]
])
self.assertEqual(parse_predicate_formula(
"r.A <= n.A + 1 or r.A >= n.A - 1 or r.B < n.B * 2.5 or r.B > n.B / 2.5 or r.C % 2 != 0"),
['Or',
['LtE',
['Attr', ['Name', 'r'], 'A'],
['Add', ['Attr', ['Name', 'n'], 'A'], ['Const', 1]]],
['GtE',
['Attr', ['Name', 'r'], 'A'],
['Sub', ['Attr', ['Name', 'n'], 'A'], ['Const', 1]]],
['Lt',
['Attr', ['Name', 'r'], 'B'],
['Mult', ['Attr', ['Name', 'n'], 'B'], ['Const', 2.5]]],
['Gt',
['Attr', ['Name', 'r'], 'B'],
['Div', ['Attr', ['Name', 'n'], 'B'], ['Const', 2.5]]],
['NotEq',
['Mod', ['Attr', ['Name', 'r'], 'C'], ['Const', 2]],
['Const', 0]]
])
self.assertEqual(parse_predicate_formula(
"rec.A is True or rec.A is not False"),
['Or',
['Is', ['Attr', ['Name', 'rec'], 'A'], ['Const', True]],
['IsNot', ['Attr', ['Name', 'rec'], 'A'], ['Const', False]]
])
self.assertEqual(parse_predicate_formula(
"$A is True or $A is not False"),
['Or',
['Is', ['Attr', ['Name', 'rec'], 'A'], ['Const', True]],
['IsNot', ['Attr', ['Name', 'rec'], 'A'], ['Const', False]]
])
self.assertEqual(parse_predicate_formula(
"user.Office.City == 'Seattle' and user.Status.IsActive"),
['And',
['Eq',
['Attr', ['Attr', ['Name', 'user'], 'Office'], 'City'],
['Const', 'Seattle']],
['Attr', ['Attr', ['Name', 'user'], 'Status'], 'IsActive']
])
self.assertEqual(parse_predicate_formula(
"True # Comment! "),
['Comment', ['Const', True], 'Comment!'])
self.assertEqual(parse_predicate_formula(
"\"#x\" == \" # Not a comment \"#Comment!"),
['Comment',
['Eq', ['Const', '#x'], ['Const', ' # Not a comment ']],
'Comment!'
])
self.assertEqual(parse_predicate_formula(
"# Allow owners\nuser.Access == 'owners' # ignored\n# comment ignored"),
['Comment',
['Eq', ['Attr', ['Name', 'user'], 'Access'], ['Const', 'owners']],
'Allow owners'
])
self.assertEqual(parse_predicate_formula(
"choice not in $Categories"),
['NotIn', ['Name', 'choice'], ['Attr', ['Name', 'rec'], 'Categories']])
self.assertEqual(parse_predicate_formula(
"choice.role == \"Manager\""),
['Eq', ['Attr', ['Name', 'choice'], 'role'], ['Const', 'Manager']])
def test_unsupported(self):
# Test a few constructs we expect to fail
# Not an expression
self.assertRaises(SyntaxError, parse_predicate_formula, "return 1")
self.assertRaises(SyntaxError, parse_predicate_formula, "def foo(): pass")
# Unsupported node type
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "max(rec)")
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "user.id in {1, 2, 3}")
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "1 if user.IsAnon else 2")
# Unsupported operation
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "1 | 2")
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "1 << 2")
self.assertRaisesRegex(ValueError, r'Unsupported syntax', parse_predicate_formula, "~test")
# Syntax error
self.assertRaises(SyntaxError, parse_predicate_formula, "[(]")
self.assertRaises(SyntaxError, parse_predicate_formula, "user.id in (1,2))")
self.assertRaisesRegex(SyntaxError, r'invalid syntax on line 1 col 9', parse_predicate_formula, "foo and !bar")

View File

@@ -12,7 +12,8 @@ from six.moves import xrange
import acl
import depend
import gencode
from acl_formula import parse_acl_formula_json
from acl_formula import parse_acl_formulas
from dropdown_condition import parse_dropdown_conditions
import actions
import column
import sort_specs
@@ -437,9 +438,7 @@ class UserActions(object):
@override_action('BulkAddRecord', '_grist_ACLRules')
def _addACLRules(self, table_id, row_ids, col_values):
# Automatically populate aclFormulaParsed value by parsing aclFormula.
if 'aclFormula' in col_values:
col_values['aclFormulaParsed'] = [parse_acl_formula_json(v) for v in col_values['aclFormula']]
parse_acl_formulas(col_values)
return self.doBulkAddOrReplace(table_id, row_ids, col_values)
#----------------------------------------
@@ -672,6 +671,7 @@ class UserActions(object):
# columns for all summary tables of the same source table).
# (4) Updates to the source columns of summary group-by columns (including renaming and type
# changes) should be copied to those group-by columns.
parse_dropdown_conditions(col_values)
# A list of individual (col_rec, values) updates, where values is a per-column dict.
col_updates = OrderedDict()
@@ -781,11 +781,14 @@ class UserActions(object):
self.doBulkUpdateRecord(table_id, row_ids, col_values)
@override_action('BulkUpdateRecord', '_grist_Views_section_field')
def _updateViewSectionFields(self, table_id, row_ids, col_values):
parse_dropdown_conditions(col_values)
return self.doBulkUpdateRecord(table_id, row_ids, col_values)
@override_action('BulkUpdateRecord', '_grist_ACLRules')
def _updateACLRules(self, table_id, row_ids, col_values):
# Automatically populate aclFormulaParsed value by parsing aclFormula.
if 'aclFormula' in col_values:
col_values['aclFormulaParsed'] = [parse_acl_formula_json(v) for v in col_values['aclFormula']]
parse_acl_formulas(col_values)
return self.doBulkUpdateRecord(table_id, row_ids, col_values)
def _prepare_formula_renames(self, renames):