""" 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], ])