mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Fix undo error for automatically removed rows, especially in summary tables
Summary: Fixes a bug noted here: https://grist.slack.com/archives/C069RUP71/p1662564341132349 This bug could happen quite easily as follows: 1. Have a formula in a summary table such as `$group.amount`. Typically there's also a `SUM` but that's not essential. 2. Find a group with nonzero values of `amount`. 3. Delete all rows in that group in the source table. Typically that just means one row in a lonely group. 4. The summary table row is automatically deleted. 5. Try to undo. This raises an error about trying to update a non-existent summary table row. I tried to account for this undo problem in https://phab.getgrist.com/D3489 by not saving the updated value for `$group` when it was found to be empty. The reason this was insufficient is that `$group.amount` is immediately invalidated anyway when the source row(s) are deleted (I think because that's just how dependency relations involving references work) *and* the calculated value of `$group.amount` changes even if `$group` doesn't. For example, `$group.amount` may have previously been `[100, 200]`. After deleting the rows, `$group.amount` becomes `[0, 0]`. Keeping `$group` unchanged prevents `$group.amount` from just being `[]`, but deleting the source rows means that the amounts become the numeric default `0` which is still a change. This change in value is then noted which leads to saving an undo action to update the summary table record. All this happens in step 3 above, and the summary record is only deleted after that point. This diff removes that special handling for `group` and instead adds a more general fix to `action_summary.py`. This inserts undo actions for deleted rows at the beginning of the undo list rather than at the end, which was already done for deleted tables and columns. Test Plan: Python tests Reviewers: dsagal Reviewed By: dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3626
This commit is contained in:
parent
c9a81ea7ea
commit
56624c4a95
@ -85,27 +85,37 @@ class ActionSummary(object):
|
|||||||
table_id = root_name(table_id)
|
table_id = root_name(table_id)
|
||||||
col_id = root_name(col_id)
|
col_id = root_name(col_id)
|
||||||
|
|
||||||
|
def update_action(filtered_row_ids, delta_index):
|
||||||
|
values = [column_delta[r][delta_index] for r in filtered_row_ids]
|
||||||
|
return actions.BulkUpdateRecord(table_id, filtered_row_ids, {col_id: values}).simplify()
|
||||||
|
|
||||||
if not defunct:
|
if not defunct:
|
||||||
row_ids = self.filter_out_gone_rows(table_id, full_row_ids)
|
row_ids_after = self.filter_out_gone_rows(table_id, full_row_ids)
|
||||||
if row_ids:
|
if row_ids_after:
|
||||||
values = [column_delta[r][1] for r in row_ids]
|
out_stored.append(update_action(row_ids_after, 1))
|
||||||
out_stored.append(actions.BulkUpdateRecord(table_id, row_ids, {col_id: values}).simplify())
|
|
||||||
|
|
||||||
if self.is_created(table_id, col_id) and not defunct:
|
if self.is_created(table_id, col_id) and not defunct:
|
||||||
# A newly-create column, and not replacing a defunct one. Don't generate undo actions.
|
# A newly-created column, and not replacing a defunct one. Don't generate undo actions.
|
||||||
pass
|
return
|
||||||
else:
|
|
||||||
row_ids = self.filter_out_new_rows(table_id, full_row_ids)
|
|
||||||
if row_ids:
|
|
||||||
values = [column_delta[r][0] for r in row_ids]
|
|
||||||
undo_action = actions.BulkUpdateRecord(table_id, row_ids, {col_id: values}).simplify()
|
|
||||||
if defunct:
|
|
||||||
# If we deleted the column (or its containing table), then during undo, the updates for it
|
|
||||||
# should come after it's re-added. So we need to insert the undos *before*.
|
|
||||||
out_undo.insert(0, undo_action)
|
|
||||||
else:
|
|
||||||
out_undo.append(undo_action)
|
|
||||||
|
|
||||||
|
## Maybe add one or two undo update actions for rows that existed before the change.
|
||||||
|
row_ids_before = self.filter_out_new_rows(table_id, full_row_ids)
|
||||||
|
|
||||||
|
if defunct:
|
||||||
|
preserved_row_ids = []
|
||||||
|
else:
|
||||||
|
preserved_row_ids = self.filter_out_gone_rows(table_id, row_ids_before)
|
||||||
|
|
||||||
|
preserved_row_ids_set = set(preserved_row_ids)
|
||||||
|
defunct_row_ids = [r for r in row_ids_before if r not in preserved_row_ids_set]
|
||||||
|
|
||||||
|
if preserved_row_ids:
|
||||||
|
out_undo.append(update_action(preserved_row_ids, 0))
|
||||||
|
|
||||||
|
if defunct_row_ids:
|
||||||
|
# Updates for deleted rows/columns/tables should come after they're re-added.
|
||||||
|
# So we need to insert the undos *before*.
|
||||||
|
out_undo.insert(0, update_action(defunct_row_ids, 0))
|
||||||
|
|
||||||
def _forTable(self, table_id):
|
def _forTable(self, table_id):
|
||||||
return self._tables.get(table_id) or self._tables.setdefault(table_id, TableDelta())
|
return self._tables.get(table_id) or self._tables.setdefault(table_id, TableDelta())
|
||||||
|
@ -826,11 +826,6 @@ class Engine(object):
|
|||||||
# know, so it can set the cell value appropriately and do some other bookkeeping.
|
# know, so it can set the cell value appropriately and do some other bookkeeping.
|
||||||
cycle = required and (node, row_id) in self._locked_cells
|
cycle = required and (node, row_id) in self._locked_cells
|
||||||
value = self._recompute_one_cell(table, col, row_id, cycle=cycle, node=node)
|
value = self._recompute_one_cell(table, col, row_id, cycle=cycle, node=node)
|
||||||
except table_module.EmptySummaryRow:
|
|
||||||
# This record is going to be deleted after the update loop completes.
|
|
||||||
# Don't save a value for it because that will lead to broken undo actions
|
|
||||||
# trying to update a record that doesn't exist.
|
|
||||||
save_value = False
|
|
||||||
except RequestingError:
|
except RequestingError:
|
||||||
# The formula will be evaluated again soon when we have a response.
|
# The formula will be evaluated again soon when we have a response.
|
||||||
save_value = False
|
save_value = False
|
||||||
@ -956,9 +951,8 @@ class Engine(object):
|
|||||||
raise self._cell_required_error # pylint: disable=raising-bad-type
|
raise self._cell_required_error # pylint: disable=raising-bad-type
|
||||||
self.formula_tracer(col, record)
|
self.formula_tracer(col, record)
|
||||||
return result
|
return result
|
||||||
except (MemoryError, table_module.EmptySummaryRow):
|
except MemoryError:
|
||||||
# Don't try to wrap memory errors.
|
# Don't try to wrap memory errors.
|
||||||
# EmptySummaryRow should be handled in _recompute_step
|
|
||||||
raise
|
raise
|
||||||
except: # pylint: disable=bare-except
|
except: # pylint: disable=bare-except
|
||||||
# Since col.method runs untrusted user code, we use a bare except to catch all
|
# Since col.method runs untrusted user code, we use a bare except to catch all
|
||||||
|
@ -563,10 +563,6 @@ class Table(object):
|
|||||||
|
|
||||||
# Remove rows with empty groups
|
# Remove rows with empty groups
|
||||||
self._engine.docmodel.setAutoRemove(rec, not result)
|
self._engine.docmodel.setAutoRemove(rec, not result)
|
||||||
if not result:
|
|
||||||
# The group is empty, tell the engine that this record will be deleted
|
|
||||||
raise EmptySummaryRow()
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
@ -662,10 +658,3 @@ class Table(object):
|
|||||||
# creates a dependency and brings formula columns up-to-date.
|
# creates a dependency and brings formula columns up-to-date.
|
||||||
self._engine._use_node(col.node, relation, row_ids)
|
self._engine._use_node(col.node, relation, row_ids)
|
||||||
return [col.get_cell_value(row_id) for row_id in row_ids]
|
return [col.get_cell_value(row_id) for row_id in row_ids]
|
||||||
|
|
||||||
|
|
||||||
class EmptySummaryRow(Exception):
|
|
||||||
"""
|
|
||||||
Special exception indicating that the summary group is empty and the row should be removed.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
@ -280,6 +280,24 @@ class TestDerived(test_engine.EngineTestCase):
|
|||||||
[5, 2007, 1, 15.0, [1]],
|
[5, 2007, 1, 15.0, [1]],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
self.assertPartialOutActions(out_actions_update, {
|
||||||
|
'stored': [
|
||||||
|
['UpdateRecord', 'Orders', 1, {'year': 2007}],
|
||||||
|
['AddRecord', 'Orders_summary_year', 5, {'year': 2007}],
|
||||||
|
['RemoveRecord', 'Orders_summary_year', 1],
|
||||||
|
['UpdateRecord', 'Orders_summary_year', 5, {'amount': 15.0}],
|
||||||
|
['UpdateRecord', 'Orders_summary_year', 5, {'count': 1}],
|
||||||
|
['UpdateRecord', 'Orders_summary_year', 5, {'group': ['L', 1]}],
|
||||||
|
],
|
||||||
|
'undo': [
|
||||||
|
['UpdateRecord', 'Orders_summary_year', 1, {'group': ['L', 1]}],
|
||||||
|
['UpdateRecord', 'Orders_summary_year', 1, {'count': 1}],
|
||||||
|
['UpdateRecord', 'Orders_summary_year', 1, {'amount': 15.0}],
|
||||||
|
['UpdateRecord', 'Orders', 1, {'year': 2012}],
|
||||||
|
['RemoveRecord', 'Orders_summary_year', 5],
|
||||||
|
['AddRecord', 'Orders_summary_year', 1, {'group': ['L'], 'year': 2012}],
|
||||||
|
]})
|
||||||
|
|
||||||
# Undo and ensure that the new line is gone from the summary table.
|
# Undo and ensure that the new line is gone from the summary table.
|
||||||
out_actions_undo = self.apply_undo_actions(out_actions_update.undo)
|
out_actions_undo = self.apply_undo_actions(out_actions_update.undo)
|
||||||
self.assertPartialData("Orders_summary_year", ["id", "year", "count", "amount", "group" ], [
|
self.assertPartialData("Orders_summary_year", ["id", "year", "count", "amount", "group" ], [
|
||||||
@ -289,13 +307,7 @@ class TestDerived(test_engine.EngineTestCase):
|
|||||||
[4, 2015, 4, 106.0, [7,8,9,10]],
|
[4, 2015, 4, 106.0, [7,8,9,10]],
|
||||||
])
|
])
|
||||||
self.assertPartialOutActions(out_actions_undo, {
|
self.assertPartialOutActions(out_actions_undo, {
|
||||||
"stored": [
|
"stored": out_actions_update.undo[::-1],
|
||||||
actions.AddRecord("Orders_summary_year", 1, {
|
|
||||||
"amount": 15.0, "count": 1, "group": [1], "year": 2012
|
|
||||||
}),
|
|
||||||
actions.RemoveRecord("Orders_summary_year", 5),
|
|
||||||
actions.UpdateRecord("Orders", 1, {"year": 2012}),
|
|
||||||
],
|
|
||||||
"calls": {
|
"calls": {
|
||||||
"Orders_summary_year": {
|
"Orders_summary_year": {
|
||||||
"#lookup#": 1, "#lookup#year": 1, "group": 1, "amount": 1, "count": 1
|
"#lookup#": 1, "#lookup#year": 1, "group": 1, "amount": 1, "count": 1
|
||||||
|
@ -388,6 +388,8 @@ def test_undo(test_method):
|
|||||||
self.assertEqualDocData(self.getFullEngineData(), expected_engine_data)
|
self.assertEqualDocData(self.getFullEngineData(), expected_engine_data)
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
|
test_undo.__test__ = False # tells pytest that this isn't a test
|
||||||
|
|
||||||
|
|
||||||
class TestEngine(EngineTestCase):
|
class TestEngine(EngineTestCase):
|
||||||
samples = {}
|
samples = {}
|
||||||
|
@ -503,6 +503,28 @@ class Address:
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Add a new source record which creates a new summary row,
|
||||||
|
# then delete the source row which implicitly deletes the summary row.
|
||||||
|
# Overall this is a no-op, but it tests a specific undo-related bugfix.
|
||||||
|
self.add_record(source_tbl_name, city="Nowhere", state="??", amount=666)
|
||||||
|
out_actions = self.remove_record(source_tbl_name, 34)
|
||||||
|
self.assertOutActions(out_actions, {
|
||||||
|
'calc': [],
|
||||||
|
'direct': [True, False],
|
||||||
|
'stored': [
|
||||||
|
['RemoveRecord', source_tbl_name, 34],
|
||||||
|
['RemoveRecord', summary_tbl_name, 12],
|
||||||
|
],
|
||||||
|
'undo': [
|
||||||
|
['UpdateRecord', summary_tbl_name, 12, {'group': ['L', 34]}],
|
||||||
|
['UpdateRecord', summary_tbl_name, 12, {'count': 1}],
|
||||||
|
['UpdateRecord', summary_tbl_name, 12, {'amount': 666.0}],
|
||||||
|
['AddRecord', source_tbl_name, 34, {'amount': 666.0, 'city': 'Nowhere', 'state': '??'}],
|
||||||
|
['AddRecord', summary_tbl_name, 12,
|
||||||
|
{'city': 'Nowhere', 'group': ['L'], 'state': '??'}],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
# Verify the resulting data after all the updates.
|
# Verify the resulting data after all the updates.
|
||||||
self.assertTableData(summary_tbl_name, cols="subset", data=[
|
self.assertTableData(summary_tbl_name, cols="subset", data=[
|
||||||
[ "id", "city", "state", "count", "amount" ],
|
[ "id", "city", "state", "count", "amount" ],
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import re
|
import re
|
||||||
import test_engine
|
import test_engine
|
||||||
import testsamples
|
import testsamples
|
||||||
|
import testutil
|
||||||
|
|
||||||
|
|
||||||
class TestUndo(test_engine.EngineTestCase):
|
class TestUndo(test_engine.EngineTestCase):
|
||||||
def test_bad_undo(self):
|
def test_bad_undo(self):
|
||||||
@ -80,3 +82,43 @@ class TestUndo(test_engine.EngineTestCase):
|
|||||||
[3, "Address", [21]],
|
[3, "Address", [21]],
|
||||||
[4, "Table1", [22, 23]],
|
[4, "Table1", [22, 23]],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@test_engine.test_undo
|
||||||
|
def test_auto_remove_undo(self):
|
||||||
|
"""
|
||||||
|
Test that a formula using docmodel.setAutoRemove doesn't break when undoing.
|
||||||
|
We don't actually recommend using docmodel.setAutoRemove in formulas,
|
||||||
|
but it'd be nice, and this is really testing that a bugfix about summary tables
|
||||||
|
also helps outside of summary tables.
|
||||||
|
"""
|
||||||
|
self.load_sample(testutil.parse_test_sample({
|
||||||
|
"SCHEMA": [
|
||||||
|
[1, "Table", [
|
||||||
|
[11, "amount", "Numeric", False, "", "", ""],
|
||||||
|
[12, "amount2", "Numeric", True, "$amount", "", ""],
|
||||||
|
[13, "remove", "Any", True,
|
||||||
|
"table.table._engine.docmodel.setAutoRemove(rec, not $amount2)", "", ""],
|
||||||
|
]]
|
||||||
|
],
|
||||||
|
"DATA": {
|
||||||
|
"Table": [
|
||||||
|
["id", "amount", "amount2"],
|
||||||
|
[21, 1, 1],
|
||||||
|
[22, 2, 2],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
out_actions = self.update_record('Table', 21, amount=0)
|
||||||
|
self.assertOutActions(out_actions, {
|
||||||
|
'calc': [],
|
||||||
|
'direct': [True, False],
|
||||||
|
'stored': [['UpdateRecord', 'Table', 21, {'amount': 0.0}],
|
||||||
|
['RemoveRecord', 'Table', 21]],
|
||||||
|
'undo': [['UpdateRecord', 'Table', 21, {'amount2': 1.0}],
|
||||||
|
['UpdateRecord', 'Table', 21, {'amount': 1.0}],
|
||||||
|
['AddRecord', 'Table', 21, {}]],
|
||||||
|
})
|
||||||
|
self.assertTableData('Table', cols="subset", data=[
|
||||||
|
["id", "amount", "amount2"],
|
||||||
|
[22, 2, 2],
|
||||||
|
])
|
||||||
|
Loading…
Reference in New Issue
Block a user