Update dropdown conditions on column rename (#1038)
Automatically update dropdown condition formulas on Ref, RefList, Choice and ChoiceList columns when a column referred to has been renamed. Also fixed column references in ACL formulas using the "$" notation not being properly renamed.pull/1109/head
parent
a437dfa28c
commit
632620544c
@ -1,50 +0,0 @@
|
|||||||
import ast
|
|
||||||
|
|
||||||
import asttokens
|
|
||||||
|
|
||||||
from predicate_formula import NamedEntity, parse_predicate_formula_json, TreeConverter
|
|
||||||
|
|
||||||
def parse_acl_formulas(col_values):
|
|
||||||
"""
|
|
||||||
Populates `aclFormulaParsed` by parsing `aclFormula` for all `col_values`.
|
|
||||||
"""
|
|
||||||
if 'aclFormula' not in col_values:
|
|
||||||
return
|
|
||||||
|
|
||||||
col_values['aclFormulaParsed'] = [parse_predicate_formula_json(v)
|
|
||||||
for v
|
|
||||||
in col_values['aclFormula']]
|
|
||||||
|
|
||||||
def parse_acl_grist_entities(acl_formula):
|
|
||||||
"""
|
|
||||||
Parse the ACL formula collecting any entities that may be subject to renaming. Returns a
|
|
||||||
NamedEntity list.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
atok = asttokens.ASTTokens(acl_formula, tree=ast.parse(acl_formula, mode='eval'))
|
|
||||||
converter = _EntityCollector()
|
|
||||||
converter.visit(atok.tree)
|
|
||||||
return converter.entities
|
|
||||||
except SyntaxError as err:
|
|
||||||
return []
|
|
||||||
|
|
||||||
class _EntityCollector(TreeConverter):
|
|
||||||
def __init__(self):
|
|
||||||
self.entities = [] # NamedEntity list
|
|
||||||
|
|
||||||
def visit_Attribute(self, node):
|
|
||||||
parent = self.visit(node.value)
|
|
||||||
|
|
||||||
# We recognize a couple of specific patterns for entities that may be affected by renames.
|
|
||||||
if parent == ['Name', 'rec'] or parent == ['Name', 'newRec']:
|
|
||||||
# rec.COL refers to the column from the table that the rule is on.
|
|
||||||
self.entities.append(NamedEntity('recCol', node.last_token.startpos, node.attr, None))
|
|
||||||
if parent == ['Name', 'user']:
|
|
||||||
# user.ATTR is a user attribute.
|
|
||||||
self.entities.append(NamedEntity('userAttr', node.last_token.startpos, node.attr, None))
|
|
||||||
elif parent[0] == 'Attr' and parent[1] == ['Name', 'user']:
|
|
||||||
# user.ATTR.COL is a column from the lookup table of the UserAttribute ATTR.
|
|
||||||
self.entities.append(
|
|
||||||
NamedEntity('userAttrCol', node.last_token.startpos, node.attr, parent[2]))
|
|
||||||
|
|
||||||
return ["Attr", parent, node.attr]
|
|
@ -0,0 +1,158 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import test_engine
|
||||||
|
import testsamples
|
||||||
|
import useractions
|
||||||
|
|
||||||
|
# A sample dropdown condition formula for the column Schools.address and alike, of type Ref/RefList.
|
||||||
|
def build_dc1_text(school_name, address_city):
|
||||||
|
return "'New' in choice.{address_city} and ${school_name} == rec.{school_name} + rec.choice.city or choice.rec.city != $name2".format(**locals())
|
||||||
|
|
||||||
|
# Another sample formula for a new column of type ChoiceList (or actually, anything other than Ref/RefList).
|
||||||
|
def build_dc2_text(school_name, school_address):
|
||||||
|
# We currently don't support layered attribute access, e.g. rec.address.city, so this is not tested.
|
||||||
|
# choice.city really is nonsense, as choice will not be an object.
|
||||||
|
# Just for testing purposes, to make sure nothing is renamed here.
|
||||||
|
return "choice + ${school_name} == choice.city or rec.{school_address} > 2".format(**locals())
|
||||||
|
|
||||||
|
def build_dc1(school_name, address_city):
|
||||||
|
return json.dumps({
|
||||||
|
"dropdownCondition": {
|
||||||
|
"text": build_dc1_text(school_name, address_city),
|
||||||
|
# The ModifyColumn user action should trigger an auto parse.
|
||||||
|
# "parsed" is stored as dumped JSON, so we need to explicitly dump it here as well.
|
||||||
|
"parsed": json.dumps(["Or", ["And", ["In", ["Const", "New"], ["Attr", ["Name", "choice"], address_city]], ["Eq", ["Attr", ["Name", "rec"], school_name], ["Add", ["Attr", ["Name", "rec"], school_name], ["Attr", ["Attr", ["Name", "rec"], "choice"], "city"]]]], ["NotEq", ["Attr", ["Attr", ["Name", "choice"], "rec"], "city"], ["Attr", ["Name", "rec"], "name2"]]])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
def build_dc2(school_name, school_address):
|
||||||
|
return json.dumps({
|
||||||
|
"dropdownCondition": {
|
||||||
|
"text": build_dc2_text(school_name, school_address),
|
||||||
|
"parsed": json.dumps(["Or", ["Eq", ["Add", ["Name", "choice"], ["Attr", ["Name", "rec"], school_name]], ["Attr", ["Name", "choice"], "city"]], ["Gt", ["Attr", ["Name", "rec"], school_address], ["Const", 2]]])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
class TestDCRenames(test_engine.EngineTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestDCRenames, self).setUp()
|
||||||
|
|
||||||
|
self.load_sample(testsamples.sample_students)
|
||||||
|
|
||||||
|
self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (
|
||||||
|
# Add some irrelevant columns to the table Schools. These should never be renamed.
|
||||||
|
["AddColumn", "Schools", "name2", {
|
||||||
|
"type": "Text"
|
||||||
|
}],
|
||||||
|
["AddColumn", "Schools", "choice", {
|
||||||
|
"type": "Ref:Address"
|
||||||
|
}],
|
||||||
|
["AddColumn", "Address", "rec", {
|
||||||
|
"type": "Text"
|
||||||
|
}],
|
||||||
|
# Add a dropdown condition formula to Schools.address (column #12).
|
||||||
|
["ModifyColumn", "Schools", "address", {
|
||||||
|
"widgetOptions": json.dumps({
|
||||||
|
"dropdownCondition": {
|
||||||
|
"text": build_dc1_text("name", "city"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
# Create a similar column with an invalid dropdown condition formula.
|
||||||
|
# This formula should never be touched.
|
||||||
|
# This column will have the ID 25.
|
||||||
|
["AddColumn", "Schools", "address2", {
|
||||||
|
"type": "Ref:Address",
|
||||||
|
"widgetOptions": json.dumps({
|
||||||
|
"dropdownCondition": {
|
||||||
|
"text": "+ 'New' in choice.city and $name == rec.name",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
# And another similar column, but of type RefList.
|
||||||
|
# This column will have the ID 26.
|
||||||
|
["AddColumn", "Schools", "addresses", {
|
||||||
|
"type": "RefList:Address",
|
||||||
|
}],
|
||||||
|
# AddColumn will not trigger parsing. We emulate a real user's action here by creating it first,
|
||||||
|
# then editing its widgetOptions.
|
||||||
|
["ModifyColumn", "Schools", "addresses", {
|
||||||
|
"widgetOptions": json.dumps({
|
||||||
|
"dropdownCondition": {
|
||||||
|
"text": build_dc1_text("name", "city"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
# And another similar column, but of type ChoiceList.
|
||||||
|
# widgetOptions stay when the column type changes. We do our best to rename stuff in stray widgetOptions.
|
||||||
|
# This column will have the ID 27.
|
||||||
|
["AddColumn", "Schools", "features", {
|
||||||
|
"type": "ChoiceList",
|
||||||
|
}],
|
||||||
|
["ModifyColumn", "Schools", "features", {
|
||||||
|
"widgetOptions": json.dumps({
|
||||||
|
"dropdownCondition": {
|
||||||
|
"text": build_dc2_text("name", "address"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
)])
|
||||||
|
|
||||||
|
# This is what we'll have at the beginning, for later tests to refer to.
|
||||||
|
# Table Schools is 2.
|
||||||
|
self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
|
||||||
|
["id", "parentId", "colId", "widgetOptions"],
|
||||||
|
[12, 2, "address", build_dc1("name", "city")],
|
||||||
|
[26, 2, "addresses", build_dc1("name", "city")],
|
||||||
|
[27, 2, "features", build_dc2("name", "address")],
|
||||||
|
])
|
||||||
|
self.assert_invalid_formula_untouched()
|
||||||
|
|
||||||
|
def assert_invalid_formula_untouched(self):
|
||||||
|
self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
|
||||||
|
["id", "parentId", "colId", "widgetOptions"],
|
||||||
|
[25, 2, "address2", json.dumps({
|
||||||
|
"dropdownCondition": {
|
||||||
|
"text": "+ 'New' in choice.city and $name == rec.name",
|
||||||
|
}
|
||||||
|
})]
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_referred_column_renames(self):
|
||||||
|
self.apply_user_action(["RenameColumn", "Address", "city", "area"])
|
||||||
|
self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
|
||||||
|
["id", "parentId", "colId", "widgetOptions"],
|
||||||
|
[12, 2, "address", build_dc1("name", "area")],
|
||||||
|
[26, 2, "addresses", build_dc1("name", "area")],
|
||||||
|
# Nothing should be renamed here, as only column renames in the table "Schools" are relevant.
|
||||||
|
[27, 2, "features", build_dc2("name", "address")],
|
||||||
|
])
|
||||||
|
self.assert_invalid_formula_untouched()
|
||||||
|
|
||||||
|
def test_record_column_renames(self):
|
||||||
|
self.apply_user_action(["RenameColumn", "Schools", "name", "identifier"])
|
||||||
|
self.apply_user_action(["RenameColumn", "Schools", "address", "location"])
|
||||||
|
self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
|
||||||
|
["id", "parentId", "colId", "widgetOptions"],
|
||||||
|
# Side effect: "address" becomes "location".
|
||||||
|
[12, 2, "location", build_dc1("identifier", "city")],
|
||||||
|
[26, 2, "addresses", build_dc1("identifier", "city")],
|
||||||
|
# Now "$name" should become "$identifier", just like in Ref/RefList columns. Nothing else should change.
|
||||||
|
[27, 2, "features", build_dc2("identifier", "location")],
|
||||||
|
])
|
||||||
|
self.assert_invalid_formula_untouched()
|
||||||
|
|
||||||
|
def test_multiple_renames(self):
|
||||||
|
# Put all renames together.
|
||||||
|
self.apply_user_action(["RenameColumn", "Address", "city", "area"])
|
||||||
|
self.apply_user_action(["RenameColumn", "Schools", "name", "identifier"])
|
||||||
|
self.assertTableData("_grist_Tables_column", cols="subset", rows="subset", data=[
|
||||||
|
["id", "parentId", "colId", "widgetOptions"],
|
||||||
|
[12, 2, "address", build_dc1("identifier", "area")],
|
||||||
|
[26, 2, "addresses", build_dc1("identifier", "area")],
|
||||||
|
[27, 2, "features", build_dc2("identifier", "address")],
|
||||||
|
])
|
||||||
|
self.assert_invalid_formula_untouched()
|
Loading…
Reference in new issue