mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
43
sandbox/grist/dropdown_condition.py
Normal file
43
sandbox/grist/dropdown_condition.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
118
sandbox/grist/predicate_formula.py
Normal file
118
sandbox/grist/predicate_formula.py
Normal 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))
|
||||
@@ -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.
|
||||
|
||||
108
sandbox/grist/test_dropdown_condition.py
Normal file
108
sandbox/grist/test_dropdown_condition.py
Normal 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\\\"]]\"}}",
|
||||
]
|
||||
}]
|
||||
]})
|
||||
154
sandbox/grist/test_predicate_formula.py
Normal file
154
sandbox/grist/test_predicate_formula.py
Normal 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")
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user