You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/sandbox/grist/test_formula_error.py

899 lines
30 KiB

"""
Tests that formula error messages (traceback) are correct
"""
import textwrap
import six
import depend
import test_engine
import testutil
import objtypes
class TestErrorMessage(test_engine.EngineTestCase):
syntax_err = \
"""
if sum(3, 5) > 6:
return 6
else:
return: 0
"""
indent_err = \
"""
if sum(3, 5) > 6:
return 6
return 0
"""
other_err = \
"""
if sum(3, 5) > 6:
return 6
"""
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Math", [
[11, "excel_formula", "Text", True, "SQRT(16, 2)", "", ""],
[12, "built_in_formula", "Text", True, "max(5)", "", ""],
[13, "syntax_err", "Text", True, syntax_err, "", ""],
[14, "indent_err", "Text", True, indent_err, "", ""],
[15, "other_err", "Text", True, other_err, "", ""],
[15, "custom_err", "Text", True, "raise Exception('hello'); return 1", "", ""],
]]
],
"DATA": {
"Math": [
["id"],
[3],
]
}
})
def test_formula_errors(self):
self.load_sample(self.sample)
if six.PY2:
self.assertFormulaError(self.engine.get_formula_error('Math', 'excel_formula', 3),
TypeError, 'SQRT() takes exactly 1 argument (2 given)',
r"TypeError: SQRT\(\) takes exactly 1 argument \(2 given\)")
else:
self.assertFormulaError(
self.engine.get_formula_error('Math', 'excel_formula', 3), TypeError,
'SQRT() takes 1 positional argument but 2 were given\n\n'
'A `TypeError` is usually caused by trying\n'
'to combine two incompatible types of objects,\n'
'by calling a function with the wrong type of object,\n'
'or by trying to do an operation not allowed on a given type of object.\n\n'
'You apparently have called the function `SQRT` with\n'
'2 positional argument(s) while it requires 1\n'
'such positional argument(s).',
r"TypeError: SQRT\(\) takes 1 positional argument but 2 were given",
)
int_not_iterable_message = "'int' object is not iterable"
if six.PY3:
int_not_iterable_message += (
'\n\n'
'A `TypeError` is usually caused by trying\n'
'to combine two incompatible types of objects,\n'
'by calling a function with the wrong type of object,\n'
'or by trying to do an operation not allowed on a given type of object.\n\n'
'An iterable is an object capable of returning its members one at a time.\n'
'Python containers (`list, tuple, dict`, etc.) are iterables.\n'
'An iterable is required here.'
)
self.assertFormulaError(self.engine.get_formula_error('Math', 'built_in_formula', 3),
TypeError, int_not_iterable_message,
textwrap.dedent(
r"""
File "usercode", line \d+, in built_in_formula
return max\(5\)
TypeError: 'int' object is not iterable
"""
))
if six.PY2:
message = "invalid syntax (usercode, line 5)"
else:
message = textwrap.dedent(
"""\
invalid syntax
A `SyntaxError` occurs when Python cannot understand your code.
I am guessing that you wrote `:` by mistake.
Removing it and writing `return 0` seems to fix the error.
(usercode, line 5)""")
self.assertFormulaError(self.engine.get_formula_error('Math', 'syntax_err', 3),
SyntaxError, message,
textwrap.dedent(
r"""
File "usercode", line 5
return: 0
\^
SyntaxError: invalid syntax
"""
))
if six.PY2:
traceback_regex = textwrap.dedent(
r"""
File "usercode", line 2
if sum\(3, 5\) > 6:
\^
IndentationError: unexpected indent
"""
)
message = 'unexpected indent (usercode, line 2)'
else:
traceback_regex = textwrap.dedent(
r"""
File "usercode", line 2
if sum\(3, 5\) > 6:
IndentationError: unexpected indent
"""
)
message = textwrap.dedent(
"""\
unexpected indent
An `IndentationError` occurs when a given line of code is
not indented (aligned vertically with other lines) as expected.
Line `2` identified above is more indented than expected.
(usercode, line 2)""")
self.assertFormulaError(self.engine.get_formula_error('Math', 'indent_err', 3),
IndentationError, message, traceback_regex)
self.assertFormulaError(self.engine.get_formula_error('Math', 'other_err', 3),
TypeError, int_not_iterable_message,
textwrap.dedent(
r"""
File "usercode", line \d+, in other_err
if sum\(3, 5\) > 6:
TypeError: 'int' object is not iterable
"""
))
self.assertFormulaError(self.engine.get_formula_error('Math', 'custom_err', 3),
Exception, "hello")
def test_missing_all_attribute(self):
# Test that `Table.Col` raises a helpful AttributeError suggesting to use `Table.all.Col`.
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table", [
[11, "A", "Any", True, "Table.id", "", ""],
[12, "B", "Any", True, "Table.id2", "", ""],
]]
],
"DATA": {
"Table": [
["id"],
[1],
]
}
})
self.load_sample(sample)
# `Table.id` gives a custom message because `id` is an existing column.
self.assertFormulaError(
self.engine.get_formula_error('Table', 'A', 1),
AttributeError,
'To retrieve all values in a column, use `Table.all.id`. '
"Tables have no attribute 'id'"
+ six.PY3 * (
"\n\nAn `AttributeError` occurs when the code contains something like\n"
" `object.x`\n"
"and `x` is not a method or attribute (variable) belonging to `object`."
)
)
# `Table.id2` gives a standard message because `id2` is not an existing column.
error = self.engine.get_formula_error('Table', 'B', 1).error
message = str(error)
self.assertNotIn('Table.all', message)
self.assertIn("'UserTable' object has no attribute 'id2'", message)
def test_missing_all_iteration(self):
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "MyTable", [
[11, "A", "Any", True, "list(MyTable)", "", ""],
[12, "B", "Any", True, "list(MyTable.all)", "", ""],
]]
],
"DATA": {
"MyTable": [
["id"],
[1],
]
}
})
self.load_sample(sample)
# `list(MyTable)` gives a custom message suggesting `.all`.
self.assertFormulaError(
self.engine.get_formula_error('MyTable', 'A', 1),
TypeError,
"To iterate (loop) over all records in a table, use `MyTable.all`. "
"Tables are not directly iterable."
+ six.PY3 * (
'\n\nA `TypeError` is usually caused by trying\n'
'to combine two incompatible types of objects,\n'
'by calling a function with the wrong type of object,\n'
'or by trying to do an operation not allowed on a given type of object.'
)
)
# `list(MyTable.all)` works correctly.
self.assertTableData('MyTable', data=[
['id', 'A', 'B'],
[ 1, objtypes.RaisedException(TypeError()), [objtypes.RecordStub('MyTable', 1)]],
])
def test_lookup_state(self):
# Bug https://phab.getgrist.com/T297 was caused by lookup maps getting corrupted while
# re-evaluating a formula for the sake of getting error details. This test case reproduces the
# bug in the old code and verifies that it is fixed.
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "LookupTest", [
[11, "A", "Numeric", False, "", "", ""],
[12, "B", "Text", True, "LookupTest.lookupOne(A=2).x.upper()", "", ""],
]]
],
"DATA": {
"LookupTest": [
["id", "A"],
[7, 2],
]
}
})
self.load_sample(sample)
self.assertTableData('LookupTest', data=[
['id', 'A', 'B'],
[ 7, 2., objtypes.RaisedException(AttributeError())],
])
# Updating a dependency shouldn't cause problems.
self.update_record('LookupTest', 7, A=3)
self.assertTableData('LookupTest', data=[
['id', 'A', 'B'],
[ 7, 3., objtypes.RaisedException(AttributeError())],
])
# Fetch the error details.
self.assertFormulaError(self.engine.get_formula_error('LookupTest', 'B', 7),
AttributeError, "Table 'LookupTest' has no column 'x'")
# Updating a dependency after the fetch used to cause the error
# "AttributeError: 'Table' object has no attribute 'col_id'". Check that it's fixed.
self.update_record('LookupTest', 7, A=2) # Should NOT raise an exception.
self.assertTableData('LookupTest', data=[
['id', 'A', 'B'],
[ 7, 2., objtypes.RaisedException(AttributeError())],
])
# Add the column that will fix the attribute error.
self.add_column('LookupTest', 'x', type='Text')
self.assertTableData('LookupTest', data=[
['id', 'A', 'x', 'B'],
[ 7, 2., '', '' ],
])
# And check that the dependency still works and is recomputed.
self.update_record('LookupTest', 7, x='hello')
self.assertTableData('LookupTest', data=[
['id', 'A', 'x', 'B'],
[ 7, 2., 'hello', 'HELLO'],
])
self.update_record('LookupTest', 7, A=3)
self.assertTableData('LookupTest', data=[
['id', 'A', 'x', 'B'],
[ 7, 3., 'hello', ''],
])
def test_undo_side_effects(self):
# Ensures that side-effects (i.e. generated doc actions) produced while evaluating
# get_formula_errors() get reverted.
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Address", [
[11, "city", "Text", False, "", "", ""],
[12, "state", "Text", False, "", "", ""],
]],
[2, "Foo", [
# Note: the formula below is a terrible example of a formula, which intentionally
# creates a new record every time it evaluates.
[21, "B", "Any", True,
"Address.lookupOrAddDerived(city=str(len(Address.all)))", "", ""],
]]
],
"DATA": {
"Foo": [["id"], [1]]
}
})
self.load_sample(sample)
self.assertTableData('Address', data=[
['id', 'city', 'state'],
[1, '0', ''],
])
# Note that evaluating the formula again would add a new record (Address[2]), but when done as
# part of get_formula_error(), that action gets undone.
self.assertEqual(str(self.engine.get_formula_error('Foo', 'B', 1)), "Address[2]")
self.assertTableData('Address', data=[
['id', 'city', 'state'],
[1, '0', ''],
])
def test_formula_reading_from_an_errored_formula(self):
# There was a bug whereby if one formula (call it D) referred to
# another (call it T), and that other formula was in error, the
# error values of that second formula would not be passed on the
# client as a BulkUpdateRecord. The bug was dependent on order of
# evaluation of columns. D would be evaluated first, and evaluate
# T in a nested way. When evaluating T, a BulkUpdateRecord would
# be prepared correctly, and when popping back to evaluate D,
# the BulkUpdateRecord for D would be prepared correctly, but since
# D was an error, any nested actions would be reverted (this is
# logic related to undoing potential side-effects on failure).
# First, set up a table with a sequence in A, a formula to do cumulative sums in T,
# and a formula D to copy T.
formula = "recs = UpdateTest.lookupRecords()\nsum(r.A for r in recs if r.A <= $A)"
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "UpdateTest", [
[20, "A", "Numeric", False, "", "", ""],
[21, "T", "Numeric", True, formula, "", ""],
[22, "D", "Numeric", True, "$T", "", ""],
]]
],
"DATA": {
"UpdateTest": [
["id", "A"],
[1, 1],
[2, 2],
[3, 3],
]
}
})
# Check the setup is working correctly.
self.load_sample(sample)
self.assertTableData('UpdateTest', data=[
['id', 'A', 'T', 'D'],
[ 1, 1., 1., 1.],
[ 2, 2., 3., 3.],
[ 3, 3., 6., 6.],
])
# Now rename the data column. This rename results in a partial
# update to the T formula that leaves it broken (not all the As are caught).
out_actions = self.apply_user_action(["RenameColumn", "UpdateTest", "A", "AA"])
# Make sure the we have bulk updates for both T and D, and not just D.
err = ["E", "AttributeError"]
self.assertPartialOutActions(out_actions, { "stored": [
["RenameColumn", "UpdateTest", "A", "AA"],
["ModifyColumn", "UpdateTest", "T", {
"formula": "recs = UpdateTest.lookupRecords()\nsum(r.A for r in recs if r.A <= $AA)"}
],
["BulkUpdateRecord", "_grist_Tables_column", [20, 21], {
"colId": ["AA", "T"],
"formula": ["", "recs = UpdateTest.lookupRecords()\nsum(r.A for r in recs if r.A <= $AA)"]}
],
[
"BulkUpdateRecord", "UpdateTest", [1, 2, 3], {
"D": [err, err, err]
}
],
[
"BulkUpdateRecord", "UpdateTest", [1, 2, 3], {
"T": [err, err, err]
}
],
]})
# Make sure the table is in the correct state.
errVal = objtypes.RaisedException(AttributeError())
self.assertTableData('UpdateTest', data=[
['id', 'AA', 'T', 'D'],
[ 1, 1., errVal, errVal],
[ 2, 2., errVal, errVal],
[ 3, 3., errVal, errVal],
])
def test_undo_side_effects_with_reordering(self):
# As for test_undo_side_effects, but now after creating a row in a
# formula we try to access a cell that hasn't been recomputed yet.
# That will result in the formula evalution being abandoned, the
# desired cell being calculated, then the formula being retried.
# All going well, we should end up with one row, not two.
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Address", [
[11, "city", "Text", False, "", "", ""],
[12, "state", "Text", False, "", "", ""],
]],
[2, "Foo", [
# Note: the formula below is a terrible example of a formula, which intentionally
# creates a new record every time it evaluates.
[21, "B", "Any", True,
"Address.lookupOrAddDerived(city=str(len(Address.all)))\nreturn $C", "", ""],
[22, "C", "Numeric", True, "42", "", ""],
]]
],
"DATA": {
"Foo": [["id"], [1]]
}
})
self.load_sample(sample)
self.assertTableData('Address', data=[
['id', 'city', 'state'],
[1, '0', ''],
])
def test_attribute_error(self):
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "AttrTest", [
[30, "A", "Numeric", False, "", "", ""],
[31, "B", "Numeric", True, "$AA", "", ""],
[32, "C", "Numeric", True, "$B", "", ""],
]]
],
"DATA": {
"AttrTest": [
["id", "A"],
[1, 1],
[2, 2],
]
}
})
self.load_sample(sample)
errVal = objtypes.RaisedException(AttributeError())
self.assertTableData('AttrTest', data=[
['id', 'A', 'B', 'C'],
[1, 1, errVal, errVal],
[2, 2, errVal, errVal],
])
self.assertFormulaError(self.engine.get_formula_error('AttrTest', 'B', 1),
AttributeError, "Table 'AttrTest' has no column 'AA'",
r"AttributeError: Table 'AttrTest' has no column 'AA'")
cell_error = self.engine.get_formula_error('AttrTest', 'C', 1)
self.assertFormulaError(
cell_error, objtypes.CellError,
"Table 'AttrTest' has no column 'AA'\n(in referenced cell AttrTest[1].B)",
r"CellError: AttributeError in referenced cell AttrTest\[1\].B",
)
self.assertEqual(
objtypes.encode_object(cell_error),
['E',
'AttributeError',
"Table 'AttrTest' has no column 'AA'\n"
"(in referenced cell AttrTest[1].B)",
cell_error.details]
)
def test_cumulative_formula(self):
formula = ("Table1.lookupOne(A=$A-1).Principal + Table1.lookupOne(A=$A-1).Interest " +
"if $A > 1 else 1000")
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table1", [
[30, "A", "Numeric", False, "", "", ""],
[31, "Principal", "Numeric", True, formula, "", ""],
[32, "Interest", "Numeric", True, "int($Principal * 0.1)", "", ""],
]]
],
"DATA": {
"Table1": [
["id", "A"],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
]
}
})
self.load_sample(sample)
self.assertTableData('Table1', data=[
['id', 'A', 'Principal', 'Interest'],
[ 1, 1, 1000.0, 100.0],
[ 2, 2, 1100.0, 110.0],
[ 3, 3, 1210.0, 121.0],
[ 4, 4, 1331.0, 133.0],
[ 5, 5, 1464.0, 146.0],
])
self.update_records('Table1', ['id', 'A'], [
[1, 5], [2, 3], [3, 4], [4, 2], [5, 1]
])
self.assertTableData('Table1', data=[
['id', 'A', 'Principal', 'Interest'],
[ 1, 5, 1464.0, 146.0],
[ 2, 3, 1210.0, 121.0],
[ 3, 4, 1331.0, 133.0],
[ 4, 2, 1100.0, 110.0],
[ 5, 1, 1000.0, 100.0],
])
def test_trivial_cycle(self):
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table1", [
[31, "A", "Numeric", False, "", "", ""],
[31, "B", "Numeric", True, "$B", "", ""],
]]
],
"DATA": {
"Table1": [
["id", "A"],
[1, 1],
[2, 2],
[3, 3],
]
}
})
self.load_sample(sample)
circle = objtypes.RaisedException(depend.CircularRefError())
self.assertTableData('Table1', data=[
['id', 'A', 'B'],
[ 1, 1, circle],
[ 2, 2, circle],
[ 3, 3, circle],
])
def test_cycle(self):
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table1", [
[30, "A", "Numeric", False, "", "", ""],
[31, "Principal", "Numeric", True, "$Interest", "", ""],
[32, "Interest", "Numeric", True, "$Principal", "", ""],
[33, "A2", "Numeric", True, "$A", "", ""],
]]
],
"DATA": {
"Table1": [
["id", "A"],
[1, 1],
[2, 2],
[3, 3],
]
}
})
self.load_sample(sample)
circle = objtypes.RaisedException(depend.CircularRefError())
self.assertTableData('Table1', data=[
['id', 'A', 'Principal', 'Interest', 'A2'],
[ 1, 1, circle, circle, 1],
[ 2, 2, circle, circle, 2],
[ 3, 3, circle, circle, 3],
])
def test_cycle_and_copy(self):
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table1", [
[31, "A", "Numeric", False, "", "", ""],
[31, "B", "Numeric", True, "$C", "", ""],
[32, "C", "Numeric", True, "$C", "", ""],
]]
],
"DATA": {
"Table1": [
["id", "A"],
[1, 1],
[2, 2],
[3, 3],
]
}
})
self.load_sample(sample)
circle = objtypes.RaisedException(depend.CircularRefError())
self.assertTableData('Table1', data=[
['id', 'A', 'B', 'C'],
[ 1, 1, circle, circle],
[ 2, 2, circle, circle],
[ 3, 3, circle, circle],
])
def test_cycle_and_reference(self):
sample = testutil.parse_test_sample({
"SCHEMA": [
[2, "ATable", [
[32, "A", "Ref:ZTable", False, "", "", ""],
[33, "B", "Numeric", True, "$A.B", "", ""],
]],
[1, "ZTable", [
[31, "A", "Numeric", False, "", "", ""],
[31, "B", "Numeric", True, "$B", "", ""],
]],
],
"DATA": {
"ATable": [
["id", "A"],
[1, 1],
[2, 2],
[3, 3],
],
"ZTable": [
["id", "A"],
[1, 6],
[2, 7],
[3, 8],
]
}
})
self.load_sample(sample)
circle = objtypes.RaisedException(depend.CircularRefError())
self.assertTableData('ATable', data=[
['id', 'A', 'B'],
[ 1, 1, circle],
[ 2, 2, circle],
[ 3, 3, circle],
])
self.assertTableData('ZTable', data=[
['id', 'A', 'B'],
[ 1, 6, circle],
[ 2, 7, circle],
[ 3, 8, circle],
])
def test_cumulative_efficiency(self):
# Make sure cumulative formula evaluation doesn't fall over after more than a few rows.
top = 250
# Compute compound interest in ascending order of A
formula = ("Table1.lookupOne(A=$A-1).Principal + Table1.lookupOne(A=$A-1).Interest " +
"if $A > 1 else 1000")
# Compute compound interest in descending order of A
rformula = ("Table1.lookupOne(A=$A+1).RPrincipal + Table1.lookupOne(A=$A+1).RInterest " +
"if $A < %d else 1000" % top)
rows = [["id", "A"]]
for i in range(1, top + 1):
rows.append([i, i])
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table1", [
[30, "A", "Numeric", False, "", "", ""],
[31, "Principal", "Numeric", True, formula, "", ""],
[32, "Interest", "Numeric", True, "int($Principal * 0.1)", "", ""],
[33, "RPrincipal", "Numeric", True, rformula, "", ""],
[34, "RInterest", "Numeric", True, "int($RPrincipal * 0.1)", "", ""],
[35, "Total", "Numeric", True, "$Principal + $RPrincipal", "", ""],
]],
[2, "Readout", [
[36, "LastPrincipal", "Numeric", True, "Table1.lookupOne(A=%d).Principal" % top, "", ""],
[37, "LastRPrincipal", "Numeric", True, "Table1.lookupOne(A=1).RPrincipal", "", ""],
[38, "FirstTotal", "Numeric", True, "Table1.lookupOne(A=1).Total", "", ""],
[39, "LastTotal", "Numeric", True, "Table1.lookupOne(A=%d).Total" % top, "", ""],
]]
],
"DATA": {
"Table1": rows,
"Readout": [["id"], [1]],
}
})
self.load_sample(sample)
principal = 20213227788876.0
self.assertTableData('Readout', data=[
['id', 'LastPrincipal', 'LastRPrincipal', 'FirstTotal', 'LastTotal'],
[1, principal, principal, principal + 1000, principal + 1000],
])
def test_cumulative_formula_with_references(self):
top = 100
formula = "max($Prev.Principal + $Prev.Interest, 1000)"
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table1", [
[41, "Prev", "Ref:Table1", True, "$id - 1", "", ""],
[42, "Principal", "Numeric", True, formula, "", ""],
[43, "Interest", "Numeric", True, "int($Principal * 0.1)", "", ""],
]],
[2, "Readout", [
[46, "LastPrincipal", "Numeric", True, "Table1.lookupOne(id=%d).Principal" % top, "", ""],
]]
],
"DATA": {
"Table1": [["id"]] + [[r] for r in range(1, top + 1)],
"Readout": [["id"], [1]],
}
})
self.load_sample(sample)
self.assertTableData('Readout', data=[
['id', 'LastPrincipal'],
[1, 12494908.0],
])
self.modify_column("Table1", "Prev", formula="$id - 1 if $id > 1 else 100")
self.assertTableData('Readout', data=[
['id', 'LastPrincipal'],
[1, objtypes.RaisedException(depend.CircularRefError())],
])
def test_catch_all_in_formula(self):
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table1", [
[51, "A", "Numeric", False, "", "", ""],
[52, "B1", "Numeric", True, "try:\n return $A+$C\nexcept:\n return 42", "", ""],
[53, "B2", "Numeric", True, "try:\n return $D+None\nexcept:\n return 42", "", ""],
[54, "B3", "Numeric", True, "try:\n return $A+$B4+$D\nexcept:\n return 42", "", ""],
[55, "B4", "Numeric", True, "try:\n return $A+$B3+$D\nexcept:\n return 42", "", ""],
[56, "B5", "Numeric", True,
"try:\n return $E+1\nexcept:\n raise Exception('monkeys!')", "", ""],
[56, "B6", "Numeric", True,
"try:\n return $F+1\nexcept Exception as e:\n e.node = e.row_id = 'monkey'", "", ""],
[57, "C", "Numeric", False, "", "", ""],
[58, "D", "Numeric", True, "$A", "", ""],
[59, "E", "Numeric", True, "$A", "", ""],
[59, "F", "Numeric", True, "$A", "", ""],
]],
],
"DATA": {
"Table1": [["id", "A", "C"], [1, 1, 2], [2, 20, 10]],
}
})
self.load_sample(sample)
circle = objtypes.RaisedException(depend.CircularRefError())
# B4 is a subtle case. B3 and B4 refer to each other. B3 is recomputed first,
# and cells evaluate to a CircularRefError. Now B3 has a value, so B4 can be
# evaluated, and results in 42 when addition of an integer and an exception value
# fails.
self.assertTableData('Table1', data=[
['id', 'A', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'C', 'D', 'E', 'F'],
[1, 1, 3, 42, circle, 42, 2, 2, 2, 1, 1, 1],
[2, 20, 30, 42, circle, 42, 21, 21, 10, 20, 20, 20],
])
def test_reference_column(self):
# There was a bug where self-references could result in a column being prematurely
# considered complete.
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table1", [
[40, "Ident", "Text", False, "", "", ""],
[41, "Prev", "Ref:Table1", False, "", "", ""],
[42, "Calc", "Numeric", True, "$Prev.Calc * 1.5 if $Prev else 1", "", ""]
]]],
"DATA": {
"Table1": [
['id', 'Ident', 'Prev'],
[1, 'a', 0],
[2, 'b', 1],
[3, 'c', 4],
[4, 'd', 0],
]
}
})
self.load_sample(sample)
self.assertTableData('Table1', data=[
['id', 'Ident', 'Prev', 'Calc'],
[1, 'a', 0, 1.0],
[2, 'b', 1, 1.5],
[3, 'c', 4, 1.5],
[4, 'd', 0, 1.0]
])
def test_loop(self):
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Table1", [
[31, "A", "Numeric", False, "", "", ""],
[31, "B", "Numeric", True, "$C", "", ""],
[32, "C", "Numeric", True, "$B", "", ""],
]]
],
"DATA": {
"Table1": [
["id", "A"],
[1, 1],
[2, 2],
[3, 3],
]
}
})
self.load_sample(sample)
circle = objtypes.RaisedException(depend.CircularRefError())
self.assertTableData('Table1', data=[
['id', 'A', 'B', 'C'],
[ 1, 1, circle, circle],
[ 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],
])