Type checking and converting.

Types in Grist can be tricky. Grist makes use of Sqlite for data storage which, as it is well known, does not enforce data types in columns (it has a notion of type affinity of its own, however).

This is intended as a feature in Grist, which is modeled more like a spreadsheet that a traditional database: you may put a note like “???” or “ask Mark” into an integer cell, and Sqlite won’t complain about type consistency. Grist itself will take advantage of its more relaxed type system to store errors in formula columns, and so on.

The flip of the coin is that it is difficult, bordering impossible, to enforce type consistency in Grist. Of course, as long as you only insert data via the APIs (and/or Pygrister!), you can triple-check your inputs; but then, you can’t prevent a GUI user from inserting whatever they want, basically. True, you may deny write permissions to GUI users, but… then what’s the point of using Grist? Bottom line, if you are looking for a strict type system, perhaps Grist is not the tool for you.

To complicate matters further, it must be said that Grist’s type system is not without its own quirks. Sometimes it’s because the Sqlite type affinity comes into play. For instance, if you put a numerical string into an integer column (say, INSERT INTO mytable (myint) VALUES ("42");) Sqlite will happily convert it for you: thus, the same applies if you try this with the Grist APIs.

Sometimes, Grist will override the Sqlite default behaviour: for instance, you can’t put a float into an integer column, neither with the GUI nor with the API, even if Sqlite would allow that.

And sometimes, the GUI and the API will behave in a subtly different way. For instance, a Grist “date” is stored as an integer in Sqlite. In fact, if you use the API, you should pass an integer timestamp. However, if you just write a number in the GUI cell, that will be treated as text, since you didn’t use the widget to select a date… but wait! You can’t actually store a numerical string in an integer Sqlite column, because it will be converted (and thus interpreted as a timestamp). To circumvent Sqlite type affinity, Grist will silently convert any numerical string inside a date column to an exadecimal blob and store it in this format. As a result, you can put a number in a date column using the GUI, but not with the API. (The same goes with a boolean column.)

There are many of these undocumented quirks and it is difficult to keep up with them all. On top of this, also your needs and use cases will vary. Sometimes you’ll need a type check just for a few columns, instead of the whole table; sometimes it’s ok to have the occasional string, or Null value inserted, and so on.

This is why Pygrister declines to check and/or convert types for you. Instead, Pygrister will offer a hook to execute custom converter functions, that you can tailor to your needs.

Converter functions.

A converter function is simply a function that accepts a single argument and returns a single value.

You may write both “input converters” (for data that you are about to write to the database) and “output converters” (for data that you are pulling from the database).

Given the relaxed nature of Grist/Sqlite type system, you are not required to be consistent about the types returned by your converters. Something like this

def maybe_to_int(value):
    try:
        res = int(value)
    except:
        return None
    if res == 42:
        return "the answer"
    return res

could totally work as an input converter for an integer column in Grist.

On the other hand, keep in mind that input converters should return, at the very least, something that you can safely pass to the Grist API, i.e. a json-izable value. For instance, if you need to store an exact decimal number in Grist, you can’t have your converter simply return a decimal.Decimal instance (no more that you could pass it directly to the API!). You may want to return Decimal.to_eng_string instead, and store the value as a string.

Also note that input converters are applied before calling the API, of course: Pygrister has no say in any further mangling of your data, once the converted values are passed to the Grist API. For instance, as discussed above, you can store a string into a Date column, but not a numerical string (at least, not with the API). Hence, if you have an input converter for a Date column which may return numerical strings, keep in mind that your values will still be converted to integers by Sqlite, then interpreted as dates by Grist, which may not be the way you intended. (A solution would be returning a hex blob instead, replicating the marshalling algorithm used internally by Grist for such cases… which is undocumented, as far as we know.)

Output converters are easier: they receive the value retrieved by the API, and convert it to whatever you want - since it is your own code that consumes the value from now on, Grist is no longer involved and you can choose freely. You only have to be prepared to accept input values of different types, since a GUI user will have more freedom in entering data anyway.

Register a converter.

Once you have written your converter functions, they have to be registered so that Pygrister learns about them and can use them.

An instance of the pygrister.api.GristApi class keeps an internal register of the converters to be applied. The register is just a dictionary - there is one in_converter for input converters, and one out_converter for output converters.

Register dictionaries are nested, mapped by table names and column names, like this:

from pygrister.api import GristApi
grist = GristApi()
gtist.in_converter = {
    'table_1': {'columnA': myconverter,
                'columnB': another_converter},
    'table_2': {'col_a': fancy_converter},
                     }

where myconverter etc. are, of course, the names of your previously defined converter functions.

You are not required to register converters for every table in your database, or for every column in your table. If Pygrister doesn’t find a converter for a specific column, it will simply pass the values as they are. Of course, you may register the same converter for more that one column.

Converters may also be passed at creation time:

in_conv = {...}
out_conv = {...}
grist = GristApi(in_converter=in_conv, out_converter=out_conv)

You may add or remove converters at runtime, by simply manipulating the register dictionaries:

grist.in_converter['table_1']['columnB'] = new_converter # change converter
grist.in_converter.pop(['table_1']['columnA'])  # delete converter

To delete all converters, set the register to an empty dictionary:

grist.in_converter = dict() # reset input converters

See the test suite for more example of converter usage.

Where converters are applied.

Once registered, input converters are applied to the following APIs:

  • GristApi.add_records

  • GristApi.update_records

  • GristApi.add_update_records

Output converters are used with the following APIs:

  • GristApi.list_records

  • GristApi.run_sql (special)

  • GritsApi.run_sql_with_args (special)

To use converters with the two “sql” APIs, you will have to register them under the special sql key, as in

grist.out_converter = {'sql': {'columnA': converter}}

This is because a sql query can pick data from any table, even multiple tables at the same time. If you want to run several different queries, you will have to register your different custom sql converters every time.

Finally, we remind you that currently the run_sql* Grist APIs are limited to SELECT statements only: there is no point in registering input converters for them.

Handling conversion errors.

A converter, just like any Python function, may throw an exception. Pygrister handles this in a different way for input and output converters.

At the moment, Pygrister does not intervene when an input converter fails, and simply let the resulting exception propagate (this may change in the future). The reasoning for this is that the data about to be written to the database should be entirely your responsibility, and Pygrister will simply refuse to guess. Therefore, you have to catch your own exceptions. The only guarantee is that the converters are applied immediately before passing the data to the API: if even one record triggers a failure in the converter, no data will be written to the database.

An output converter, on the other hand, is handled a little more graciously. Should it fail with a ValueError or TypeError (the most common ones in such cases), then Pygrister will catch the exception and return either None (for null values) or str(value) (for anything else). Since str(x) always works in Python, this means that your output converter is almost always guaranteed to return “something” that you can work with - well, almost!, unless the converter fails with another, more exotic exception, that is.

This is meant as a way of saving you from having to write all the boring try/except (or if/else) blocks in your output converters, to account for the occasional null value or plain string that you may find in a Grist (Sqlite) column. For instance, this is usually not necessary:

def my_output_converter(value):
    if value is None:   # this is handled by Pygrister
        return None
    try:
        return some_fancy_data_conversion(value)
    except (TypeError, ValueError):  # also this
        returt str(value)

In many cases, this could be written just as

def my_output_converter(value):
    # just do your actual conversion here
    return some_fancy_data_conversion(value)

Of course, if you don’t like Pygrister’s default behaviour, you can always catch the ValueError or TypeError yourself, and write your own handlers.

A note on converting date/times.

A common use case for converters is adapting Grist timestamps in Date columns from/to complex Python types such as datetime.date. If you are writing a converter like this, please note that Grist has a notion of “local time”: Grist documents set a default timezone when they are first created; date/time columns set their own timezones (defaulting to the global document timezone); then the GUI widget may (or may not!) compensate for the users’ local time offset when converting to database timestamps. See the Grist documentation for details about this.

The problem is, contrary to the GUI in the browser, the APIs have no way to know your local timezone: hence, if you insert a “naive” timestamp, without compensating for the timezone difference, chances are that you will end up with a different time value than you intended. A Pygrister converter can be the right place to address this, but you have to do it right.

For instance, this won’t work as expected:

from datetime import datetime
conv = {'mytable':
            {'datecol': lambda i: int(datetime.timestamp(i))}
        }
grist = GristApi(in_converter=conv)
the_date = datetime(2024, 8, 15)
grist.add_records('mytable', [{'datecol': the_date}])

The problem here is that the_date is a “naive” date object, without timezone info. Grist will assume UTC+0, and further mangle the timestamp produced by the converter to compensate for the column’s own timezone. Since your local time is probably different than UTC+0, when you’ll check the inserted value with the GUI, you will likely see a different thing. (The test suite has examples of this behaviour.)

One possible solution is to use a timezone-aware Python object instead (see the module zoneinfo for this)… however, you may also decide to set a different timezone for the Grist document, and/or columns, and/or use only UTC+0 dates… Timezone problems are notoriously tricky and there’s no easy fix for that. Make sure to test extensively your converters!