gristlabs_grist-core/sandbox/grist/test_useractions.py
Dmitry S e2226c3ab7 (core) Store formula values in DB, and include them into .stored/.undo fields of actions.
Summary:
- Introduce a new SQLiteDB migration, which adds DB columns for formula columns
- Newly added columns have the special ['P'] (pending) value in them
  (in order to show the usual "Loading..." on the first load that triggers the migration)
- Calculated values are added to .stored/.undo fields of user actions.
- Various changes made in the sandbox to include .stored/.undo in the right order.
- OnDemand tables ignore stored formula columns, replacing them with special SQL as before
- In particular, converting to OnDemand table leaves stale values in those
  columns, we should maybe clean those out.

Some tweaks on the side:
- Allow overriding chai assertion truncateThreshold with CHAI_TRUNCATE_THRESHOLD
- Rebuild python automatically in watch mode

Test Plan: Fixed various tests, updated some fixtures. Many python tests that check actions needed adjustments because actions moved from .stored to .undo. Some checks added to catch situations previously only caught in browser tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2645
2020-11-04 16:45:47 -05:00

919 lines
38 KiB
Python

import types
import logger
import useractions
import testutil
import test_engine
from test_engine import Table, Column, View, Section, Field
log = logger.Logger(__name__, logger.INFO)
class TestUserActions(test_engine.EngineTestCase):
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Address", [
[21, "city", "Text", False, "", "", ""],
]]
],
"DATA": {
"Address": [
["id", "city" ],
[11, "New York" ],
[12, "Colombia" ],
[13, "New Haven" ],
[14, "West Haven" ]],
}
})
starting_table = Table(1, "Address", primaryViewId=0, summarySourceTable=0, columns=[
Column(21, "city", "Text", isFormula=False, formula="", summarySourceCol=0)
])
#----------------------------------------------------------------------
def test_conversions(self):
# Test the sequence of user actions as used for transform-based conversions. This is actually
# not exactly what the client emits, but more like what the client should ideally emit.
# Our sample has a Schools.city text column; we'll convert it to Ref:Address.
self.load_sample(self.sample)
# Add a new table for Schools so that we get the associated views and fields.
self.apply_user_action(['AddTable', 'Schools', [{'id': 'city', 'type': 'Text'}]])
self.apply_user_action(['BulkAddRecord', 'Schools', [1,2,3,4], {
'city': ['New York', 'Colombia', 'New York', '']
}])
self.assertPartialData("_grist_Tables", ["id", "tableId"], [
[1, "Address"],
[2, "Schools"],
])
self.assertPartialData("_grist_Tables_column",
["id", "colId", "parentId", "parentPos", "widgetOptions"], [
[21, "city", 1, 1.0, ""],
[22, "manualSort", 2, 2.0, ""],
[23, "city", 2, 3.0, ""],
])
self.assertPartialData("_grist_Views_section_field", ["id", "colRef", "widgetOptions"], [
[1, 23, ""]
])
self.assertPartialData("Schools", ["id", "city"], [
[1, "New York" ],
[2, "Colombia" ],
[3, "New York" ],
[4, "" ],
])
# Our sample has a text column city.
out_actions = self.add_column('Schools', 'grist_Transform',
isFormula=True, formula='return $city', type='Text')
self.assertPartialOutActions(out_actions, { "stored": [
['AddColumn', 'Schools', 'grist_Transform', {
'type': 'Text', 'isFormula': True, 'formula': 'return $city',
}],
['AddRecord', '_grist_Tables_column', 24, {
'widgetOptions': '', 'parentPos': 4.0, 'isFormula': True, 'parentId': 2, 'colId':
'grist_Transform', 'formula': 'return $city', 'label': 'grist_Transform',
'type': 'Text'
}],
["AddRecord", "_grist_Views_section_field", 2, {
"colRef": 24, "parentId": 1, "parentPos": 2.0
}],
["BulkUpdateRecord", "Schools", [1, 2, 3],
{"grist_Transform": ["New York", "Colombia", "New York"]}],
]})
out_actions = self.update_record('_grist_Tables_column', 24,
type='Ref:Address',
formula='return Address.lookupOne(city=$city).id')
self.assertPartialOutActions(out_actions, { "stored": [
['ModifyColumn', 'Schools', 'grist_Transform', {
'formula': 'return Address.lookupOne(city=$city).id', 'type': 'Ref:Address'}],
['UpdateRecord', '_grist_Tables_column', 24, {
'formula': 'return Address.lookupOne(city=$city).id', 'type': 'Ref:Address'}],
["BulkUpdateRecord", "Schools", [1, 2, 3, 4], {"grist_Transform": [11, 12, 11, 0]}],
]})
# It seems best if TypeTransform sets widgetOptions on grist_Transform column, so that they
# can be copied in CopyFromColumn; rather than updating them after the copy is done.
self.update_record('_grist_Views_section_field', 1, widgetOptions="hello")
self.update_record('_grist_Tables_column', 24, widgetOptions="world")
out_actions = self.apply_user_action(
['CopyFromColumn', 'Schools', 'grist_Transform', 'city', None])
self.assertPartialOutActions(out_actions, { "stored": [
['ModifyColumn', 'Schools', 'city', {'type': 'Ref:Address'}],
['UpdateRecord', 'Schools', 4, {'city': 0}],
['UpdateRecord', '_grist_Views_section_field', 1, {'widgetOptions': ''}],
['UpdateRecord', '_grist_Tables_column', 23, {
'type': 'Ref:Address', 'widgetOptions': 'world'
}],
['BulkUpdateRecord', 'Schools', [1, 2, 3], {'city': [11, 12, 11]}],
["BulkUpdateRecord", "Schools", [1, 2, 3], {"grist_Transform": [0, 0, 0]}],
]})
out_actions = self.update_record('_grist_Tables_column', 23,
widgetOptions='{"widget":"Reference","visibleCol":"city"}')
self.assertPartialOutActions(out_actions, { "stored": [
['UpdateRecord', '_grist_Tables_column', 23, {
'widgetOptions': '{"widget":"Reference","visibleCol":"city"}'}],
]})
out_actions = self.remove_column('Schools', 'grist_Transform')
self.assertPartialOutActions(out_actions, { "stored": [
['RemoveRecord', '_grist_Views_section_field', 2],
['RemoveRecord', '_grist_Tables_column', 24],
['RemoveColumn', 'Schools', 'grist_Transform'],
]})
#----------------------------------------------------------------------
def test_create_section_existing_view(self):
# Test that CreateViewSection works for an existing view.
self.load_sample(self.sample)
self.assertTables([self.starting_table])
# Create a view + section for the initial table.
self.apply_user_action(["CreateViewSection", 1, 0, "record", None])
# Verify that we got a new view, with one section, and three fields.
self.assertViews([View(1, sections=[
Section(1, parentKey="record", tableRef=1, fields=[
Field(1, colRef=21),
])
]) ])
# Create a new section for the same view, check that only a section is added.
self.apply_user_action(["CreateViewSection", 1, 1, "record", None])
self.assertTables([self.starting_table])
self.assertViews([View(1, sections=[
Section(1, parentKey="record", tableRef=1, fields=[
Field(1, colRef=21),
]),
Section(2, parentKey="record", tableRef=1, fields=[
Field(2, colRef=21),
])
]) ])
# Create another section for the same view, this time summarized.
self.apply_user_action(["CreateViewSection", 1, 1, "record", [21]])
summary_table = Table(2, "GristSummary_7_Address", 0, summarySourceTable=1, columns=[
Column(22, "city", "Text", isFormula=False, formula="", summarySourceCol=21),
Column(23, "group", "RefList:Address", isFormula=True,
formula="table.getSummarySourceGroup(rec)", summarySourceCol=0),
Column(24, "count", "Int", isFormula=True, formula="len($group)", summarySourceCol=0),
])
self.assertTables([self.starting_table, summary_table])
# Check that we still have one view, with sections for different tables.
view = View(1, sections=[
Section(1, parentKey="record", tableRef=1, fields=[
Field(1, colRef=21),
]),
Section(2, parentKey="record", tableRef=1, fields=[
Field(2, colRef=21),
]),
Section(3, parentKey="record", tableRef=2, fields=[
Field(3, colRef=22),
Field(4, colRef=24),
]),
])
self.assertTables([self.starting_table, summary_table])
self.assertViews([view])
# Try to create a summary table for an invalid column, and check that it fails.
with self.assertRaises(ValueError):
self.apply_user_action(["CreateViewSection", 1, 1, "record", [23]])
self.assertTables([self.starting_table, summary_table])
self.assertViews([view])
#----------------------------------------------------------------------
def test_creates_section_new_table(self):
# Test that CreateViewSection works for adding a new table.
self.load_sample(self.sample)
self.assertTables([self.starting_table])
self.assertViews([])
# When we create a section/view for new table, we get both a primary view, and the new view we
# are creating.
self.apply_user_action(["CreateViewSection", 0, 0, "record", None])
new_table = Table(2, "Table1", primaryViewId=1, summarySourceTable=0, columns=[
Column(22, "manualSort", "ManualSortPos", isFormula=False, formula="", summarySourceCol=0),
Column(23, "A", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(24, "B", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(25, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
])
primary_view = View(1, sections=[
Section(1, parentKey="record", tableRef=2, fields=[
Field(1, colRef=23),
Field(2, colRef=24),
Field(3, colRef=25),
])
])
new_view = View(2, sections=[
Section(2, parentKey="record", tableRef=2, fields=[
Field(4, colRef=23),
Field(5, colRef=24),
Field(6, colRef=25),
])
])
self.assertTables([self.starting_table, new_table])
self.assertViews([primary_view, new_view])
# Create another section in an existing view for a new table.
self.apply_user_action(["CreateViewSection", 0, 2, "record", None])
new_table2 = Table(3, "Table2", primaryViewId=3, summarySourceTable=0, columns=[
Column(26, "manualSort", "ManualSortPos", isFormula=False, formula="", summarySourceCol=0),
Column(27, "A", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(28, "B", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(29, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
])
primary_view2 = View(3, sections=[
Section(3, parentKey="record", tableRef=3, fields=[
Field(7, colRef=27),
Field(8, colRef=28),
Field(9, colRef=29),
])
])
new_view.sections.append(
Section(4, parentKey="record", tableRef=3, fields=[
Field(10, colRef=27),
Field(11, colRef=28),
Field(12, colRef=29),
])
)
# Check that we have a new table, only the primary view as new view; and a new section.
self.assertTables([self.starting_table, new_table, new_table2])
self.assertViews([primary_view, new_view, primary_view2])
# Check that we can't create a summary of a table grouped by a column that doesn't exist yet.
with self.assertRaises(ValueError):
self.apply_user_action(["CreateViewSection", 0, 2, "record", [31]])
self.assertTables([self.starting_table, new_table, new_table2])
self.assertViews([primary_view, new_view, primary_view2])
# But creating a new table and showing totals for it is possible though dumb.
self.apply_user_action(["CreateViewSection", 0, 2, "record", []])
# We expect a new table.
new_table3 = Table(4, "Table3", primaryViewId=4, summarySourceTable=0, columns=[
Column(30, "manualSort", "ManualSortPos", isFormula=False, formula="", summarySourceCol=0),
Column(31, "A", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(32, "B", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(33, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
])
# A summary of it.
summary_table = Table(5, "GristSummary_6_Table3", 0, summarySourceTable=4, columns=[
Column(34, "group", "RefList:Table3", isFormula=True,
formula="table.getSummarySourceGroup(rec)", summarySourceCol=0),
Column(35, "count", "Int", isFormula=True, formula="len($group)", summarySourceCol=0),
])
# The primary view of the new table.
primary_view3 = View(4, sections=[
Section(5, parentKey="record", tableRef=4, fields=[
Field(13, colRef=31),
Field(14, colRef=32),
Field(15, colRef=33),
])
])
# And a new view section for the summary.
new_view.sections.append(Section(6, parentKey="record", tableRef=5, fields=[
Field(16, colRef=35)
]))
self.assertTables([self.starting_table, new_table, new_table2, new_table3, summary_table])
self.assertViews([primary_view, new_view, primary_view2, primary_view3])
#----------------------------------------------------------------------
def init_views_sample(self):
# Add a new table and a view, to get some Views/Sections/Fields, and TableView/TabBar items.
self.apply_user_action(['AddTable', 'Schools', [
{'id': 'city', 'type': 'Text'},
{'id': 'state', 'type': 'Text'},
{'id': 'size', 'type': 'Numeric'},
]])
self.apply_user_action(['BulkAddRecord', 'Schools', [1,2,3,4], {
'city': ['New York', 'Colombia', 'New York', ''],
'state': ['NY', 'NY', 'NY', ''],
'size': [1000, 2000, 3000, 4000],
}])
# Add a new view; a second section (summary) to it; and a third view.
self.apply_user_action(['CreateViewSection', 1, 0, 'detail', None])
self.apply_user_action(['CreateViewSection', 1, 2, 'record', [3]])
self.apply_user_action(['CreateViewSection', 1, 0, 'chart', None])
self.apply_user_action(['CreateViewSection', 0, 2, 'record', None])
# Verify the new structure of tables and views.
self.assertTables([
Table(1, "Schools", 1, 0, columns=[
Column(1, "manualSort", "ManualSortPos", False, "", 0),
Column(2, "city", "Text", False, "", 0),
Column(3, "state", "Text", False, "", 0),
Column(4, "size", "Numeric", False, "", 0),
]),
Table(2, "GristSummary_7_Schools", 0, 1, columns=[
Column(5, "state", "Text", False, "", 3),
Column(6, "group", "RefList:Schools", True, "table.getSummarySourceGroup(rec)", 0),
Column(7, "count", "Int", True, "len($group)", 0),
Column(8, "size", "Numeric", True, "SUM($group.size)", 0),
]),
Table(3, 'Table1', 4, 0, columns=[
Column(9, "manualSort", "ManualSortPos", False, "", 0),
Column(10, "A", "Any", True, "", 0),
Column(11, "B", "Any", True, "", 0),
Column(12, "C", "Any", True, "", 0),
]),
])
self.assertViews([
View(1, sections=[
Section(1, parentKey="record", tableRef=1, fields=[
Field(1, colRef=2),
Field(2, colRef=3),
Field(3, colRef=4),
]),
]),
View(2, sections=[
Section(2, parentKey="detail", tableRef=1, fields=[
Field(4, colRef=2),
Field(5, colRef=3),
Field(6, colRef=4),
]),
Section(3, parentKey="record", tableRef=2, fields=[
Field(7, colRef=5),
Field(8, colRef=7),
Field(9, colRef=8),
]),
Section(6, parentKey='record', tableRef=3, fields=[
Field(15, colRef=10),
Field(16, colRef=11),
Field(17, colRef=12),
]),
]),
View(3, sections=[
Section(4, parentKey="chart", tableRef=1, fields=[
Field(10, colRef=2),
Field(11, colRef=3),
]),
]),
View(4, sections=[
Section(5, parentKey='record', tableRef=3, fields=[
Field(12, colRef=10),
Field(13, colRef=11),
Field(14, colRef=12),
]),
]),
])
self.assertTableData('_grist_TableViews', data=[
["id", "tableRef", "viewRef"],
[1, 1, 2],
[2, 1, 3],
])
self.assertTableData('_grist_TabBar', cols="subset", data=[
["id", "viewRef"],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
])
self.assertTableData('_grist_Pages', cols="subset", data=[
["id", "viewRef"],
[1, 1],
[2, 2],
[3, 3],
[4, 4]
])
#----------------------------------------------------------------------
def test_view_remove(self):
# Add a couple of tables and views, to trigger creation of some related items.
self.init_views_sample()
# Remove a view. Ensure related items, sections, fields get removed.
self.apply_user_action(["BulkRemoveRecord", "_grist_Views", [2,3]])
# Verify the new structure of tables and views.
self.assertTables([
Table(1, "Schools", 1, 0, columns=[
Column(1, "manualSort", "ManualSortPos", False, "", 0),
Column(2, "city", "Text", False, "", 0),
Column(3, "state", "Text", False, "", 0),
Column(4, "size", "Numeric", False, "", 0),
]),
# Note that the summary table is gone.
Table(3, 'Table1', 4, 0, columns=[
Column(9, "manualSort", "ManualSortPos", False, "", 0),
Column(10, "A", "Any", True, "", 0),
Column(11, "B", "Any", True, "", 0),
Column(12, "C", "Any", True, "", 0),
]),
])
self.assertViews([
View(1, sections=[
Section(1, parentKey="record", tableRef=1, fields=[
Field(1, colRef=2),
Field(2, colRef=3),
Field(3, colRef=4),
]),
]),
View(4, sections=[
Section(5, parentKey='record', tableRef=3, fields=[
Field(12, colRef=10),
Field(13, colRef=11),
Field(14, colRef=12),
]),
]),
])
self.assertTableData('_grist_TableViews', data=[
["id", "tableRef", "viewRef"],
])
self.assertTableData('_grist_TabBar', cols="subset", data=[
["id", "viewRef"],
[1, 1],
[4, 4],
])
self.assertTableData('_grist_Pages', cols="subset", data=[
["id", "viewRef"],
[1, 1],
[4, 4],
])
#----------------------------------------------------------------------
def test_view_rename(self):
# Add a couple of tables and views, to trigger creation of some related items.
self.init_views_sample()
# Verify the new structure of tables and views.
self.assertTableData('_grist_Tables', cols="subset", data=[
[ 'id', 'tableId', 'primaryViewId' ],
[ 1, 'Schools', 1],
[ 2, 'GristSummary_7_Schools', 0],
[ 3, 'Table1', 4],
])
self.assertTableData('_grist_Views', cols="subset", data=[
[ 'id', 'name', 'primaryViewTable' ],
[ 1, 'Schools', 1],
[ 2, 'New page', 0],
[ 3, 'New page', 0],
[ 4, 'Table1', 3],
])
# Update the names in a few views, and ensure that primary ones cause tables to get renamed.
self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2,3,4],
{'name': ['A', 'B', 'C']}])
self.assertTableData('_grist_Tables', cols="subset", data=[
[ 'id', 'tableId', 'primaryViewId' ],
[ 1, 'Schools', 1],
[ 2, 'GristSummary_7_Schools', 0],
[ 3, 'C', 4],
])
self.assertTableData('_grist_Views', cols="subset", data=[
[ 'id', 'name', 'primaryViewTable' ],
[ 1, 'Schools', 1],
[ 2, 'A', 0],
[ 3, 'B', 0],
[ 4, 'C', 3]
])
#----------------------------------------------------------------------
def test_section_removes(self):
# Add a couple of tables and views, to trigger creation of some related items.
self.init_views_sample()
# Remove a couple of sections. Ensure their fields get removed.
self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section', [3,6]])
self.assertViews([
View(1, sections=[
Section(1, parentKey="record", tableRef=1, fields=[
Field(1, colRef=2),
Field(2, colRef=3),
Field(3, colRef=4),
]),
]),
View(2, sections=[
Section(2, parentKey="detail", tableRef=1, fields=[
Field(4, colRef=2),
Field(5, colRef=3),
Field(6, colRef=4),
]),
]),
View(3, sections=[
Section(4, parentKey="chart", tableRef=1, fields=[
Field(10, colRef=2),
Field(11, colRef=3),
]),
]),
View(4, sections=[
Section(5, parentKey='record', tableRef=3, fields=[
Field(12, colRef=10),
Field(13, colRef=11),
Field(14, colRef=12),
]),
]),
])
#----------------------------------------------------------------------
def test_schema_consistency_check(self):
# Verify that schema consistency check actually runs, but only when schema is affected.
self.init_views_sample()
# Replace the engine's assert_schema_consistent() method with a mocked version.
orig_method = self.engine.assert_schema_consistent
count_calls = [0]
def override(self): # pylint: disable=unused-argument
count_calls[0] += 1
# pylint: disable=not-callable
orig_method()
self.engine.assert_schema_consistent = types.MethodType(override, self.engine)
# Do a non-sschema action to ensure it doesn't get called.
self.apply_user_action(['UpdateRecord', '_grist_Views', 2, {'name': 'A'}])
self.assertEqual(count_calls[0], 0)
# Do a schema action to ensure it gets called: this causes a table rename.
self.apply_user_action(['UpdateRecord', '_grist_Views', 4, {'name': 'C'}])
self.assertEqual(count_calls[0], 1)
self.assertTableData('_grist_Tables', cols="subset", data=[
[ 'id', 'tableId', 'primaryViewId' ],
[ 1, 'Schools', 1],
[ 2, 'GristSummary_7_Schools', 0],
[ 3, 'C', 4],
])
# Do another schema and non-schema action.
self.apply_user_action(['UpdateRecord', 'Schools', 1, {'city': 'Seattle'}])
self.assertEqual(count_calls[0], 1)
self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 2, {'colId': 'city2'}])
self.assertEqual(count_calls[0], 2)
#----------------------------------------------------------------------
def test_new_column_conversions(self):
self.init_views_sample()
self.apply_user_action(['AddColumn', 'Schools', None, {}])
self.assertTableData('_grist_Tables_column', cols="subset", data=[
["id", "colId", "type", "isFormula", "formula"],
[1, "manualSort", "ManualSortPos",False, ""],
[2, "city", "Text", False, ""],
[3, "state", "Text", False, ""],
[4, "size", "Numeric", False, ""],
[13, "A", "Any", True, ""],
], rows=lambda r: r.parentId.id == 1)
self.assertTableData('Schools', cols="subset", data=[
["id", "city", "A"],
[1, "New York", None],
[2, "Colombia", None],
[3, "New York", None],
[4, "", None],
])
# Check that typing in text into the column produces a text column.
out_actions = self.apply_user_action(['UpdateRecord', 'Schools', 3, {"A": "foo"}])
self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
["id", "colId", "type", "isFormula", "formula"],
[13, "A", "Text", False, ""],
])
self.assertTableData('Schools', cols="subset", data=[
["id", "city", "A" ],
[1, "New York", "" ],
[2, "Colombia", "" ],
[3, "New York", "foo" ],
[4, "", "" ],
])
# Undo, and check that typing in a number produces a numeric column.
self.apply_undo_actions(out_actions.undo)
out_actions = self.apply_user_action(['UpdateRecord', 'Schools', 3, {"A": " -17.6"}])
self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
["id", "colId", "type", "isFormula", "formula"],
[13, "A", "Numeric", False, ""],
])
self.assertTableData('Schools', cols="subset", data=[
["id", "city", "A" ],
[1, "New York", 0.0 ],
[2, "Colombia", 0.0 ],
[3, "New York", -17.6 ],
[4, "", 0.0 ],
])
# Undo, and set a formula for the new column instead.
self.apply_undo_actions(out_actions.undo)
self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 13, {'formula': 'len($city)'}])
self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
["id", "colId", "type", "isFormula", "formula"],
[13, "A", "Any", True, "len($city)"],
])
self.assertTableData('Schools', cols="subset", data=[
["id", "city", "A" ],
[1, "New York", 8 ],
[2, "Colombia", 8 ],
[3, "New York", 8 ],
[4, "", 0 ],
])
# Convert the formula column to non-formula.
self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 13, {'isFormula': False}])
self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
["id", "colId", "type", "isFormula", "formula"],
[13, "A", "Numeric", False, "len($city)"],
])
self.assertTableData('Schools', cols="subset", data=[
["id", "city", "A" ],
[1, "New York", 8 ],
[2, "Colombia", 8 ],
[3, "New York", 8 ],
[4, "", 0 ],
])
# Add some more formula columns of type 'Any'.
self.apply_user_action(['AddColumn', 'Schools', None, {"formula": "1"}])
self.apply_user_action(['AddColumn', 'Schools', None, {"formula": "'x'"}])
self.apply_user_action(['AddColumn', 'Schools', None, {"formula": "$city == 'New York'"}])
self.apply_user_action(['AddColumn', 'Schools', None, {"formula": "$city=='New York' or '-'"}])
self.assertTableData('_grist_Tables_column', cols="subset", data=[
["id", "colId", "type", "isFormula", "formula"],
[1, "manualSort", "ManualSortPos",False, ""],
[2, "city", "Text", False, ""],
[3, "state", "Text", False, ""],
[4, "size", "Numeric", False, ""],
[13, "A", "Numeric", False, "len($city)"],
[14, "B", "Any", True, "1"],
[15, "C", "Any", True, "'x'"],
[16, "D", "Any", True, "$city == 'New York'"],
[17, "E", "Any", True, "$city=='New York' or '-'"],
], rows=lambda r: r.parentId.id == 1)
self.assertTableData('Schools', cols="subset", data=[
["id", "city", "A", "B", "C", "D", "E"],
[1, "New York", 8, 1, "x", True, True],
[2, "Colombia", 8, 1, "x", False, '-' ],
[3, "New York", 8, 1, "x", True, True],
[4, "", 0, 1, "x", False, '-' ],
])
# Convert all these formulas to non-formulas, and see that their types get guessed OK.
# TODO: We should also guess Int, Bool, Reference, ReferenceList, Date, and DateTime.
# TODO: It is possibly better if B became Int, and D became Bool.
self.apply_user_action(['BulkUpdateRecord', '_grist_Tables_column', [14,15,16,17],
{'isFormula': [False, False, False, False]}])
self.assertTableData('_grist_Tables_column', cols="subset", data=[
["id", "colId", "type", "isFormula", "formula"],
[1, "manualSort", "ManualSortPos",False, ""],
[2, "city", "Text", False, ""],
[3, "state", "Text", False, ""],
[4, "size", "Numeric", False, ""],
[13, "A", "Numeric", False, "len($city)"],
[14, "B", "Numeric", False, "1"],
[15, "C", "Text", False, "'x'"],
[16, "D", "Text", False, "$city == 'New York'"],
[17, "E", "Text", False, "$city=='New York' or '-'"],
], rows=lambda r: r.parentId.id == 1)
self.assertTableData('Schools', cols="subset", data=[
["id", "city", "A", "B", "C", "D", "E"],
[1, "New York", 8, 1.0, "x", "True", 'True'],
[2, "Colombia", 8, 1.0, "x", "False", '-' ],
[3, "New York", 8, 1.0, "x", "True", 'True'],
[4, "", 0, 1.0, "x", "False", '-' ],
])
#----------------------------------------------------------------------
def test_useraction_failures(self):
# Verify that when a useraction fails, we revert any changes already applied.
self.load_sample(self.sample)
# Simple failure: bad action (last argument should be a dict). It shouldn't cause any actions
# in the first place, just raise an exception about the argument being an int.
with self.assertRaisesRegexp(AttributeError, r"'int'"):
self.apply_user_action(['AddColumn', 'Address', "A", 17])
# Do some successful actions, just to make sure we know what they look like.
self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (
['AddColumn', 'Address', "B", {"isFormula": True}],
['UpdateRecord', 'Address', 11, {"city": "New York2"}],
)])
# More complicated: here some actions should succeed, but get reverted when a later one fails.
with self.assertRaisesRegexp(AttributeError, r"'int'"):
self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (
['UpdateRecord', 'Address', 11, {"city": "New York3"}],
['AddColumn', 'Address', "C", {"isFormula": True}],
['AddColumn', 'Address', "D", 17]
)])
with self.assertRaisesRegexp(Exception, r"non-existent record #77"):
self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (
['UpdateRecord', 'Address', 11, {"city": "New York4"}],
['UpdateRecord', 'Address', 77, {"city": "Chicago"}],
)])
# Make sure that no columns got added except the intentionally successful one.
self.assertTableData('_grist_Tables_column', cols="subset", data=[
["id", "colId", "type", "isFormula", "formula"],
[21, "city", "Text", False, ""],
[22, "B", "Any", True, ""],
], rows=lambda r: r.parentId.id == 1)
# Make sure that no columns got added here either, and the only change to "New York" is the
# one in the successful user-action.
self.assertTableData('Address', cols="all", data=[
["id", "city" , "B" ],
[11, "New York2" , None ],
[12, "Colombia" , None ],
[13, "New Haven" , None ],
[14, "West Haven", None ],
])
#----------------------------------------------------------------------
def test_acl_principal_actions(self):
# Test the AddUser, RemoveUser, AddInstance and RemoveInstance actions.
self.load_sample(self.sample)
# Add two users
out_actions = self.apply_user_action(['AddUser', 'jake@grist.com', 'Jake', ['i001', 'i002']])
self.assertPartialOutActions(out_actions, { "stored": [
["AddRecord", "_grist_ACLPrincipals", 1, {
"type": "user",
"userEmail": "jake@grist.com",
"userName": "Jake"
}],
["BulkAddRecord", "_grist_ACLPrincipals", [2, 3], {
"instanceId": ["i001", "i002"],
"type": ["instance", "instance"]
}],
["BulkAddRecord", "_grist_ACLMemberships", [1, 2], {
"child": [2, 3],
"parent": [1, 1]
}]
]})
out_actions = self.apply_user_action(['AddUser', 'steve@grist.com', 'Steve', ['i003']])
self.assertPartialOutActions(out_actions, { "stored": [
["AddRecord", "_grist_ACLPrincipals", 4, {
"type": "user",
"userEmail": "steve@grist.com",
"userName": "Steve"
}],
["AddRecord", "_grist_ACLPrincipals", 5, {
"instanceId": "i003",
"type": "instance"
}],
["AddRecord", "_grist_ACLMemberships", 3, {
"child": 5,
"parent": 4
}]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"],
[1, "user", "jake@grist.com", "Jake", "", ""],
[2, "instance", "", "", "", "i001"],
[3, "instance", "", "", "", "i002"],
[4, "user", "steve@grist.com", "Steve", "", ""],
[5, "instance", "", "", "", "i003"],
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"],
[1, 1, 2],
[2, 1, 3],
[3, 4, 5]
])
# Add an instance to a non-existent user
with self.assertRaisesRegexp(ValueError, "Cannot find existing user with email null@grist.com"):
self.apply_user_action(['AddInstance', 'null@grist.com', 'i003'])
# Add an instance to an existing user
out_actions = self.apply_user_action(['AddInstance', 'jake@grist.com', 'i004'])
self.assertPartialOutActions(out_actions, { "stored": [
["AddRecord", "_grist_ACLPrincipals", 6, {
"instanceId": "i004",
"type": "instance"
}],
["AddRecord", "_grist_ACLMemberships", 4, {
"child": 6,
"parent": 1
}]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"],
[1, "user", "jake@grist.com", "Jake", "", ""],
[2, "instance", "", "", "", "i001"],
[3, "instance", "", "", "", "i002"],
[4, "user", "steve@grist.com", "Steve", "", ""],
[5, "instance", "", "", "", "i003"],
[6, "instance", "", "", "", "i004"],
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"],
[1, 1, 2],
[2, 1, 3],
[3, 4, 5],
[4, 1, 6]
])
# Remove a non-existent instance from a user
with self.assertRaisesRegexp(ValueError, "Cannot find existing instance id i000"):
self.apply_user_action(['RemoveInstance', 'i000'])
# Remove an instance from a user
out_actions = self.apply_user_action(['RemoveInstance', 'i002'])
self.assertPartialOutActions(out_actions, { "stored": [
["RemoveRecord", "_grist_ACLMemberships", 2],
["RemoveRecord", "_grist_ACLPrincipals", 3]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"],
[1, "user", "jake@grist.com", "Jake", "", ""],
[2, "instance", "", "", "", "i001"],
[4, "user", "steve@grist.com", "Steve", "", ""],
[5, "instance", "", "", "", "i003"],
[6, "instance", "", "", "", "i004"],
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"],
[1, 1, 2],
[3, 4, 5],
[4, 1, 6]
])
# Remove a non-existent user
with self.assertRaisesRegexp(ValueError, "Cannot find existing user with email null@grist.com"):
self.apply_user_action(['RemoveUser', 'null@grist.com'])
# Remove an existing user
out_actions = self.apply_user_action(['RemoveUser', 'jake@grist.com'])
self.assertPartialOutActions(out_actions, { "stored": [
["BulkRemoveRecord", "_grist_ACLMemberships", [1, 4]],
["BulkRemoveRecord", "_grist_ACLPrincipals", [2, 6, 1]]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"],
[4, "user", "steve@grist.com", "Steve", "", ""],
[5, "instance", "", "", "", "i003"],
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"],
[3, 4, 5]
])
# Remove the only instance of an existing user, removing that user
out_actions = self.apply_user_action(['RemoveInstance', 'i003'])
self.assertPartialOutActions(out_actions, { "stored": [
["RemoveRecord", "_grist_ACLMemberships", 3],
["BulkRemoveRecord", "_grist_ACLPrincipals", [4, 5]]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"]
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"]
])
#----------------------------------------------------------------------
def test_pages_remove(self):
# Test that orphan pages get fixed after removing a page
self.init_views_sample()
# Moves page 2 to children of page 1.
self.apply_user_action(['BulkUpdateRecord', '_grist_Pages', [2], {'indentation': [1]}])
self.assertTableData('_grist_Pages', cols='subset', data=[
['id', 'indentation'],
[ 1, 0],
[ 2, 1],
[ 3, 0],
[ 4, 0],
])
# Verify that removing page 1 fixes page 2 indentation.
self.apply_user_action(['RemoveRecord', '_grist_Pages', 1])
self.assertTableData('_grist_Pages', cols='subset', data=[
['id', 'indentation'],
[ 2, 0],
[ 3, 0],
[ 4, 0],
])
# Removing last page should not fail
# Verify that removing page 1 fixes page 2 indentation.
self.apply_user_action(['RemoveRecord', '_grist_Pages', 4])
self.assertTableData('_grist_Pages', cols='subset', data=[
['id', 'indentation'],
[ 2, 0],
[ 3, 0],
])
# Removing a page that has no children should do nothing
self.apply_user_action(['RemoveRecord', '_grist_Pages', 2])
self.assertTableData('_grist_Pages', cols='subset', data=[
['id', 'indentation'],
[ 3, 0],
])