mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Adding traceback to trigger formulas
Summary: Traceback is available on the Creator Panel in the formula editor. It is evaluated the same way as for normal formulas. In case when the traceback is not available, only the error name is displayed with information that traceback is not available. Cell with an error, when edited, shows the previous valid value that was used before the error happened (or None for new rows). Value is stored inside the RaisedException object that is stored in a cell. Test Plan: Created tests Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki, dsagal Differential Revision: https://phab.getgrist.com/D3033
This commit is contained in:
parent
048c8ee165
commit
8684c9e930
@ -18,6 +18,9 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
|
|||||||
// Is a real formula column (not an empty column; i.e. contains a non-empty formula).
|
// Is a real formula column (not an empty column; i.e. contains a non-empty formula).
|
||||||
isRealFormula: ko.Computed<boolean>;
|
isRealFormula: ko.Computed<boolean>;
|
||||||
|
|
||||||
|
// Is a trigger formula column (not formula, but contains non-empty formula)
|
||||||
|
hasTriggerFormula: ko.Computed<boolean>;
|
||||||
|
|
||||||
// Used for transforming a column.
|
// Used for transforming a column.
|
||||||
// Reference to the original column for a transform column, or to itself for a non-transforming column.
|
// Reference to the original column for a transform column, or to itself for a non-transforming column.
|
||||||
origColRef: ko.Observable<number>;
|
origColRef: ko.Observable<number>;
|
||||||
@ -56,6 +59,8 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
|
|||||||
|
|
||||||
// Is this a real formula column (not an empty column; i.e. contains a non-empty formula).
|
// Is this a real formula column (not an empty column; i.e. contains a non-empty formula).
|
||||||
this.isRealFormula = ko.pureComputed(() => this.isFormula() && this.formula() !== '');
|
this.isRealFormula = ko.pureComputed(() => this.isFormula() && this.formula() !== '');
|
||||||
|
// If this column has a trigger formula defined
|
||||||
|
this.hasTriggerFormula = ko.pureComputed(() => !this.isFormula() && this.formula() !== '');
|
||||||
|
|
||||||
// Used for transforming a column.
|
// Used for transforming a column.
|
||||||
// Reference to the original column for a transform column, or to itself for a non-transforming column.
|
// Reference to the original column for a transform column, or to itself for a non-transforming column.
|
||||||
|
@ -158,7 +158,8 @@ function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, origColum
|
|||||||
function countErrors() {
|
function countErrors() {
|
||||||
if (owner.isDisposed()) { return; }
|
if (owner.isDisposed()) { return; }
|
||||||
const tableData = gristDoc.docData.getTable(origColumn.table.peek().tableId.peek());
|
const tableData = gristDoc.docData.getTable(origColumn.table.peek().tableId.peek());
|
||||||
if (tableData && origColumn.isRealFormula.peek()) {
|
const isFormula = origColumn.isRealFormula.peek() || origColumn.hasTriggerFormula.peek();
|
||||||
|
if (tableData && isFormula) {
|
||||||
const colId = origColumn.colId.peek();
|
const colId = origColumn.colId.peek();
|
||||||
const numCells = tableData.getColValues(colId)?.length || 0;
|
const numCells = tableData.getColValues(colId)?.length || 0;
|
||||||
const numErrors = tableData.countErrors(colId) || 0;
|
const numErrors = tableData.countErrors(colId) || 0;
|
||||||
|
@ -469,13 +469,16 @@ function getFormulaError(
|
|||||||
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
|
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
|
||||||
): Observable<CellValue>|undefined {
|
): Observable<CellValue>|undefined {
|
||||||
const colId = column.colId.peek();
|
const colId = column.colId.peek();
|
||||||
let formulaError: Observable<CellValue>|undefined;
|
|
||||||
const cellCurrentValue = editRow.cells[colId].peek();
|
const cellCurrentValue = editRow.cells[colId].peek();
|
||||||
if (column.isFormula() && isRaisedException(cellCurrentValue)) {
|
const isFormula = column.isFormula() || column.hasTriggerFormula();
|
||||||
const fv = formulaError = Observable.create(null, cellCurrentValue);
|
if (isFormula && isRaisedException(cellCurrentValue)) {
|
||||||
|
const formulaError = Observable.create(null, cellCurrentValue);
|
||||||
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
|
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
|
||||||
.then(value => { fv.set(value); })
|
.then(value => {
|
||||||
|
formulaError.set(value);
|
||||||
|
})
|
||||||
.catch(reportError);
|
.catch(reportError);
|
||||||
}
|
|
||||||
return formulaError;
|
return formulaError;
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
@ -8,7 +8,9 @@ import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorBu
|
|||||||
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
||||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||||
import {undef} from 'app/common/gutil';
|
import {undef} from 'app/common/gutil';
|
||||||
import {dom, Observable, styled} from 'grainjs';
|
import {Computed, dom, Observable, styled} from 'grainjs';
|
||||||
|
import {isRaisedException} from "app/common/gristTypes";
|
||||||
|
import {decodeObject, RaisedException} from "app/plugin/objtypes";
|
||||||
|
|
||||||
// How wide to expand the FormulaEditor when an error is shown in it.
|
// How wide to expand the FormulaEditor when an error is shown in it.
|
||||||
const minFormulaErrorWidth = 400;
|
const minFormulaErrorWidth = 400;
|
||||||
@ -59,8 +61,28 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
: options.commands;
|
: options.commands;
|
||||||
this._commandGroup = this.autoDispose(createGroup(allCommands, this, options.field.editingFormula));
|
this._commandGroup = this.autoDispose(createGroup(allCommands, this, options.field.editingFormula));
|
||||||
|
|
||||||
const hideErrDetails = Observable.create(null, true);
|
const hideErrDetails = Observable.create(this, true);
|
||||||
const formulaError = options.formulaError;
|
const raisedException = Computed.create(this, use => {
|
||||||
|
if (!options.formulaError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const error = isRaisedException(use(options.formulaError)) ?
|
||||||
|
decodeObject(use(options.formulaError)) as RaisedException:
|
||||||
|
new RaisedException(["Unknown error"]);
|
||||||
|
return error;
|
||||||
|
});
|
||||||
|
const errorText = Computed.create(this, raisedException, (_, error) => {
|
||||||
|
if (!error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return error.message ? `${error.name} : ${error.message}` : error.name;
|
||||||
|
});
|
||||||
|
const errorDetails = Computed.create(this, raisedException, (_, error) => {
|
||||||
|
if (!error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return error.details ?? "";
|
||||||
|
});
|
||||||
|
|
||||||
this.autoDispose(this._formulaEditor);
|
this.autoDispose(this._formulaEditor);
|
||||||
this._dom = dom('div.default_editor',
|
this._dom = dom('div.default_editor',
|
||||||
@ -103,21 +125,27 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
aceObj.once("change", () => options.field.editingFormula(true));
|
aceObj.once("change", () => options.field.editingFormula(true));
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
(formulaError ?
|
(options.formulaError ?
|
||||||
dom('div.error_box',
|
dom('div.error_box',
|
||||||
dom('div.error_msg', testId('formula-error-msg'),
|
dom('div.error_msg', testId('formula-error-msg'),
|
||||||
dom.on('click', () => {
|
dom.on('click', () => {
|
||||||
|
if (errorDetails.get()){
|
||||||
hideErrDetails.set(!hideErrDetails.get());
|
hideErrDetails.set(!hideErrDetails.get());
|
||||||
this._formulaEditor.resize();
|
this._formulaEditor.resize();
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse')),
|
dom.maybe(errorDetails, () =>
|
||||||
dom.text((use) => { const f = use(formulaError) as string[]; return `${f[1]}: ${f[2]}`; }),
|
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse'))
|
||||||
),
|
),
|
||||||
|
dom.text(errorText),
|
||||||
|
),
|
||||||
|
dom.maybe(errorDetails, () =>
|
||||||
dom('div.error_details',
|
dom('div.error_details',
|
||||||
dom.hide(hideErrDetails),
|
dom.hide(hideErrDetails),
|
||||||
dom.text((use) => (use(formulaError) as string[])[3]),
|
dom.text(errorDetails),
|
||||||
testId('formula-error-details'),
|
testId('formula-error-details'),
|
||||||
),
|
)
|
||||||
|
)
|
||||||
) : null
|
) : null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -67,7 +67,21 @@ export class ReferenceList {
|
|||||||
* optional details.
|
* optional details.
|
||||||
*/
|
*/
|
||||||
export class RaisedException {
|
export class RaisedException {
|
||||||
constructor(public name: string, public message?: string, public details?: string) {}
|
public name: string;
|
||||||
|
public details?: string;
|
||||||
|
public message?: string;
|
||||||
|
public user_input?: CellValue;
|
||||||
|
|
||||||
|
constructor(list: any[]) {
|
||||||
|
if (!list.length) {
|
||||||
|
throw new Error("RaisedException requires a name as first element");
|
||||||
|
}
|
||||||
|
list = [...list];
|
||||||
|
this.name = list.shift();
|
||||||
|
this.message = list.shift();
|
||||||
|
this.details = list.shift();
|
||||||
|
this.user_input = list.shift()?.u;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is designed to look somewhat similar to Excel, e.g. #VALUE or #DIV/0!"
|
* This is designed to look somewhat similar to Excel, e.g. #VALUE or #DIV/0!"
|
||||||
@ -195,7 +209,7 @@ export function decodeObject(value: CellValue): unknown {
|
|||||||
switch (code) {
|
switch (code) {
|
||||||
case 'D': return GristDateTime.fromGristValue(args[0], String(args[1]));
|
case 'D': return GristDateTime.fromGristValue(args[0], String(args[1]));
|
||||||
case 'd': return GristDate.fromGristValue(args[0]);
|
case 'd': return GristDate.fromGristValue(args[0]);
|
||||||
case 'E': return new RaisedException(args[0], args[1], args[2]);
|
case 'E': return new RaisedException(args);
|
||||||
case 'L': return (args as CellValue[]).map(decodeObject);
|
case 'L': return (args as CellValue[]).map(decodeObject);
|
||||||
case 'O': return mapValues(args[0] as {[key: string]: CellValue}, decodeObject, {sort: true});
|
case 'O': return mapValues(args[0] as {[key: string]: CellValue}, decodeObject, {sort: true});
|
||||||
case 'P': return new PendingValue();
|
case 'P': return new PendingValue();
|
||||||
|
@ -138,7 +138,7 @@ class BaseColumn(object):
|
|||||||
"""
|
"""
|
||||||
self.set(row_id, self.getdefault())
|
self.set(row_id, self.getdefault())
|
||||||
|
|
||||||
def get_cell_value(self, row_id):
|
def get_cell_value(self, row_id, restore=False):
|
||||||
"""
|
"""
|
||||||
Returns the "rich" value for the given row_id, i.e. the value that would be seen by formulas.
|
Returns the "rich" value for the given row_id, i.e. the value that would be seen by formulas.
|
||||||
E.g. for ReferenceColumns it'll be the referred-to Record object. For cells containing
|
E.g. for ReferenceColumns it'll be the referred-to Record object. For cells containing
|
||||||
@ -146,8 +146,13 @@ class BaseColumn(object):
|
|||||||
error, this will re-raise that error.
|
error, this will re-raise that error.
|
||||||
"""
|
"""
|
||||||
raw = self.raw_get(row_id)
|
raw = self.raw_get(row_id)
|
||||||
|
|
||||||
if isinstance(raw, objtypes.RaisedException):
|
if isinstance(raw, objtypes.RaisedException):
|
||||||
if isinstance(raw.error, depend.CircularRefError):
|
# For trigger formulas, we want to restore the previous value to recalculate
|
||||||
|
# the cell one more time.
|
||||||
|
if restore and raw.has_user_input():
|
||||||
|
raw = raw.user_input
|
||||||
|
elif isinstance(raw.error, depend.CircularRefError):
|
||||||
# Wrapping a CircularRefError in a CellError is often redundant, but
|
# Wrapping a CircularRefError in a CellError is often redundant, but
|
||||||
# TODO a CellError is still useful if the calling cell is not involved in the cycle
|
# TODO a CellError is still useful if the calling cell is not involved in the cycle
|
||||||
raise raw.error
|
raise raw.error
|
||||||
|
@ -642,7 +642,16 @@ class Engine(object):
|
|||||||
col = table.get_column(col_id)
|
col = table.get_column(col_id)
|
||||||
checkpoint = self._get_undo_checkpoint()
|
checkpoint = self._get_undo_checkpoint()
|
||||||
try:
|
try:
|
||||||
return self._recompute_one_cell(None, table, col, row_id)
|
result = self._recompute_one_cell(None, table, col, row_id)
|
||||||
|
# If the error is gone for a trigger formula
|
||||||
|
if col.has_formula() and not col.is_formula():
|
||||||
|
if not isinstance(result, objtypes.RaisedException):
|
||||||
|
# Get the error stored in the cell
|
||||||
|
# and change it to show to the user that no traceback is available
|
||||||
|
error_in_cell = objtypes.decode_object(col.raw_get(row_id))
|
||||||
|
assert isinstance(error_in_cell, objtypes.RaisedException)
|
||||||
|
return error_in_cell.no_traceback()
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
# It is possible for formula evaluation to have side-effects that produce DocActions (e.g.
|
# It is possible for formula evaluation to have side-effects that produce DocActions (e.g.
|
||||||
# lookupOrAddDerived() creates those). In case of get_formula_error(), these aren't fully
|
# lookupOrAddDerived() creates those). In case of get_formula_error(), these aren't fully
|
||||||
@ -775,7 +784,8 @@ class Engine(object):
|
|||||||
if is_first:
|
if is_first:
|
||||||
self._is_node_exception_reported.add(node)
|
self._is_node_exception_reported.add(node)
|
||||||
log.info(value.details)
|
log.info(value.details)
|
||||||
value = objtypes.RaisedException(value.error) # strip out details after logging
|
# strip out details after logging
|
||||||
|
value = objtypes.RaisedException(value.error, user_input=value.user_input)
|
||||||
|
|
||||||
# TODO: validation columns should be wrapped to always return True/False (catching
|
# TODO: validation columns should be wrapped to always return True/False (catching
|
||||||
# exceptions), so that we don't need special handling here.
|
# exceptions), so that we don't need special handling here.
|
||||||
@ -831,11 +841,13 @@ class Engine(object):
|
|||||||
|
|
||||||
checkpoint = self._get_undo_checkpoint()
|
checkpoint = self._get_undo_checkpoint()
|
||||||
record = table.Record(row_id, table._identity_relation)
|
record = table.Record(row_id, table._identity_relation)
|
||||||
|
value = None
|
||||||
try:
|
try:
|
||||||
if cycle:
|
if cycle:
|
||||||
raise depend.CircularRefError("Circular Reference")
|
raise depend.CircularRefError("Circular Reference")
|
||||||
if not col.is_formula():
|
if not col.is_formula():
|
||||||
result = col.method(record, table.user_table, col.get_cell_value(int(record)), self._user)
|
value = col.get_cell_value(int(record), restore=True)
|
||||||
|
result = col.method(record, table.user_table, value, self._user)
|
||||||
else:
|
else:
|
||||||
result = col.method(record, table.user_table)
|
result = col.method(record, table.user_table)
|
||||||
if self._cell_required_error:
|
if self._cell_required_error:
|
||||||
@ -866,6 +878,9 @@ class Engine(object):
|
|||||||
self.formula_tracer(col, record)
|
self.formula_tracer(col, record)
|
||||||
|
|
||||||
include_details = (node not in self._is_node_exception_reported) if node else True
|
include_details = (node not in self._is_node_exception_reported) if node else True
|
||||||
|
if not col.is_formula():
|
||||||
|
return objtypes.RaisedException(regular_error, include_details, user_input=value)
|
||||||
|
else:
|
||||||
return objtypes.RaisedException(regular_error, include_details)
|
return objtypes.RaisedException(regular_error, include_details)
|
||||||
|
|
||||||
def convert_action_values(self, action):
|
def convert_action_values(self, action):
|
||||||
|
@ -123,6 +123,10 @@ _max_js_int = 1<<31
|
|||||||
def is_int_short(value):
|
def is_int_short(value):
|
||||||
return -_max_js_int <= value < _max_js_int
|
return -_max_js_int <= value < _max_js_int
|
||||||
|
|
||||||
|
def safe_shift(arg, default=None):
|
||||||
|
value = arg.pop(0) if arg else None
|
||||||
|
return default if value is None else value
|
||||||
|
|
||||||
def safe_repr(obj):
|
def safe_repr(obj):
|
||||||
"""
|
"""
|
||||||
Like repr(obj) but falls back to a simpler "<type-name>" string when repr() itself fails.
|
Like repr(obj) but falls back to a simpler "<type-name>" string when repr() itself fails.
|
||||||
@ -252,16 +256,39 @@ class RaisedException(object):
|
|||||||
RaisedException is registered under a special short name ("E") to save bytes since it's such a
|
RaisedException is registered under a special short name ("E") to save bytes since it's such a
|
||||||
widely-used wrapper. To encode_args, it simply returns the entire encoded stored error, e.g.
|
widely-used wrapper. To encode_args, it simply returns the entire encoded stored error, e.g.
|
||||||
RaisedException(ValueError("foo")) is encoded as ["E", "ValueError", "foo"].
|
RaisedException(ValueError("foo")) is encoded as ["E", "ValueError", "foo"].
|
||||||
|
|
||||||
|
When user_input is passed, RaisedException(ValueError("foo"), user_input=2) is encoded as:
|
||||||
|
["E", "ValueError", "foo", {u: 2}].
|
||||||
"""
|
"""
|
||||||
def __init__(self, error, include_details=False, encoded_error=None):
|
|
||||||
|
# Marker object that indicates that there was no user input.
|
||||||
|
NO_INPUT = object()
|
||||||
|
|
||||||
|
def __init__(self, error, include_details=False, user_input=NO_INPUT):
|
||||||
|
self.user_input = user_input
|
||||||
self.error = error
|
self.error = error
|
||||||
self.details = traceback.format_exc() if include_details else None
|
self.details = None
|
||||||
self._encoded_error = encoded_error or self._encode_error()
|
self._encoded_error = None
|
||||||
|
self._name = None
|
||||||
|
self._message = None
|
||||||
|
if error is not None:
|
||||||
|
self._fill_from_error(self.has_user_input(), include_details)
|
||||||
|
|
||||||
def encode_args(self):
|
def encode_args(self):
|
||||||
|
if self._encoded_error is not None:
|
||||||
return self._encoded_error
|
return self._encoded_error
|
||||||
|
if self.has_user_input():
|
||||||
|
user_input = {"u": encode_object(self.user_input)}
|
||||||
|
else:
|
||||||
|
user_input = None
|
||||||
|
result = [self._name, self._message, self.details, user_input]
|
||||||
|
# Trim last values that are None
|
||||||
|
while len(result) > 1 and result[-1] is None:
|
||||||
|
result.pop()
|
||||||
|
self._encoded_error = result
|
||||||
|
return result
|
||||||
|
|
||||||
def _encode_error(self):
|
def _fill_from_error(self, include_message=False, include_details=False):
|
||||||
# TODO: We should probably return all args, to communicate the error details to the browser
|
# TODO: We should probably return all args, to communicate the error details to the browser
|
||||||
# and to DB (for when we store formula results). There are two concerns: one is that it's
|
# and to DB (for when we store formula results). There are two concerns: one is that it's
|
||||||
# potentially quite verbose; the other is that it's makes the tests more annoying (again b/c
|
# potentially quite verbose; the other is that it's makes the tests more annoying (again b/c
|
||||||
@ -272,18 +299,40 @@ class RaisedException(object):
|
|||||||
if not location:
|
if not location:
|
||||||
location = "\n(in referenced cell {error.location})".format(error=error)
|
location = "\n(in referenced cell {error.location})".format(error=error)
|
||||||
error = error.error
|
error = error.error
|
||||||
name = type(error).__name__
|
self._name = type(error).__name__
|
||||||
if self.details:
|
if include_details:
|
||||||
return [name, str(error) + location, self.details]
|
self.details = traceback.format_exc()
|
||||||
if isinstance(error, InvalidTypedValue):
|
self._message = str(error) + location
|
||||||
return [name, error.typename, error.value]
|
elif isinstance(error, InvalidTypedValue):
|
||||||
return [name]
|
self._message = error.typename
|
||||||
|
self.details = error.value
|
||||||
|
elif include_message:
|
||||||
|
self._message = str(error) + location
|
||||||
|
|
||||||
|
def has_user_input(self):
|
||||||
|
return self.user_input is not RaisedException.NO_INPUT
|
||||||
|
|
||||||
|
def no_traceback(self):
|
||||||
|
exc = RaisedException(None)
|
||||||
|
exc._name = self._name
|
||||||
|
exc.error = self.error
|
||||||
|
exc.user_input = self.user_input
|
||||||
|
exc.details = "This error is left over from before, and " + \
|
||||||
|
"the formula hasn't been triggered since then."
|
||||||
|
exc._message = self._message
|
||||||
|
return exc
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def decode_args(cls, *args):
|
def decode_args(cls, *args):
|
||||||
# Decoding of a RaisedException is only enough to re-encode it.
|
exc = cls(None)
|
||||||
return cls(None, encoded_error=list(args))
|
args = list(args)
|
||||||
|
assert args
|
||||||
|
exc._name = safe_shift(args)
|
||||||
|
exc._message = safe_shift(args)
|
||||||
|
exc.details = safe_shift(args)
|
||||||
|
exc.user_input = safe_shift(args, {})
|
||||||
|
exc.user_input = decode_object(exc.user_input.get("u", RaisedException.NO_INPUT))
|
||||||
|
return exc
|
||||||
|
|
||||||
class CellError(Exception):
|
class CellError(Exception):
|
||||||
def __init__(self, table_id, col_id, row_id, error):
|
def __init__(self, table_id, col_id, row_id, error):
|
||||||
|
@ -12,6 +12,7 @@ import engine
|
|||||||
import logger
|
import logger
|
||||||
import useractions
|
import useractions
|
||||||
import testutil
|
import testutil
|
||||||
|
import objtypes
|
||||||
|
|
||||||
log = logger.Logger(__name__, logger.DEBUG)
|
log = logger.Logger(__name__, logger.DEBUG)
|
||||||
|
|
||||||
@ -262,6 +263,13 @@ class EngineTestCase(unittest.TestCase):
|
|||||||
for tbl in list_of_tables
|
for tbl in list_of_tables
|
||||||
for col in tbl.columns))
|
for col in tbl.columns))
|
||||||
|
|
||||||
|
def assertFormulaError(self, exc, type_, message, tracebackRegexp=None):
|
||||||
|
self.assertIsInstance(exc, objtypes.RaisedException)
|
||||||
|
self.assertIsInstance(exc.error, type_)
|
||||||
|
self.assertEqual(str(exc.error), message)
|
||||||
|
if tracebackRegexp:
|
||||||
|
self.assertRegex(exc.details, tracebackRegexp)
|
||||||
|
|
||||||
def assertViews(self, list_of_views):
|
def assertViews(self, list_of_views):
|
||||||
"""
|
"""
|
||||||
Verifies that the given View test-records correspond to the metadata for views/sections/fields.
|
Verifies that the given View test-records correspond to the metadata for views/sections/fields.
|
||||||
|
@ -46,13 +46,6 @@ else:
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
def assertFormulaError(self, exc, type_, message, tracebackRegexp=None):
|
|
||||||
self.assertIsInstance(exc, objtypes.RaisedException)
|
|
||||||
self.assertIsInstance(exc.error, type_)
|
|
||||||
self.assertEqual(str(exc.error), message)
|
|
||||||
if tracebackRegexp:
|
|
||||||
self.assertRegex(exc.details, tracebackRegexp)
|
|
||||||
|
|
||||||
def test_formula_errors(self):
|
def test_formula_errors(self):
|
||||||
self.load_sample(self.sample)
|
self.load_sample(self.sample)
|
||||||
|
|
||||||
|
@ -10,7 +10,12 @@ from schema import RecalcWhen
|
|||||||
|
|
||||||
log = logger.Logger(__name__, logger.INFO)
|
log = logger.Logger(__name__, logger.INFO)
|
||||||
|
|
||||||
attr_error = objtypes.RaisedException(AttributeError())
|
def column_error(table, column, user_input):
|
||||||
|
return objtypes.RaisedException(
|
||||||
|
AttributeError("Table '%s' has no column '%s'" % (table, column)),
|
||||||
|
user_input=user_input
|
||||||
|
)
|
||||||
|
div_error = lambda value: objtypes.RaisedException(ZeroDivisionError("float division by zero"), user_input=value)
|
||||||
|
|
||||||
class TestTriggerFormulas(test_engine.EngineTestCase):
|
class TestTriggerFormulas(test_engine.EngineTestCase):
|
||||||
col = testutil.col_schema_row
|
col = testutil.col_schema_row
|
||||||
@ -396,15 +401,17 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
|
|||||||
out_actions = self.update_record("Creatures", 1, Name="Whale")
|
out_actions = self.update_record("Creatures", 1, Name="Whale")
|
||||||
self.assertTableData("Creatures", data=[
|
self.assertTableData("Creatures", data=[
|
||||||
["id","Name", "BossDef", "BossNvr", "BossUpd", "BossAll" ],
|
["id","Name", "BossDef", "BossNvr", "BossUpd", "BossAll" ],
|
||||||
[1, "Whale", "Arthur", "Arthur", "Arthur", attr_error],
|
[1, "Whale", "Arthur", "Arthur", "Arthur", column_error("Creatures", "Ocean", "Arthur")],
|
||||||
])
|
])
|
||||||
|
|
||||||
# Add a record. BossUpd's formula still runs, though with an error.
|
# Add a record. BossUpd's formula still runs, though with an error.
|
||||||
|
no_column = column_error("Creatures", "Ocean", "")
|
||||||
|
no_column_value = column_error("Creatures", "Ocean", "Arthur")
|
||||||
out_actions = self.add_record("Creatures", None, Name="Manatee")
|
out_actions = self.add_record("Creatures", None, Name="Manatee")
|
||||||
self.assertTableData("Creatures", data=[
|
self.assertTableData("Creatures", data=[
|
||||||
["id","Name", "BossDef", "BossNvr", "BossUpd", "BossAll" ],
|
["id","Name", "BossDef", "BossNvr", "BossUpd", "BossAll" ],
|
||||||
[1, "Whale", "Arthur", "Arthur", "Arthur", attr_error],
|
[1, "Whale", "Arthur", "Arthur", "Arthur", no_column_value],
|
||||||
[2, "Manatee", attr_error, "", attr_error, attr_error],
|
[2, "Manatee", no_column, "", no_column, no_column],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
@ -596,3 +603,67 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
|
|||||||
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", "Foo Bar <foo.bar@getgrist.com>"],
|
[1, "Whale", 3, "Arthur", "Arthur", "Neptune", "Neptune", "Indian", "Foo Bar <foo.bar@getgrist.com>"],
|
||||||
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", "Foo Bar <foo.bar@getgrist.com>"],
|
[2, "Manatee", 2, "Poseidon", "", "Poseidon", "Poseidon", "ATLANTIC", "Foo Bar <foo.bar@getgrist.com>"],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
sample_desc_math = {
|
||||||
|
"SCHEMA": [
|
||||||
|
[1, "Math", [
|
||||||
|
col(1, "A", "Numeric", False),
|
||||||
|
col(2, "B", "Numeric", False),
|
||||||
|
col(3, "C", "Numeric", False, "1/$A + 1/$B", recalcDeps=[1]),
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
"DATA": {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sample_math = testutil.parse_test_sample(sample_desc_math)
|
||||||
|
|
||||||
|
def test_triggers_on_error(self):
|
||||||
|
# In case of an error in a trigger formula can be reevaluated when new value is provided
|
||||||
|
self.load_sample(self.sample_math)
|
||||||
|
self.add_record("Math", A=0, B=1)
|
||||||
|
self.assertTableData("Math", data=[
|
||||||
|
["id", "A", "B", "C"],
|
||||||
|
[1, 0, 1, div_error(0)],
|
||||||
|
])
|
||||||
|
self.update_record("Math", 1, A=1)
|
||||||
|
self.assertTableData("Math", data=[
|
||||||
|
["id", "A", "B", "C"],
|
||||||
|
[1, 1, 1, 2],
|
||||||
|
])
|
||||||
|
# When the error is cased by external column, formula is not reevaluated
|
||||||
|
self.update_record("Math", 1, A=2, B=0)
|
||||||
|
self.update_record("Math", 1, A=1)
|
||||||
|
self.assertTableData("Math", data=[
|
||||||
|
["id", "A", "B", "C"],
|
||||||
|
[1, 1, 0, div_error(2)],
|
||||||
|
])
|
||||||
|
self.update_record("Math", 1, B=1)
|
||||||
|
self.assertTableData("Math", data=[
|
||||||
|
["id", "A", "B", "C"],
|
||||||
|
[1, 1, 1, div_error(2)],
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_traceback_available_for_trigger_formula(self):
|
||||||
|
# In case of an error engine is able to retrieve a traceback.
|
||||||
|
self.load_sample(self.sample_math)
|
||||||
|
self.add_record("Math", A=0, B=0)
|
||||||
|
self.assertTableData("Math", data=[
|
||||||
|
["id", "A", "B", "C"],
|
||||||
|
[1, 0, 0, div_error(0)],
|
||||||
|
])
|
||||||
|
self.assertFormulaError(self.engine.get_formula_error('Math', 'C', 1),
|
||||||
|
ZeroDivisionError, 'float division by zero',
|
||||||
|
r"1/rec\.A \+ 1/rec\.B")
|
||||||
|
self.update_record("Math", 1, A=1)
|
||||||
|
|
||||||
|
# Updating B should remove the traceback from an error, but the error should remain.
|
||||||
|
self.update_record("Math", 1, B=1)
|
||||||
|
self.assertTableData("Math", data=[
|
||||||
|
["id", "A", "B", "C"],
|
||||||
|
[1, 1, 1, div_error(0)],
|
||||||
|
])
|
||||||
|
error = self.engine.get_formula_error('Math', 'C', 1)
|
||||||
|
self.assertFormulaError(error, ZeroDivisionError, 'float division by zero')
|
||||||
|
self.assertEqual(error._message, 'float division by zero')
|
||||||
|
self.assertEqual(error.details, objtypes.RaisedException(ZeroDivisionError()).no_traceback().details)
|
||||||
|
Loading…
Reference in New Issue
Block a user