(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:
Alex Hall
2022-06-02 16:24:41 +02:00
parent f837f43e55
commit c5ebd7db3d
4 changed files with 95 additions and 0 deletions

View File

@@ -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,