diff --git a/sandbox/grist/codebuilder.py b/sandbox/grist/codebuilder.py index bda112bf..43b736b1 100644 --- a/sandbox/grist/codebuilder.py +++ b/sandbox/grist/codebuilder.py @@ -19,6 +19,7 @@ LAZY_ARG_FUNCTIONS = { 'ISERR': slice(0, 1), 'ISERROR': slice(0, 1), 'IFERROR': slice(0, 1), + 'PEEK': slice(0, 1), } def make_formula_body(formula, default_value, assoc_value=None): diff --git a/sandbox/grist/engine.py b/sandbox/grist/engine.py index e8efd599..3b05bd1b 100644 --- a/sandbox/grist/engine.py +++ b/sandbox/grist/engine.py @@ -177,6 +177,9 @@ class Engine(object): # on any of these cells implies a circular dependency. self._locked_cells = set() + # Set to True by the PEEK() function to temporarily disable dependency tracking + self._peeking = False + # The lists of actions of different kinds, built up while applying an action. self.out_actions = action_obj.ActionGroup() @@ -474,6 +477,9 @@ class Engine(object): # This is used whenever a formula accesses any part of any record. It's hot code, and # it's worth optimizing. + if self._peeking: + return + if self._current_node: # Add an edge to indicate that the node being computed depends on the node passed in. # Note that during evaluation, we only *add* dependencies. We *remove* them by clearing them diff --git a/sandbox/grist/functions/info.py b/sandbox/grist/functions/info.py index 979beb69..7b3b9edb 100644 --- a/sandbox/grist/functions/info.py +++ b/sandbox/grist/functions/info.py @@ -10,6 +10,7 @@ import re import six import column +import docmodel from functions import date # pylint: disable=import-error from functions.unimplemented import unimplemented from objtypes import CellError @@ -534,6 +535,26 @@ def CELL(info_type, reference): raise NotImplementedError() +def PEEK(func): + """ + Evaluates the given expression without creating dependencies + or requiring that referenced values are up to date, using whatever value it finds in a cell. + This is useful for preventing circular reference errors, particularly in trigger formulas. + + For example, if the formula for `A` depends on `$B` and the formula for `B` depends on `$A`, + then normally this would raise a circular reference error because each value needs to be + calculated before the other. But if `A` uses `PEEK($B)` then it will simply get the value + already stored in `$B` without requiring that `$B` is first calculated to the latest value. + Therefore `A` will be calculated first, and `B` can use `$A` without problems. + """ + engine = docmodel.global_docmodel._engine + engine._peeking = True + try: + return func() + finally: + engine._peeking = False + + def RECORD(record_or_list, dates_as_iso=False, expand_refs=0): """ Returns a Python dictionary with all fields in the given record. If a list of records is given, diff --git a/sandbox/grist/test_formula_error.py b/sandbox/grist/test_formula_error.py index 2dddefaf..d6f82707 100644 --- a/sandbox/grist/test_formula_error.py +++ b/sandbox/grist/test_formula_error.py @@ -701,3 +701,70 @@ else: [ 2, 2, circle, circle], [ 3, 3, circle, circle], ]) + + def test_peek(self): + """ + Test using the PEEK function to avoid circular errors in formulas. + """ + col = testutil.col_schema_row + sample = testutil.parse_test_sample({ + "SCHEMA": [ + [1, "Table1", [ + col(31, "A", "Numeric", False, "$B + 1", recalcDeps=[31, 32]), + col(32, "B", "Numeric", False, "$A + 1", recalcDeps=[31, 32]), + ]] + ], + "DATA": { + "Table1": [ + ["id", "A", "B"], + ] + } + }) + self.load_sample(sample) + + # Normal formulas without PEEK() raise a circular error as expected. + self.add_record("Table1", A=1) + self.add_record("Table1") + error = depend.CircularRefError("Circular Reference") + self.assertTableData('Table1', data=[ + ['id', 'A', 'B'], + [1, objtypes.RaisedException(error, user_input=None), + objtypes.RaisedException(error, user_input=0)], + [2, objtypes.RaisedException(error, user_input=None), + objtypes.RaisedException(error, user_input=0)], + ]) + self.remove_record("Table1", 1) + self.remove_record("Table1", 2) + + self.modify_column("Table1", "A", formula="PEEK($B) + 1") + self.add_record("Table1", A=10) + self.add_record("Table1", B=20) + + self.modify_column("Table1", "A", formula="$B + 1") + self.modify_column("Table1", "B", formula="PEEK($A + 1)") + self.add_record("Table1", A=100) + self.add_record("Table1", B=200) + + self.assertTableData('Table1', data=[ + ['id', 'A', 'B'], + # When A peeks at B, A gets evaluated first, so it's always 1 less than B + [1, 1, 2], # Here we set A=10 but it used $B+1 where B=0 (the default value) + [2, 21, 22], + + # Now B peeks at A so B is evaluated first + [3, 102, 101], + [4, 2, 1], + ]) + + # Test updating records (instead of just adding) + self.update_record("Table1", 1, A=30) + self.update_record("Table1", 2, B=40) + self.update_record("Table1", 3, A=50, B=60) + + self.assertTableData('Table1', rows="subset", data=[ + ['id', 'A', 'B'], + # B is still peeking at A so it's always evaluated first and 1 less than A + [1, 32, 31], + [2, 23, 22], # The user input B=40 was overridden by the formula, which saw the old A=21 + [3, 52, 51], + ])