SELSimple Expression Language
GitHub

Scripting with host functions

SEL cannot reach the world on its own — no files, no network, no clock — and that is deliberate: the application decides what a script may touch, by registering functions. A registered function is called like a builtin, receives its arguments evaluated and typed-checked by the same readers the builtins use, and returns a SEL value.

That turns SEL into a small, safe scripting language for an application's policy. Here a warehouse registers four functions — STOCK and WEIGHT read the catalogue, RESERVE takes stock, NOTIFY queues a message — and a script the warehouse team owns decides, for each order, whether to ship it, how, and whom to tell.

Registering functions

One call per function: a name, the least and most arguments it takes, and the code. Register before compiling the scripts that call them — an unknown name is a compile-time error.

python
def stock(args):
    return Value.int(inventory.get(args.text(0), 0))


def reserve(args):
    sku, qty = args.text(0), args.non_neg_int(1)
    if inventory.get(sku, 0) < qty:
        return Value.bool(False)
    inventory[sku] -= qty
    return Value.bool(True)


def weight(args):
    return Value.text(weights.get(args.text(0), '0'))


def notify(args):
    outbox.append(f'{args.text(0)}: {args.text(1)}')
    return Value.bool(True)


register_function('STOCK', 1, 1, stock)
register_function('RESERVE', 2, 2, reserve)
register_function('WEIGHT', 1, 1, weight)
register_function('NOTIFY', 2, 2, notify)

The rules are the same in every host (spec §8.1):

  • A host function adds to the language: the name of a builtin, or a reserved word, is refused. Registering the same name again replaces the host function for programs compiled afterwards.
  • It is strict: its arguments are evaluated once, left to right, before it runs. The typed readers — text, bool, int, nonNegInt and their spellings — raise the usual error at that argument's position, so a script that passes the wrong kind gets a message pointing at its own mistake.
  • It returns a new value and must not modify its arguments.
  • To fail on purpose, raise a SelError with a code from the catalogue and the argument's position (args.posOf(i) and its spellings); anything else it throws is the host's own exception and passes through unchanged.

The script

fulfil.sel is plain text that every host reads and compiles:

sel
# What happens to one order. The warehouse team edits this file; the
# application never changes when it does.
#
# STOCK, RESERVE, WEIGHT and NOTIFY are not SEL builtins: the application
# registers them (examples/scripting/*), and they are how the script reaches
# the inventory and the outbox. ORDER, COUNTRY and ITEMS are the order itself.

MISSING = ITEMS .> FILTER(STOCK(_["sku"]) < _["qty"]);

IF(COUNT(MISSING) > 0,
   (NOTIFY("purchasing", "order {ORDER} waits for " & JOIN(MAP(MISSING, _["sku"]), ", "));
    "backorder"),
   (ALL(ITEMS, RESERVE(_["sku"], _["qty"]));
    KG = SUM(ITEMS, WEIGHT(_["sku"]) * _["qty"]);
    CARRIER = COND(COUNTRY $== "PL" AND KG <= 25, "inpost",
                   KG > 30, "freight",
                   "courier");
    NOTIFY("customer", "order {ORDER} ships by {CARRIER}, {KG} kg");
    "ship by " & CARRIER))

It computes (MISSING, KG), decides (IF, COND), and acts through the host's functions — and its inputs are exactly what dependencies() says: COUNTRY, ITEMS and ORDER. Nothing else is reachable.

Running it

python
with open(os.path.join(HERE, 'fulfil.sel'), encoding='utf-8') as fh:
    fulfil = compile(fh.read())             # after registering: names resolve now

print('1. the script reads', ', '.join(fulfil.dependencies()))
print('2. orders')
orders = [
    {'ORDER': 'A-1', 'COUNTRY': 'PL', 'ITEMS': [{'sku': 'LAMP-01', 'qty': '2'},
                                               {'sku': 'CHAIR-03', 'qty': '1'}]},
    {'ORDER': 'A-2', 'COUNTRY': 'DE', 'ITEMS': [{'sku': 'DESK-02', 'qty': '1'},
                                               {'sku': 'CHAIR-03', 'qty': '2'}]},
    {'ORDER': 'A-3', 'COUNTRY': 'PL', 'ITEMS': [{'sku': 'LAMP-01', 'qty': '3'}]},
    {'ORDER': 'A-4', 'COUNTRY': 'PL', 'ITEMS': [{'sku': 'LAMP-01', 'qty': 'two'}]},
]
for order in orders:
    try:
        decision = fulfil.run(Value.from_native(order)).as_text()
    except SelError as e:
        decision = f'{e.code} at {e.line}:{e.col}'
    print(f'   {order["ORDER"]}  {decision}')

What it prints

text
1. the script reads COUNTRY, ITEMS, ORDER
2. orders
   A-1  ship by inpost
   A-2  ship by freight
   A-3  backorder
   A-4  E_NOT_NUM at 8:46
3. outbox
   customer: order A-1 ships by inpost, 10.7 kg
   customer: order A-2 ships by freight, 43.0 kg
   purchasing: order A-3 waits for LAMP-01
4. stock left
   CHAIR-03  3
   DESK-02   0
   LAMP-01   2

Order A-3 finds the lamps already reserved by A-1 and is put on back order; order A-4 has a quantity that is not a number, and the error names the script's line and column — STOCK(_["sku"]) < _["qty"], where "two" met <.

Host functions and SQL

A host function runs in memory. By default the SQL translator refuses a rule that calls it (E_SQL_UNSUPPORTED), and the hybrid planner keeps the steps that call it in memory: the database does what it can, and the host function runs over the rows that come back.

When the database can compute the same thing — a built-in, an extension, a stored function the application created — the application can give its function a SQL spelling for that dialect, and the function then translates like any builtin:

python
map.define('postgresql', 'funcs', 'VAT_RATE',
           {'tpl': 'vat_rate({0}, {1})', 'ret': 'NUM', 'args': ['TEXT', 'TEXT']})

The application promises that vat_rate() computes what its VAT_RATE computes; SEL checks the shape and marks every such fragment with the caveat host-function. Your own functions, in SQL is the worked example, with PostgreSQL stored functions and all five languages.

View this page's Markdown on GitHub