(core) Implement RECORD function that converts Records to Python dictionaries

Summary:
Examining a Record is currently difficult, because its columns are hard to
list (and to use), and CircularRef errors hard to avoid. The RECORD function
takes care of this mess to return a simple dictionary of values.

- Supports dates_as_iso=False flag to turn off the translation of date/datetime
  objects to strings.
- Supports expand_refs=True flag to apply RECORD() to encountered values of
  type Record, for a single level of nesting.

Test Plan: Added a unittest for RECORD()

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2582
This commit is contained in:
Dmitry S
2020-08-12 11:52:01 -04:00
parent 4e20f7a8a2
commit 4e11b6a922
2 changed files with 278 additions and 1 deletions

View File

@@ -7,9 +7,10 @@ import math
import numbers
import re
import column
from functions import date # pylint: disable=import-error
from usertypes import AltText # pylint: disable=import-error
from records import Record # pylint: disable=import-error
from records import Record, RecordSet
def ISBLANK(value):
"""
@@ -499,6 +500,86 @@ def CELL(info_type, reference):
raise NotImplementedError()
def RECORD(record_or_list, dates_as_iso=True, expand_refs=0):
"""
Returns a Python dictionary with all fields in the given record. If a list of records is given,
returns a list of corresponding Python dictionaries.
If dates_as_iso is set (which is the default), Date and DateTime values are converted to string
using ISO 8601 format.
If expand_refs is set to 1 or higher, Reference values are replaced with a RECORD representation
of the referenced record, expanding the given number of levels.
Error values present in cells of the record are replaced with None value, and a special key of
"_error_" gets added containing the error messages for those cells. For example:
`{"Ratio": None, "_error_": {"Ratio": "ZeroDivisionError: integer division or modulo by zero"}}`
Note that care is needed to avoid circular references when using RECORD(), since it creates a
dependency on every cell in the record. In case of RECORD(rec), the cell containing this call
will be omitted from the resulting dictionary.
For example:
```
RECORD($Person)
RECORD(rec)
RECORD(People.lookupOne(First_Name="Alice"))
RECORD(People.lookupRecords(Department="HR"))
```
"""
if isinstance(record_or_list, Record):
return _prepare_record_dict(record_or_list, dates_as_iso=dates_as_iso, expand_refs=expand_refs)
try:
records = list(record_or_list)
assert all(isinstance(r, Record) for r in records)
except Exception:
raise ValueError('RECORD() requires a Record or an iterable of Records')
return [_prepare_record_dict(r, dates_as_iso=dates_as_iso, expand_refs=expand_refs)
for r in records]
def _prepare_record_dict(record, dates_as_iso=True, expand_refs=0):
table_id = record._table.table_id
docmodel = record._table._engine.docmodel
columns = docmodel.get_table_rec(table_id).columns
frame = record._table._engine.get_current_frame()
result = {'id': int(record)}
errors = {}
for col in columns:
col_id = col.colId
# Skip helper columns.
if not column.is_visible_column(col_id):
continue
# Avoid trying to access the cell being evaluated, since cycles get detected even if the
# CircularRef exception is caught. TODO This is hacky, and imperfect. If another column
# references a column containing the RECORD(rec) call, CircularRefError will still happen.
if frame and frame.node == (table_id, col_id):
continue
try:
val = getattr(record, col_id)
if dates_as_iso and isinstance(val, datetime.date):
val = val.isoformat()
elif expand_refs and isinstance(val, (Record, RecordSet)):
# Reduce expand_refs levels.
if val:
val = RECORD(val, dates_as_iso=dates_as_iso, expand_refs=expand_refs - 1)
else:
val = None
result[col_id] = val
except Exception as e:
result[col_id] = None
errors[col_id] = "%s: %s" % (type(e).__name__, str(e))
if errors:
result["_error_"] = errors
return result
# Unique sentinel value to represent that a lazy value evaluates with an exception.
_error_sentinel = object()