|
|
|
"""
|
|
|
|
The basic types in Grist include Numeric, Text, Reference, Date, and others. Each type needs a
|
|
|
|
representation in storage (database), in communication messages, and in the memory of JS and
|
|
|
|
Python interpreters. Each type also needs a convenient Python representation when used in
|
|
|
|
formulas. Any typed column may also contain values of a wrong type, and those also need a
|
|
|
|
representation. Finally, every type defines a default value, used when the column is first
|
|
|
|
created, and for new records.
|
|
|
|
|
|
|
|
For values of type int or bool, It's possible to save some memory by using JS typed arrays or
|
|
|
|
Python's array.array. However, at least on the Python side, it means that we need an additional
|
|
|
|
data structure for values of the wrong type, and the memory savings aren't that great to be worth
|
|
|
|
the extra complexity.
|
|
|
|
"""
|
|
|
|
import csv
|
|
|
|
import datetime
|
|
|
|
import json
|
|
|
|
import six
|
|
|
|
import objtypes
|
|
|
|
from objtypes import AltText
|
|
|
|
import moment
|
|
|
|
import logger
|
|
|
|
from records import Record, RecordSet
|
|
|
|
log = logger.Logger(__name__, logger.INFO)
|
|
|
|
|
|
|
|
NoneType = type(None)
|
|
|
|
|
|
|
|
# Note that this matches the defaults in app/common/gristTypes.js
|
|
|
|
_type_defaults = {
|
|
|
|
'Any': None,
|
|
|
|
'Attachments': None,
|
|
|
|
'Blob': None,
|
|
|
|
'Bool': False,
|
|
|
|
'Choice': u'',
|
|
|
|
'ChoiceList': None,
|
|
|
|
'Date': None,
|
|
|
|
'DateTime': None,
|
|
|
|
'Id': 0,
|
|
|
|
'Int': 0,
|
|
|
|
'ManualSortPos': float('inf'),
|
|
|
|
'Numeric': 0.0,
|
|
|
|
'PositionNumber': float('inf'),
|
|
|
|
'Ref': 0,
|
|
|
|
'RefList': None,
|
|
|
|
'Text': u'',
|
|
|
|
}
|
|
|
|
|
|
|
|
def get_type_default(col_type):
|
|
|
|
col_type = col_type.split(':', 1)[0] # Strip suffix for Ref:, DateTime:, etc.
|
|
|
|
return _type_defaults.get(col_type, None)
|
|
|
|
|
|
|
|
def formulaType(grist_type):
|
|
|
|
"""
|
|
|
|
formulaType(gristType) is a decorator which saves the type as the 'grist_type' attribute
|
|
|
|
on the decorated formula function. It allows the formula columns to be typed.
|
|
|
|
"""
|
|
|
|
def wrapper(method):
|
|
|
|
method.grist_type = grist_type
|
|
|
|
return method
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
|
|
|
def ifError(value, value_if_error):
|
|
|
|
"""
|
|
|
|
Return `value` if it is valid, or `value_if_error` otherwise. Similar to Excel's IFERROR.
|
|
|
|
"""
|
|
|
|
# TODO: this should ideally handle exception values and values of wrong type returned by
|
|
|
|
# formulas, but it's unclear how to make that work.
|
|
|
|
return value_if_error if isinstance(value, AltText) else value
|
|
|
|
|
|
|
|
|
|
|
|
# Unique sentinel object to tell BaseColumnType constructor to use get_type_default().
|
|
|
|
_use_type_default = object()
|
|
|
|
|
|
|
|
|
|
|
|
class BaseColumnType(object):
|
|
|
|
"""
|
|
|
|
Base class for all column types.
|
|
|
|
"""
|
|
|
|
_global_creation_order = 0
|
|
|
|
|
|
|
|
def __init__(self, default=_use_type_default):
|
|
|
|
self.default = get_type_default(self.typename()) if default is _use_type_default else default
|
|
|
|
self.default_func = None
|
|
|
|
|
|
|
|
# Slightly silly, but it allows us to extract the order in which fields are listed in the
|
|
|
|
# model definition, without looking back at the schema.
|
|
|
|
self._creation_order = BaseColumnType._global_creation_order
|
|
|
|
BaseColumnType._global_creation_order += 1
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def typename(cls):
|
|
|
|
"""
|
|
|
|
Returns the name of the type, e.g. "Int", "Ref", or "RefList".
|
|
|
|
"""
|
|
|
|
return cls.__name__
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, _value):
|
|
|
|
"""
|
|
|
|
Returns whether the given value belongs to this type. A cell may contain a wrong-type value
|
|
|
|
(e.g. alttext, error), but formulas will only see right-type values, defaulting to the
|
|
|
|
column's default.
|
|
|
|
|
|
|
|
If is_right_type returns true, it must be possible to store the value (so with typed arrays,
|
|
|
|
it must fit the type's restrictions).
|
|
|
|
"""
|
|
|
|
return True
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
"""
|
|
|
|
Converts a value of any type to one of our type (for which is_right_type is true) and returns
|
|
|
|
it, or throws an exception. This is the method that should be overridden by subclasses.
|
|
|
|
"""
|
|
|
|
return value
|
|
|
|
|
|
|
|
def convert(self, value_to_convert):
|
|
|
|
"""
|
|
|
|
Converts a value of any type to this type, returning either a value of the right type, or
|
|
|
|
alttext, or error. It never throws, and should not be overridden by subclasses (override
|
|
|
|
do_convert instead).
|
|
|
|
"""
|
|
|
|
# Don't try to convert errors, although some day we may want to attempt it (e.g. if an error
|
|
|
|
# contains original text, we may want to try to convert the original text).
|
|
|
|
if isinstance(value_to_convert, objtypes.RaisedException):
|
|
|
|
return value_to_convert
|
|
|
|
|
|
|
|
try:
|
|
|
|
return self.do_convert(value_to_convert)
|
|
|
|
except Exception as e:
|
|
|
|
# If conversion failed, return a string to serve as alttext.
|
|
|
|
try:
|
|
|
|
return six.text_type(value_to_convert)
|
|
|
|
except Exception:
|
|
|
|
# If converting to string failed, we should still produce something.
|
|
|
|
return objtypes.safe_repr(value_to_convert)
|
|
|
|
|
|
|
|
|
|
|
|
# This is a user-facing method, hence the camel-case naming, as for `lookupRecords` and such.
|
|
|
|
@classmethod
|
|
|
|
def typeConvert(cls, value):
|
|
|
|
"""
|
|
|
|
Convert a value from a different type to something that this type can accept, as when
|
|
|
|
explicitly converting a column type. Note that usual conversion (such as converting numbers to
|
|
|
|
strings or vice versa) will still apply to the returned value.
|
|
|
|
"""
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
class Text(BaseColumnType):
|
|
|
|
"""
|
|
|
|
Text is the type for a field holding string (text) data.
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
if isinstance(value, six.binary_type):
|
|
|
|
return value.decode('utf8')
|
|
|
|
elif value is None:
|
|
|
|
return None
|
|
|
|
else:
|
|
|
|
return six.text_type(value)
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
return isinstance(value, (six.string_types, NoneType))
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def typeConvert(cls, value):
|
|
|
|
if value is None:
|
|
|
|
# When converting NULLs (that typically show up as a plain empty cell for Numeric or Date
|
|
|
|
# columns) to Text, it makes more sense to end up with a plain blank text cell.
|
|
|
|
return ''
|
|
|
|
elif isinstance(value, bool):
|
|
|
|
# Normalize True/False to true/false (Toggle columns use true/false).
|
|
|
|
return str(value).lower()
|
|
|
|
else:
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
class Blob(BaseColumnType):
|
|
|
|
"""
|
|
|
|
Blob hold binary data.
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
return value
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
return isinstance(value, (six.binary_type, NoneType))
|
|
|
|
|
|
|
|
|
|
|
|
class Any(BaseColumnType):
|
|
|
|
"""
|
|
|
|
Any is the type that can hold any kind of value. It's used to hold computed values.
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
# Convert AltText values to plain text when assigning to type Any.
|
|
|
|
return six.text_type(value) if isinstance(value, AltText) else value
|
|
|
|
|
|
|
|
|
|
|
|
class Bool(BaseColumnType):
|
|
|
|
"""
|
|
|
|
Bool is the type for a field holding boolean data.
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
# We'll convert any falsy value to False, non-zero numbers to True, and only strings we
|
|
|
|
# recognize. Everything else will result in alttext.
|
|
|
|
if not value:
|
|
|
|
return False
|
|
|
|
if isinstance(value, (float, six.integer_types)):
|
|
|
|
return True
|
|
|
|
if isinstance(value, AltText):
|
|
|
|
value = six.text_type(value)
|
|
|
|
if isinstance(value, six.string_types):
|
|
|
|
if value.lower() in ("false", "no", "0"):
|
|
|
|
return False
|
|
|
|
if value.lower() in ("true", "yes", "1"):
|
|
|
|
return True
|
|
|
|
raise objtypes.ConversionError("Bool")
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
return isinstance(value, (bool, NoneType))
|
|
|
|
|
|
|
|
|
|
|
|
class Int(BaseColumnType):
|
|
|
|
"""
|
|
|
|
Int is the type for a field holding integer data.
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
if value in ("", None):
|
|
|
|
return None
|
|
|
|
# Convert to float first, since python does not allow casting strings with decimals to int
|
|
|
|
ret = int(float(value))
|
|
|
|
if not objtypes.is_int_short(ret):
|
|
|
|
raise OverflowError("Integer value too large")
|
|
|
|
return ret
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
return value is None or (isinstance(value, six.integer_types) and not isinstance(value, bool) and
|
|
|
|
objtypes.is_int_short(value))
|
|
|
|
|
|
|
|
|
|
|
|
class Numeric(BaseColumnType):
|
|
|
|
"""
|
|
|
|
Numeric is the type for a field holding numerical data.
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
return float(value) if value not in ("", None) else None
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
# TODO: Python distinguishes ints from floats, while JS only has floats. A value that can be
|
|
|
|
# interpreted as an int will upon being entered have type 'float', but after database reload
|
|
|
|
# will have type 'int'.
|
|
|
|
return isinstance(value, (float, six.integer_types, NoneType)) and not isinstance(value, bool)
|
|
|
|
|
|
|
|
|
|
|
|
class Date(Numeric):
|
|
|
|
"""
|
|
|
|
Date is the type for a field holding date data (no timezone).
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
if value in ("", None):
|
|
|
|
return None
|
|
|
|
elif isinstance(value, datetime.datetime):
|
|
|
|
return moment.dt_to_ts(value)
|
|
|
|
elif isinstance(value, datetime.date):
|
|
|
|
return moment.date_to_ts(value)
|
|
|
|
elif isinstance(value, (float, six.integer_types)):
|
|
|
|
return float(value)
|
|
|
|
elif isinstance(value, six.string_types):
|
|
|
|
# We also accept a date in ISO format (YYYY-MM-DD), the time portion is optional and ignored
|
|
|
|
return moment.parse_iso_date(value)
|
|
|
|
else:
|
|
|
|
raise objtypes.ConversionError('Date')
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
return isinstance(value, (float, six.integer_types, NoneType))
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def typeConvert(cls, value, date_format, timezone='UTC'): # pylint: disable=arguments-differ
|
|
|
|
# Note: the timezone argument is used in DateTime conversions, allows sharing this method.
|
|
|
|
try:
|
|
|
|
return moment.parse(value, date_format, timezone)
|
|
|
|
except Exception:
|
|
|
|
return value
|
|
|
|
|
|
|
|
class DateTime(Date):
|
|
|
|
"""
|
|
|
|
DateTime is the type for a field holding date and time data.
|
|
|
|
"""
|
|
|
|
def __init__(self, timezone="America/New_York", default=_use_type_default):
|
|
|
|
super(DateTime, self).__init__(default)
|
|
|
|
|
|
|
|
try:
|
|
|
|
self.timezone = moment.Zone(timezone)
|
|
|
|
except KeyError:
|
|
|
|
self.timezone = moment.Zone('UTC')
|
|
|
|
|
|
|
|
def do_convert(self, value):
|
|
|
|
if value in ("", None):
|
|
|
|
return None
|
|
|
|
elif isinstance(value, datetime.datetime):
|
|
|
|
return moment.dt_to_ts(value, self.timezone)
|
|
|
|
elif isinstance(value, datetime.date):
|
|
|
|
return moment.date_to_ts(value, self.timezone)
|
|
|
|
elif isinstance(value, (float, six.integer_types)):
|
|
|
|
return float(value)
|
|
|
|
elif isinstance(value, six.string_types):
|
|
|
|
# We also accept a datetime in ISO format (YYYY-MM-DD[T]HH:mm:ss)
|
|
|
|
return moment.parse_iso(value, self.timezone)
|
|
|
|
else:
|
|
|
|
raise objtypes.ConversionError('DateTime')
|
|
|
|
|
|
|
|
class Choice(Text):
|
|
|
|
"""
|
|
|
|
Choice is the type for a field holding one of a set of acceptable string (text) values.
|
|
|
|
TODO: Type should possibly be aware of the allowed choices, and be considered invalid
|
|
|
|
when its value isn't one of them
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
class ChoiceList(BaseColumnType):
|
|
|
|
"""
|
|
|
|
ChoiceList is the type for a field holding a list of strings from a set of acceptable choices.
|
|
|
|
"""
|
|
|
|
def do_convert(self, value):
|
|
|
|
if not value:
|
|
|
|
return None
|
|
|
|
elif isinstance(value, six.string_types):
|
|
|
|
# If it's a string that looks like JSON, try to parse it as such.
|
|
|
|
if value.startswith('['):
|
|
|
|
try:
|
|
|
|
return tuple(six.text_type(item) for item in json.loads(value))
|
|
|
|
except Exception:
|
|
|
|
pass
|
|
|
|
return value
|
|
|
|
else:
|
|
|
|
# Accepts other kinds of iterables; if that doesn't work, fail the conversion too.
|
|
|
|
return tuple(six.text_type(item) for item in value)
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
return value is None or (isinstance(value, (tuple, list)) and
|
|
|
|
all(isinstance(item, six.string_types) for item in value))
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def typeConvert(cls, value):
|
|
|
|
if value is None:
|
|
|
|
return value
|
|
|
|
if isinstance(value, six.string_types) and not value.startswith('['):
|
|
|
|
# Try to parse as CSV. If this doesn't work, we'll still try usual conversions later.
|
|
|
|
try:
|
|
|
|
tags = next(csv.reader([value]))
|
|
|
|
return tuple(t.strip() for t in tags if t.strip())
|
|
|
|
except Exception:
|
|
|
|
pass
|
|
|
|
if not isinstance(value, (tuple, list)):
|
|
|
|
value = [Choice.typeConvert(value)]
|
|
|
|
return value
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def toString(cls, value):
|
|
|
|
if isinstance(value, (tuple, list)):
|
|
|
|
try:
|
|
|
|
buf = six.StringIO()
|
|
|
|
csv.writer(buf).writerow(value)
|
|
|
|
return buf.getvalue().strip()
|
|
|
|
except Exception:
|
|
|
|
pass
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
class PositionNumber(BaseColumnType):
|
|
|
|
"""
|
|
|
|
PositionNumber is the type for a position field used to order records in record lists.
|
|
|
|
"""
|
|
|
|
# The 'inf' default is used by prepare_new_values() in column.py, which always changes it to
|
|
|
|
# finite numbers, but relies on it to keep newly-added records below existing ones by default.
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
return float(value) if value not in ("", None) else float('inf')
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
# Same as Numeric, but does not support None.
|
|
|
|
return isinstance(value, (float, six.integer_types)) and not isinstance(value, bool)
|
|
|
|
|
|
|
|
|
|
|
|
class ManualSortPos(PositionNumber):
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
class Id(BaseColumnType):
|
|
|
|
"""
|
|
|
|
Id is the type for the record ID field, present automatically in each table.
|
|
|
|
The default of 0 points to the always-present empty record. Real records start at index 1.
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
|
|
def do_convert(cls, value):
|
|
|
|
# Just like Int.do_convert, but skips conversion via float. This also makes it work for Record
|
|
|
|
# types, which override int() conversion to yield the row ID. Arbitrary values should not be
|
|
|
|
# cast to ints as it results in false hits when converting numerical values to reference ids.
|
|
|
|
if not value:
|
|
|
|
return 0
|
|
|
|
if not isinstance(value, (int, Record)):
|
|
|
|
raise TypeError("Cannot convert to Id type")
|
|
|
|
ret = int(value)
|
|
|
|
if not objtypes.is_int_short(ret):
|
|
|
|
raise OverflowError("Integer value too large")
|
|
|
|
return ret
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
return (isinstance(value, six.integer_types) and not isinstance(value, bool) and
|
|
|
|
objtypes.is_int_short(value))
|
|
|
|
|
|
|
|
|
|
|
|
class Reference(Id):
|
|
|
|
"""
|
|
|
|
Reference is the type for a field holding a reference into another table.
|
|
|
|
|
|
|
|
Note that if `foo` is a Reference('Foo'), then `rec.foo` is of type `Foo.Record`. The ID of that
|
|
|
|
record is available as `rec.foo._row_id`. It is equivalent to `rec.foo.id`, except that
|
|
|
|
accessing `id`, as other public properties, involves a lookup in `Foo` table.
|
|
|
|
"""
|
|
|
|
def __init__(self, table_id):
|
|
|
|
super(Reference, self).__init__()
|
|
|
|
self.table_id = table_id
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def typename(cls):
|
|
|
|
return "Ref"
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def typeConvert(cls, value, ref_table, visible_col=None): # pylint: disable=arguments-differ
|
|
|
|
if value and ref_table and visible_col:
|
|
|
|
return ref_table.lookupOne(**{visible_col: value}) or six.text_type(value)
|
|
|
|
else:
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
class ReferenceList(BaseColumnType):
|
|
|
|
"""
|
|
|
|
ReferenceList stores a list of references into another table.
|
|
|
|
"""
|
|
|
|
def __init__(self, table_id):
|
|
|
|
super(ReferenceList, self).__init__()
|
|
|
|
self.table_id = table_id
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def typename(cls):
|
|
|
|
return "RefList"
|
|
|
|
|
|
|
|
def do_convert(self, value):
|
|
|
|
if isinstance(value, six.string_types):
|
|
|
|
# If it's a string that looks like JSON, try to parse it as such.
|
|
|
|
if value.startswith('['):
|
|
|
|
try:
|
|
|
|
value = json.loads(value)
|
|
|
|
except Exception:
|
|
|
|
pass
|
|
|
|
|
|
|
|
if isinstance(value, RecordSet):
|
|
|
|
assert value._table.table_id == self.table_id
|
|
|
|
return objtypes.RecordList(value._row_ids, group_by=value._group_by, sort_by=value._sort_by)
|
|
|
|
elif not value:
|
|
|
|
# Represent an empty ReferenceList as None (also its default value). Formulas will see [].
|
|
|
|
return None
|
|
|
|
return [Reference.do_convert(val) for val in value]
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_right_type(cls, value):
|
|
|
|
return value is None or (isinstance(value, list) and
|
|
|
|
all(Reference.is_right_type(val) for val in value))
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def typeConvert(cls, value, ref_table, visible_col=None): # noqa # pylint: disable=arguments-differ
|
|
|
|
# TODO this is based on Reference.typeConvert.
|
|
|
|
# It doesn't make much sense as a conversion but I don't know what would
|
|
|
|
if value and ref_table and visible_col:
|
|
|
|
return ref_table.lookupRecords(**{visible_col: value}) or six.text_type(value)
|
|
|
|
else:
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
class Attachments(ReferenceList):
|
|
|
|
"""
|
|
|
|
Currently attachment type is the field for holding data for attachments.
|
|
|
|
"""
|
|
|
|
def __init__(self):
|
|
|
|
super(Attachments, self).__init__('_grist_Attachments')
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def typeConvert(cls, value): # noqa # pylint: disable=arguments-differ
|
|
|
|
# Don't use ReferenceList.typeConvert which is called with a different number of arguments
|
|
|
|
return value
|