SELSimple Expression Language
GitHub

Your own functions, in SQL

An application's own functions (Scripting) run in memory. When the same computation exists in the database — a built-in function, an extension's, or one the application created — the application can give its function a SQL spelling for that dialect. Rules and pipelines that call the function then translate like any other, and the planner can push the steps that call it into the database.

Two implementations of one function is a promise, and it is the application's: the spelling must compute what the local function computes, for every value a rule can pass it. SEL checks the shape — the arity, the kind of each argument, a scalar result — and cannot check the meaning, so every fragment that uses a spelling says so with the caveat host-function, and strict translation refuses it. The example below checks the promise the only way it can be checked: by running each pipeline both ways over the same data and comparing.


The functions

Function Shape Local implementation SQL spelling (PostgreSQL)
SLUG(title) text → text, a plain mapping host code slug(), an SQL function
MARGIN_PCT(price, cost) two numbers → a number a SEL expression, for exact decimals margin_pct(), an SQL function
VAT_RATE(country, category) a lookup in a table a copy of vat_rates loaded at start vat_rate(), reads vat_rates
SHIPPING_COST(kg, country) logic with tiers a SEL expression shipping_cost(), PL/pgSQL
HAS_TAG(tags, tag) takes a list host code an inline tag = ANY(ARRAY[…])
WORDS(title) returns a list host code none: a list has no SQL value

The database side

The seed (seed.postgresql.sql) creates the shop's tables and four functions. Two are one-line SQL functions, one reads a table, and one is PL/pgSQL:

sql
CREATE FUNCTION slug(t text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
  SELECT trim(both '-' from regexp_replace(lower(t), '[^a-z0-9]+', '-', 'g'))
$$;

CREATE FUNCTION vat_rate(c text, k text) RETURNS numeric STABLE LANGUAGE sql AS $$
  SELECT coalesce(
    (SELECT rate FROM vat_rates WHERE country = c AND category = k),
    (SELECT rate FROM vat_rates WHERE country = c AND category = '*'),
    0)
$$;

CREATE FUNCTION shipping_cost(weight numeric, c text) RETURNS numeric IMMUTABLE LANGUAGE plpgsql AS $$
DECLARE
  base numeric;
BEGIN
  IF weight <= 1 THEN base := 4.90;
  ELSIF weight <= 5 THEN base := 9.90;
  ELSIF weight <= 20 THEN base := 19.90;
  ELSE base := 49.00;
  END IF;
  IF c <> 'PL' THEN
    RETURN base * 2;
  END IF;
  RETURN base;
END
$$;

A spelling can call a PostgreSQL FUNCTION, not a PROCEDURE: a procedure cannot appear in an expression. IMMUTABLE and STABLE let PostgreSQL plan the call like any other expression; SEL does not need them, the database does.

Registering, then spelling

The local implementations are ordinary host functions. Where the database does exact decimal arithmetic, the local side does too, by running a SEL expression — MARGIN_PCT and SHIPPING_COST are one line of SEL each — so the two agree to the last digit rather than to a float's precision:

python
def slug(args):
    out, dash = [], False
    for c in args.text(0):
        c = chr(ord(c) + 32) if 'A' <= c <= 'Z' else c          # ASCII only, as SQL's
        if 'a' <= c <= 'z' or '0' <= c <= '9':                  # [^a-z0-9]+ sees it
            if dash and out:
                out.append('-')
            out.append(c)
            dash = False
        else:
            dash = True
    return Value.text(''.join(out))


MARGIN = compile('ROUND((PRICE - COST) * 100 / PRICE, 1)')


def margin_pct(args):
    ctx = Value.none()
    ctx.set('PRICE', args.val(0))
    ctx.set('COST', args.val(1))
    return MARGIN.run(ctx)


RATES = {(r.get('country').as_text(), r.get('category').as_text()): r.get('rate')
         for r in query(conn, 'SELECT * FROM vat_rates').values()}


def vat_rate(args):
    country, category = args.text(0), args.text(1)
    rate = RATES.get((country, category))
    if rate is None:                          # not `or`: a Value with no children is falsy
        rate = RATES.get((country, '*'))
    return rate.clone() if rate is not None else Value.text('0')


SHIPPING = compile('COND(KG <= 1, 4.90, KG <= 5, 9.90, KG <= 20, 19.90, 49.00)'
                   ' * IF(COUNTRY $== "PL", 1, 2)')


def shipping_cost(args):
    ctx = Value.none()
    ctx.set('KG', args.val(0))
    ctx.set('COUNTRY', args.val(1))
    return SHIPPING.run(ctx)


def has_tag(args):
    tags, tag = args.val(0), args.text(1)
    values = tags.values() if tags.size() > 0 else [tags]      # a scalar is a list of one
    return Value.bool(any(v.as_text() == tag for v in values))


def words(args):
    out = Value.none()
    for w in slug(args).as_text().split('-'):
        if w:
            out.set(str(out.size() + 1), Value.text(w))
    return out


register_function('SLUG', 1, 1, slug)
register_function('MARGIN_PCT', 2, 2, margin_pct)
register_function('VAT_RATE', 2, 2, vat_rate)
register_function('SHIPPING_COST', 2, 2, shipping_cost)
register_function('HAS_TAG', 2, 2, has_tag)
register_function('WORDS', 1, 1, words)

Then the spellings, one define per function and dialect. The function must be registered first — a spelling for an unknown name is refused where it is written:

python
map.define('postgresql', 'funcs', 'SLUG',
           {'tpl': 'slug({0})', 'ret': 'TEXT', 'args': ['TEXT']})
map.define('postgresql', 'funcs', 'MARGIN_PCT',
           {'tpl': 'margin_pct({0}, {1})', 'ret': 'NUM', 'args': ['NUM', 'NUM']})
map.define('postgresql', 'funcs', 'VAT_RATE',
           {'tpl': 'vat_rate({0}, {1})', 'ret': 'NUM', 'args': ['TEXT', 'TEXT']})
map.define('postgresql', 'funcs', 'SHIPPING_COST',
           {'tpl': 'shipping_cost({0}, {1})', 'ret': 'NUM', 'args': ['NUM', 'TEXT']})
map.define('postgresql', 'funcs', 'HAS_TAG',
           {'tpl': '({1} = ANY(ARRAY[{0}]))', 'ret': 'BOOL', 'args': ['LIST', 'TEXT']})
# WORDS returns a list: no spelling can say that, so it has none.

args says what each argument must be, and so how it renders: NUM arguments that are not declared numbers go through the dialect's numeric guard, a BOOL or BIN where TEXT is declared is refused, and LIST expands a list known when translating — a literal list, a columns binding, a list-valued value binding — into its elements, joined with commas, for the template to wrap: ARRAY[{0}] here.

The pipelines

Each is planned for PostgreSQL, run, and compared with the same program in memory:

python
with open(os.path.join(HERE, file), encoding='utf-8') as fh:
    program = compile(fh.read())
plan = plan_hybrid(program, 'postgresql', SCHEMA)
rows = execute_hybrid(plan, runner(conn), tables if plan.pure_memory else None)
sel
# Gifts with a margin of 40% or more: a list argument, two stored functions, one statement.
PRODUCTS
  .> FILTER(HAS_TAG((_["tag1"], _["tag2"], _["tag3"]), "gift")
            AND MARGIN_PCT(_["price"], _["cost"]) >= 40)
  .> MAP(RECORD("product", SLUG(_["title"]), "margin", MARGIN_PCT(_["price"], _["cost"])))
  .> SORT_BY(_["margin"], "DESC")
sel
# Gross revenue and shipping per country: stored functions inside joins, arithmetic and a GROUP BY.
LINES
  .> LINK(ORDERS, L, O, L["order_id"] == O["order_id"])
  .> LINK(PRODUCTS, X, P, X["product_id"] == P["product_id"])
  .> MAP(RECORD(
       "country", _["country"],
       "gross", _["qty"] * _["price"] * (1 + VAT_RATE(_["country"], _["category"])),
       "shipping", SHIPPING_COST(_["weight_kg"] * _["qty"], _["country"])))
  .> BUCKET(_["country"], RECORD(
       "country", _K, "gross", SUM(_, _["gross"]), "shipping", SUM(_, _["shipping"])))
  .> SORT_BY(_["gross"], "DESC")
sel
# Words in the titles of the better-margin products: WORDS returns a list, so that pair stays in memory.
PRODUCTS
  .> FILTER(MARGIN_PCT(_["price"], _["cost"]) > 30)
  .> SORT_BY(_["product_id"])
  .> MAP(RECORD("product", SLUG(_["title"]), "words", COUNT(WORDS(_["title"]))))

And strict translation, which refuses what only the application vouches for:

python
rule = compile('SLUG(TITLE) $== "cast-iron-pan"')
title = {'TITLE': Binding.column('title', 'p', 'TEXT')}
print('   caveats    ', ', '.join(Sql.translate(rule, 'postgresql', title).caveats))
try:
    Sql.translate(rule, 'postgresql', title, {'strict': True})
except SqlError as e:
    print('   strict     ', e.code)

What it prints

text
1. gifts with a margin of 40% or more
   plan        pure_sql
   sql         SELECT "_sub1".* FROM (SELECT slug("p"."title") AS "product", margin_pct("p"."price", "p"."cost") AS "margin" FROM "products" "p" WHERE (('gift' = ANY(ARRAY["p"."tag1", "p"."tag2", "p"."tag3"])) AND (margin_pct("p"."price", "p"."cost") >= 40))) "_sub1" ORDER BY "_sub1"."margin" DESC
   caveats     host-function, text-order
   | product=harbour-lights  margin=46.1
   | product=cast-iron-pan  margin=46.0
   | product=decimal-tales-2nd-edition  margin=44.9
   in memory   same rows
2. gross revenue and shipping per country
   plan        pure_sql
   sql         SELECT CAST("_sub1"."country" AS TEXT) COLLATE "C" AS "country", COALESCE(SUM("_sub1"."gross"), 0) AS "gross", COALESCE(SUM("_sub1"."shipping"), 0) AS "shipping" FROM (SELECT "o"."country" AS "country", (CAST((CAST("l"."qty" AS NUMERIC) * CAST("p"."price" AS NUMERIC)) AS NUMERIC) * CAST((CAST(1 AS NUMERIC) + CAST(vat_rate("o"."country", "p"."category") AS NUMERIC)) AS NUMERIC)) AS "gross", shipping_cost((CAST("p"."weight_kg" AS NUMERIC) * CAST("l"."qty" AS NUMERIC)), "o"."country") AS "shipping" FROM "order_lines" "l" INNER JOIN "orders" "o" ON ("l"."order_id" = "o"."order_id") INNER JOIN "products" "p" ON ("l"."product_id" = "p"."product_id")) "_sub1" GROUP BY CAST("_sub1"."country" AS TEXT) COLLATE "C" ORDER BY COALESCE(SUM("_sub1"."gross"), 0) DESC
   caveats     host-function
   | country=PL  gross=915.059700  shipping=93.40
   | country=DE  gross=636.640100  shipping=79.40
   | country=FR  gross=369.600000  shipping=117.80
   in memory   same rows
3. words in the titles of the better-margin products
   plan        hybrid
   sql         SELECT slug("p"."title") AS "product", "p"."title" AS "title" FROM "products" "p" WHERE (margin_pct("p"."price", "p"."cost") > 30) ORDER BY "p"."product_id" ASC
   caveats     host-function
   | product=cast-iron-pan  words=3
   | product=chef-s-knife-8  words=4
   | product=decimal-tales-2nd-edition  words=4
   | product=rain-barrel-200l  words=3
   | product=harbour-lights  words=2
   | product=cardinal-rules  words=2
   in memory   same rows
4. strict translation
   caveats     host-function
   strict      E_SQL_UNSUPPORTED
  • 1 is one statement: the list of three tag columns became ARRAY["p"."tag1", "p"."tag2", "p"."tag3"], and slug() and margin_pct() are called in the select list and the WHERE. The text-order caveat is the planner noting that margin, computed in a derived table, is sorted without a declared kind.
  • 2 is one statement too: two joins, vat_rate() inside the arithmetic, shipping_cost() over the line weight, and a GROUP BY over the sums — the stored functions run inside PostgreSQL's aggregation.
  • 3 is hybrid: WORDS returns a list and has no spelling, so the planner keeps that pair in memory and lets PostgreSQL do the rest — the filter through margin_pct(), the sort, and slug() — returning the title for the local pair.

The rules

The contract, in full, is sql/MAP.md §4.7:

  • Function first, spelling second. define accepts a SEL function or a registered host function; anything else is the host's start-up error.
  • Arity is the registration's, and is recorded with the spelling. Register the function again with a different arity and translation refuses the call until the spelling is defined for the new one; the same arity keeps it.
  • reset() drops spellings, not functions.
  • The result is a scalar. ret has no list kind; a function returning a list stays in memory — give its SQL uses a scalar companion, WORD_COUNT beside WORDS.
  • The builtins' argument rules do not apply; args does (ANY, TEXT, NUM, BOOL, BIN, LIST).
  • Per dialect, inherited. A spelling defined for postgresql is used by a dialect that extends it, and by nothing else; on MariaDB the same rule is refused and runs in memory.
  • Every use carries host-function, and strict refuses it.

When the two disagree

While this example was written, pipeline 2 printed DIFFERENT. The database was right and the local VAT_RATE was not: it looked the category's rate up with rates.get(...) or rates.get(default) — and a SEL value with no children is falsy in Python, so every category-specific rate fell through to the country's default. Nothing in SEL could have caught that; the comparison did. That is the practical meaning of "the application promises": test the two implementations against each other on the data they will see, the way these examples do on every run.

View this page's Markdown on GitHub