mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add PEEK() function to bypass circular dependencies
Summary: Adds a Python function `PEEK()` for use in formulas which temporarily sets a new attribute `Engine._peeking` which disables the `_use_node` method, preventing dependency tracking and allowing the given expression to use outdated values. This allows circumventing circular reference errors. It's particularly meant for trigger formulas although it works in normal formulas as well. The expression is wrapped in a `lambda` by `codebuilder` for lazy evaluation. Discussion: https://grist.slack.com/archives/C0234CPPXPA/p1653571024031359 Test Plan: Added a Python unit test for circular trigger formulas using PEEK. Reviewers: dsagal Reviewed By: dsagal Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D3453
This commit is contained in:
parent
f837f43e55
commit
c5ebd7db3d
@ -19,6 +19,7 @@ LAZY_ARG_FUNCTIONS = {
|
|||||||
'ISERR': slice(0, 1),
|
'ISERR': slice(0, 1),
|
||||||
'ISERROR': slice(0, 1),
|
'ISERROR': slice(0, 1),
|
||||||
'IFERROR': slice(0, 1),
|
'IFERROR': slice(0, 1),
|
||||||
|
'PEEK': slice(0, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
def make_formula_body(formula, default_value, assoc_value=None):
|
def make_formula_body(formula, default_value, assoc_value=None):
|
||||||
|
@ -177,6 +177,9 @@ class Engine(object):
|
|||||||
# on any of these cells implies a circular dependency.
|
# on any of these cells implies a circular dependency.
|
||||||
self._locked_cells = set()
|
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.
|
# The lists of actions of different kinds, built up while applying an action.
|
||||||
self.out_actions = action_obj.ActionGroup()
|
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
|
# This is used whenever a formula accesses any part of any record. It's hot code, and
|
||||||
# it's worth optimizing.
|
# it's worth optimizing.
|
||||||
|
|
||||||
|
if self._peeking:
|
||||||
|
return
|
||||||
|
|
||||||
if self._current_node:
|
if self._current_node:
|
||||||
# Add an edge to indicate that the node being computed depends on the node passed in.
|
# 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
|
# Note that during evaluation, we only *add* dependencies. We *remove* them by clearing them
|
||||||
|
@ -10,6 +10,7 @@ import re
|
|||||||
import six
|
import six
|
||||||
|
|
||||||
import column
|
import column
|
||||||
|
import docmodel
|
||||||
from functions import date # pylint: disable=import-error
|
from functions import date # pylint: disable=import-error
|
||||||
from functions.unimplemented import unimplemented
|
from functions.unimplemented import unimplemented
|
||||||
from objtypes import CellError
|
from objtypes import CellError
|
||||||
@ -534,6 +535,26 @@ def CELL(info_type, reference):
|
|||||||
raise NotImplementedError()
|
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):
|
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,
|
Returns a Python dictionary with all fields in the given record. If a list of records is given,
|
||||||
|
@ -701,3 +701,70 @@ else:
|
|||||||
[ 2, 2, circle, circle],
|
[ 2, 2, circle, circle],
|
||||||
[ 3, 3, 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],
|
||||||
|
])
|
||||||
|
Loading…
Reference in New Issue
Block a user