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