mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
ec023a3ba6
Summary: An incorrect DocAction (as possible from an Undo of a non-last action) could cause RemoveRecord on an already missing record. This used to create an invalid undo, and wreak havoc when a series of DocActions later fails and needs to be reverted. To fix, consider RemoveRecord of a missing record to be a no-op. Test Plan: Includes a new test case that triggers the problem. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2717
262 lines
11 KiB
Python
262 lines
11 KiB
Python
import actions
|
|
import schema
|
|
import logger
|
|
from objtypes import strict_equal
|
|
|
|
log = logger.Logger(__name__, logger.INFO)
|
|
|
|
class DocActions(object):
|
|
def __init__(self, engine):
|
|
self._engine = engine
|
|
|
|
#----------------------------------------
|
|
# Actions on records.
|
|
#----------------------------------------
|
|
|
|
def AddRecord(self, table_id, row_id, column_values):
|
|
self.BulkAddRecord(
|
|
table_id, [row_id], {key: [val] for key, val in column_values.iteritems()})
|
|
|
|
def BulkAddRecord(self, table_id, row_ids, column_values):
|
|
table = self._engine.tables[table_id]
|
|
for row_id in row_ids:
|
|
assert row_id not in table.row_ids, \
|
|
"docactions.[Bulk]AddRecord for existing record #%s" % row_id
|
|
|
|
self._engine.out_actions.undo.append(actions.BulkRemoveRecord(table_id, row_ids).simplify())
|
|
self._engine.out_actions.summary.add_records(table_id, row_ids)
|
|
|
|
self._engine.add_records(table_id, row_ids, column_values)
|
|
|
|
def RemoveRecord(self, table_id, row_id):
|
|
return self.BulkRemoveRecord(table_id, [row_id])
|
|
|
|
def BulkRemoveRecord(self, table_id, row_ids):
|
|
table = self._engine.tables[table_id]
|
|
|
|
# Ignore records that don't exist in the table.
|
|
row_ids = [r for r in row_ids if r in table.row_ids]
|
|
if not row_ids:
|
|
return
|
|
|
|
# Collect the undo values, and unset all values in the column (i.e. set to defaults), just to
|
|
# make sure we don't have stale values hanging around.
|
|
undo_values = {}
|
|
for column in table.all_columns.itervalues():
|
|
if not column.is_private() and column.col_id != "id":
|
|
col_values = map(column.raw_get, row_ids)
|
|
default = column.getdefault()
|
|
# If this column had all default values, don't include it into the undo BulkAddRecord.
|
|
if not all(strict_equal(val, default) for val in col_values):
|
|
undo_values[column.col_id] = col_values
|
|
for row_id in row_ids:
|
|
column.unset(row_id)
|
|
|
|
# Generate the undo action.
|
|
self._engine.out_actions.undo.append(
|
|
actions.BulkAddRecord(table_id, row_ids, undo_values).simplify())
|
|
self._engine.out_actions.summary.remove_records(table_id, row_ids)
|
|
|
|
# Invalidate the deleted rows, so that anything that depends on them gets recomputed.
|
|
self._engine.invalidate_records(table_id, row_ids)
|
|
|
|
def UpdateRecord(self, table_id, row_id, columns):
|
|
self.BulkUpdateRecord(
|
|
table_id, [row_id], {key: [val] for key, val in columns.iteritems()})
|
|
|
|
def BulkUpdateRecord(self, table_id, row_ids, columns):
|
|
table = self._engine.tables[table_id]
|
|
for row_id in row_ids:
|
|
assert row_id in table.row_ids, \
|
|
"docactions.[Bulk]UpdateRecord for non-existent record #%s" % row_id
|
|
|
|
# Load the updated values.
|
|
undo_values = {}
|
|
for col_id, values in columns.iteritems():
|
|
col = table.get_column(col_id)
|
|
undo_values[col_id] = map(col.raw_get, row_ids)
|
|
for (row_id, value) in zip(row_ids, values):
|
|
col.set(row_id, value)
|
|
|
|
# Generate the undo action.
|
|
self._engine.out_actions.undo.append(
|
|
actions.BulkUpdateRecord(table_id, row_ids, undo_values).simplify())
|
|
|
|
# Invalidate the updated rows, just for the columns that got changed (and, as always,
|
|
# anything that depends on them).
|
|
self._engine.invalidate_records(table_id, row_ids, col_ids=columns.keys())
|
|
|
|
|
|
def ReplaceTableData(self, table_id, row_ids, column_values):
|
|
old_data = self._engine.fetch_table(table_id, formulas=False)
|
|
self._engine.out_actions.undo.append(actions.ReplaceTableData(*old_data))
|
|
self._engine.out_actions.summary.remove_records(table_id, old_data[1])
|
|
self._engine.out_actions.summary.add_records(table_id, row_ids)
|
|
self._engine.load_table(actions.TableData(table_id, row_ids, column_values))
|
|
|
|
#----------------------------------------
|
|
# Actions on columns.
|
|
#----------------------------------------
|
|
|
|
def AddColumn(self, table_id, col_id, col_info):
|
|
table = self._engine.tables[table_id]
|
|
assert not table.has_column(col_id), "Column %s already exists in %s" % (col_id, table_id)
|
|
|
|
# Add the new column to the schema object maintained in the engine.
|
|
self._engine.schema[table_id].columns[col_id] = schema.dict_to_col(col_info, col_id=col_id)
|
|
self._engine.rebuild_usercode()
|
|
self._engine.new_column_name(table)
|
|
|
|
# Generate the undo action.
|
|
self._engine.out_actions.undo.append(actions.RemoveColumn(table_id, col_id))
|
|
self._engine.out_actions.summary.add_column(table_id, col_id)
|
|
|
|
def RemoveColumn(self, table_id, col_id):
|
|
table = self._engine.tables[table_id]
|
|
assert table.has_column(col_id), "Column %s not in table %s" % (col_id, table_id)
|
|
|
|
# Generate (if needed) the undo action to restore the data.
|
|
undo_action = None
|
|
column = table.get_column(col_id)
|
|
if not column.is_private():
|
|
default = column.getdefault()
|
|
# Add to undo a BulkUpdateRecord for non-default values in the column being removed.
|
|
undo_values = [(r, column.raw_get(r)) for r in table.row_ids
|
|
if not strict_equal(column.raw_get(r), default)]
|
|
|
|
# Remove the specified column from the schema object.
|
|
colinfo = self._engine.schema[table_id].columns.pop(col_id)
|
|
self._engine.rebuild_usercode()
|
|
|
|
# Generate the undo action(s); if for a formula column, add them to the calc summary.
|
|
if undo_values:
|
|
if column.is_formula():
|
|
changes = [(r, v, default) for (r, v) in undo_values]
|
|
self._engine.out_actions.summary.add_changes(table_id, col_id, changes)
|
|
else:
|
|
row_ids = [r for (r, v) in undo_values]
|
|
values = [v for (r, v) in undo_values]
|
|
undo_action = actions.BulkUpdateRecord(table_id, row_ids, {col_id: values}).simplify()
|
|
self._engine.out_actions.undo.append(undo_action)
|
|
|
|
self._engine.out_actions.undo.append(actions.AddColumn(
|
|
table_id, col_id, schema.col_to_dict(colinfo, include_id=False)))
|
|
self._engine.out_actions.summary.remove_column(table_id, col_id)
|
|
|
|
def RenameColumn(self, table_id, old_col_id, new_col_id):
|
|
table = self._engine.tables[table_id]
|
|
|
|
assert table.has_column(old_col_id), "Column %s not in table %s" % (old_col_id, table_id)
|
|
assert not table.has_column(new_col_id), \
|
|
"Column %s already exists in %s" % (new_col_id, table_id)
|
|
old_column = table.get_column(old_col_id)
|
|
|
|
# Replace the renamed column in the schema object.
|
|
schema_table_info = self._engine.schema[table_id]
|
|
colinfo = schema_table_info.columns.pop(old_col_id)
|
|
schema_table_info.columns[new_col_id] = schema.SchemaColumn(
|
|
new_col_id, colinfo.type, colinfo.isFormula, colinfo.formula)
|
|
|
|
self._engine.rebuild_usercode()
|
|
self._engine.new_column_name(table)
|
|
|
|
# We replaced the old column with a new Column object (not strictly necessary, but simpler).
|
|
# For a raw data column, we need to copy over the data from the old column object.
|
|
new_column = table.get_column(new_col_id)
|
|
new_column.copy_from_column(old_column)
|
|
|
|
# Generate the undo action.
|
|
self._engine.out_actions.undo.append(actions.RenameColumn(table_id, new_col_id, old_col_id))
|
|
self._engine.out_actions.summary.rename_column(table_id, old_col_id, new_col_id)
|
|
|
|
def ModifyColumn(self, table_id, col_id, col_info):
|
|
table = self._engine.tables[table_id]
|
|
assert table.has_column(col_id), "Column %s not in table %s" % (col_id, table_id)
|
|
old_column = table.get_column(col_id)
|
|
|
|
# Modify the specified column in the schema object.
|
|
schema_table_info = self._engine.schema[table_id]
|
|
old = schema_table_info.columns[col_id]
|
|
new = schema.SchemaColumn(col_id,
|
|
col_info.get('type', old.type),
|
|
bool(col_info.get('isFormula', old.isFormula)),
|
|
col_info.get('formula', old.formula))
|
|
if new == old:
|
|
log.info("ModifyColumn called which was a noop")
|
|
return
|
|
|
|
undo_col_info = {k: v for k, v in schema.col_to_dict(old, include_id=False).iteritems()
|
|
if k in col_info}
|
|
|
|
# Remove the column from the schema, then re-add it, to force creation of a new column object.
|
|
schema_table_info.columns.pop(col_id)
|
|
self._engine.rebuild_usercode()
|
|
|
|
schema_table_info.columns[col_id] = new
|
|
self._engine.rebuild_usercode()
|
|
|
|
# Fill in the new column with the values from the old column.
|
|
new_column = table.get_column(col_id)
|
|
for row_id in table.row_ids:
|
|
new_column.set(row_id, old_column.raw_get(row_id))
|
|
|
|
# Generate the undo action.
|
|
self._engine.out_actions.undo.append(actions.ModifyColumn(table_id, col_id, undo_col_info))
|
|
|
|
#----------------------------------------
|
|
# Actions on tables.
|
|
#----------------------------------------
|
|
|
|
def AddTable(self, table_id, columns):
|
|
assert table_id not in self._engine.tables, "Table %s already exists" % table_id
|
|
|
|
# Update schema, and re-generate the module code.
|
|
self._engine.schema[table_id] = schema.SchemaTable(table_id, schema.dict_list_to_cols(columns))
|
|
self._engine.rebuild_usercode()
|
|
|
|
# Generate the undo action.
|
|
self._engine.out_actions.undo.append(actions.RemoveTable(table_id))
|
|
self._engine.out_actions.summary.add_table(table_id)
|
|
|
|
def RemoveTable(self, table_id):
|
|
assert table_id in self._engine.tables, "Table %s doesn't exist" % table_id
|
|
|
|
# Create undo actions to restore all the data records of this table.
|
|
table_data = self._engine.fetch_table(table_id, formulas=True)
|
|
undo_action = actions.BulkAddRecord(*table_data).simplify()
|
|
if undo_action:
|
|
self._engine.out_actions.undo.append(undo_action)
|
|
|
|
# Update schema, and re-generate the module code.
|
|
schema_table = self._engine.schema.pop(table_id)
|
|
self._engine.rebuild_usercode()
|
|
|
|
# Generate the undo action.
|
|
self._engine.out_actions.undo.append(actions.AddTable(
|
|
table_id, schema.cols_to_dict_list(schema_table.columns)))
|
|
self._engine.out_actions.summary.remove_table(table_id)
|
|
|
|
def RenameTable(self, old_table_id, new_table_id):
|
|
assert old_table_id in self._engine.tables, "Table %s doesn't exist" % old_table_id
|
|
assert new_table_id not in self._engine.tables, "Table %s already exists" % new_table_id
|
|
|
|
old_table = self._engine.tables[old_table_id]
|
|
|
|
# Update schema, and re-generate the module code.
|
|
old = self._engine.schema.pop(old_table_id)
|
|
self._engine.schema[new_table_id] = schema.SchemaTable(new_table_id, old.columns)
|
|
self._engine.rebuild_usercode()
|
|
|
|
# Copy over all columns from the old table to the new.
|
|
new_table = self._engine.tables[new_table_id]
|
|
for new_column in new_table.all_columns.itervalues():
|
|
if not new_column.is_private():
|
|
new_column.copy_from_column(old_table.get_column(new_column.col_id))
|
|
new_table.grow_to_max() # We need to bring formula columns to the right size too.
|
|
|
|
# Generate the undo action.
|
|
self._engine.out_actions.undo.append(actions.RenameTable(new_table_id, old_table_id))
|
|
self._engine.out_actions.summary.rename_table(old_table_id, new_table_id)
|
|
|
|
# end
|