Extending SEL
There are three ways to extend SEL, and they differ in who does it and what it costs:
| Who | Where it lives | Cost | |
|---|---|---|---|
| Host functions | an application | the application's own code, at start-up | one call per function, in one language |
| Extending the SQL layer | an application | the application's own code, at start-up | one call per difference from a shipped dialect |
| A builtin for everyone | a contributor | the specification, the suite and all five hosts | a change to the language |
The first two change nothing about SEL and need no one's agreement. The third is how SEL itself grows — and why it grows slowly.
Host functions
An application adds functions of its own — a stock lookup, a formatter, a message queue — with one registration call. A registered function is called like a builtin and is held to the same rules: strict, arity checked when a rule is compiled, arguments read through the same typed readers, errors with positions.
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)function stock(args) {
return Value.int(inventory.get(args.text(0)) ?? 0);
}
function reserve(args) {
const sku = args.text(0), qty = args.nonNegInt(1);
if ((inventory.get(sku) ?? 0) < qty) return Value.bool(false);
inventory.set(sku, inventory.get(sku) - qty);
return Value.bool(true);
}
function weight(args) {
return Value.text(weights.get(args.text(0)) ?? '0');
}
function notify(args) {
outbox.push(`${args.text(0)}: ${args.text(1)}`);
return Value.bool(true);
}
registerFunction('STOCK', 1, 1, stock);
registerFunction('RESERVE', 2, 2, reserve);
registerFunction('WEIGHT', 1, 1, weight);
registerFunction('NOTIFY', 2, 2, notify);Sel::registerFunction('STOCK', 1, 1, function (Args $args) use (&$inventory): Value {
return Value::int($inventory[$args->text(0)] ?? 0);
});
Sel::registerFunction('RESERVE', 2, 2, function (Args $args) use (&$inventory): Value {
[$sku, $qty] = [$args->text(0), $args->nonNegInt(1)];
if (($inventory[$sku] ?? 0) < $qty) {
return Value::bool(false);
}
$inventory[$sku] -= $qty;
return Value::bool(true);
});
Sel::registerFunction('WEIGHT', 1, 1, function (Args $args) use ($weights): Value {
return Value::text($weights[$args->text(0)] ?? '0');
});
Sel::registerFunction('NOTIFY', 2, 2, function (Args $args) use (&$outbox): Value {
$outbox[] = "{$args->text(0)}: {$args->text(1)}";
return Value::bool(true);
});sel::register_function("STOCK", 1, 1, [](sel::HostArgs& args) {
const auto found = inventory.find(args.text(0));
return sel::Value::integer(found == inventory.end() ? 0 : found->second);
});
sel::register_function("RESERVE", 2, 2, [](sel::HostArgs& args) {
const std::string sku = args.text(0);
const long long qty = args.non_neg_int(1);
const auto found = inventory.find(sku);
if (found == inventory.end() || found->second < qty) return sel::Value::boolean(false);
found->second -= qty;
return sel::Value::boolean(true);
});
sel::register_function("WEIGHT", 1, 1, [](sel::HostArgs& args) {
const auto found = weights.find(args.text(0));
return sel::Value::text(found == weights.end() ? "0" : found->second);
});
sel::register_function("NOTIFY", 2, 2, [](sel::HostArgs& args) {
outbox.push_back(args.text(0) + ": " + args.text(1));
return sel::Value::boolean(true);
});(defun stock (args)
(sel:make-int (gethash (sel:args-text args 0) *inventory* 0)))
(defun reserve (args)
(let ((sku (sel:args-text args 0))
(qty (sel:args-non-neg-int args 1)))
(cond ((< (gethash sku *inventory* 0) qty)
(sel:make-bool nil))
(t
(decf (gethash sku *inventory*) qty)
(sel:make-bool t)))))
(defun weight (args)
(sel:make-text (gethash (sel:args-text args 0) *weights* "0")))
(defun notify (args)
(setf *outbox* (append *outbox* (list (format nil "~a: ~a"
(sel:args-text args 0)
(sel:args-text args 1)))))
(sel:make-bool t))
(sel:register-function "STOCK" 1 1 #'stock)
(sel:register-function "RESERVE" 2 2 #'reserve)
(sel:register-function "WEIGHT" 1 1 #'weight)
(sel:register-function "NOTIFY" 2 2 #'notify)The rules, the same in every host (spec §8.1):
- Register before compiling. An unknown name is a compile-time error, so a rule that calls your function must be compiled after the registration.
- Add, never change. The name of a builtin, or a reserved word, is refused. Registering your own name again replaces the function for rules compiled afterwards; rules already compiled keep the one they were compiled with.
- Strict. Arguments are evaluated once, left to right, before your function
runs; read them through the accessor (
text,bool,int,nonNegInt,valand their spellings), which raises the usual error at the argument's own position. - Return a new value; do not modify the arguments.
- Fail with a code. Raise the host's
SelErrorwith a code from the catalogue —E_BAD_ARGis usually right — and the argument's position; any other exception is the host's and passes through untouched. - SQL only if you spell it. Until it has a SQL spelling for a dialect, a rule that calls it is refused by the translator and kept in memory by the planner — below is how to give it one.
Scripting with host functions is a complete program built this way.
Giving a host function a SQL spelling
When the database can compute what your function computes, give the function a
SQL spelling for that dialect, with the same define that respells a builtin —
after registering the function. Here, from
Your own functions, in SQL, four functions are spelled as
PostgreSQL functions the application created, and one as an inline expression
over a list:
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.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.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.Map::define("postgresql", Section::Funcs, "SLUG",
EntrySpec::tpl("slug({0})", "TEXT").args({"TEXT"}));
Map::define("postgresql", Section::Funcs, "MARGIN_PCT",
EntrySpec::tpl("margin_pct({0}, {1})", "NUM").args({"NUM", "NUM"}));
Map::define("postgresql", Section::Funcs, "VAT_RATE",
EntrySpec::tpl("vat_rate({0}, {1})", "NUM").args({"TEXT", "TEXT"}));
Map::define("postgresql", Section::Funcs, "SHIPPING_COST",
EntrySpec::tpl("shipping_cost({0}, {1})", "NUM").args({"NUM", "TEXT"}));
Map::define("postgresql", Section::Funcs, "HAS_TAG",
EntrySpec::tpl("({1} = ANY(ARRAY[{0}]))", "BOOL").args({"LIST", "TEXT"}));
// WORDS returns a list: no spelling can say that, so it has none.(sel.sql:define-entry "postgresql" :funcs "SLUG"
(list :tpl "slug({0})" :ret "TEXT" :args '("TEXT")))
(sel.sql:define-entry "postgresql" :funcs "MARGIN_PCT"
(list :tpl "margin_pct({0}, {1})" :ret "NUM" :args '("NUM" "NUM")))
(sel.sql:define-entry "postgresql" :funcs "VAT_RATE"
(list :tpl "vat_rate({0}, {1})" :ret "NUM" :args '("TEXT" "TEXT")))
(sel.sql:define-entry "postgresql" :funcs "SHIPPING_COST"
(list :tpl "shipping_cost({0}, {1})" :ret "NUM" :args '("NUM" "TEXT")))
(sel.sql:define-entry "postgresql" :funcs "HAS_TAG"
(list :tpl "({1} = ANY(ARRAY[{0}]))" :ret "BOOL" :args '("LIST" "TEXT")))
;; WORDS returns a list: no spelling can say that, so it has none.args declares what each argument must be (ANY, TEXT, NUM, BOOL, BIN,
LIST), and so how it renders: a NUM goes through the numeric guard unless it is
a declared number, and a LIST expands into its elements for the template to
bracket. The spelling is your promise that the database computes what your code
computes — SEL checks the shape, marks every such fragment with the caveat
host-function, and refuses it under strict. A function that returns a list has
no spelling. The rules in full are in the SQL reference.
Extending the SQL layer
A deployment is rarely exactly one of the shipped dialects: a driver wants numbered placeholders, a server lacks a function, an extension adds one. A dialect is registered, not forked — the new one names its parent and states only its differences, and everything else is inherited key by key.
A dialect of your own
map.define_dialect('pg-libpq', {
'extends': 'postgresql',
'version': '15',
'target': True, # a base is not a target; this is a server
'lexical': {'placeholder': '${n}'}, # libpq numbers its parameters
})
print(' targets =>', ' '.join(Sql.dialects()))
print(' chain =>', ' -> '.join(map.chain('pg-libpq')))
print(' base =>', sql_in('postgresql'))
print(' pg-libpq =>', sql_in('pg-libpq'))map.defineDialect('pg-libpq', {
extends: 'postgresql',
version: '15',
target: true, // a base is not a target; this is a server
lexical: { placeholder: '${n}' }, // libpq numbers its parameters
});
console.log(' targets =>', Sql.dialects().join(' '));
console.log(' chain =>', map.chain('pg-libpq').join(' -> '));
console.log(' base =>', sqlIn('postgresql'));
console.log(' pg-libpq =>', sqlIn('pg-libpq'));Map::defineDialect('pg-libpq', [
'extends' => 'postgresql',
'version' => '15',
'target' => true, // a base is not a target; this is a server
'lexical' => ['placeholder' => '${n}'], // libpq numbers its parameters
]);
echo ' targets => ', implode(' ', Sql::dialects()), "\n";
echo ' chain => ', implode(' -> ', Map::chain('pg-libpq')), "\n";
echo ' base => ', $sqlIn('postgresql'), "\n";
echo ' pg-libpq => ', $sqlIn('pg-libpq'), "\n";Map::define_dialect("pg-libpq",
DialectSpec::extending("postgresql")
.version("15")
.target(true) // a base is not a target; this is a server
.lexical("placeholder", "${n}")); // libpq numbers its parameters
const std::string base = sql_in("postgresql");
const std::string libpq = sql_in("pg-libpq");
std::cout << " targets => " << join(Sql::dialects(), " ") << "\n";
std::cout << " chain => " << join(Map::chain("pg-libpq"), " -> ") << "\n";
std::cout << " base => " << base << "\n";
std::cout << " pg-libpq => " << libpq << "\n";(sel.sql:define-dialect "pg-libpq"
;; :TARGET T because a base is not a target; this is a server. The
;; lexical map is keyed by strings, which are the map document's own
;; key names rather than this host's vocabulary.
'(:extends "postgresql"
:version "15"
:target t
:lexical (("placeholder" . "${n}")))) ; libpq numbers its parameters
(format t " targets => ~{~a~^ ~}~%" (sel.sql:dialects))
(format t " chain => ~{~a~^ -> ~}~%" (sel.sql:dialect-chain "pg-libpq"))
(format t " base => ~a~%" (sql-in "postgresql"))
(format t " pg-libpq => ~a~%" (sql-in "pg-libpq"))Spelling a function differently
A template's {0}, {1}, … are the arguments (zero-based: these are template
holes, not SEL positions) and {*} all of them. An entry says what it returns,
because the translator infers kinds and will not guess.
map.define('pg-libpq', 'funcs', 'UPPER', {'tpl': 'UPPER({0} COLLATE "C")', 'ret': 'TEXT'})
print(' upper =>',
Sql.translate(compile('UPPER(NAME)'), 'pg-libpq', bindings).as_value())map.define('pg-libpq', 'funcs', 'UPPER', { tpl: 'UPPER({0} COLLATE "C")', ret: 'TEXT' });
console.log(' upper =>',
Sql.translate(compile('UPPER(NAME)'), 'pg-libpq', bindings).asValue());Map::define('pg-libpq', 'funcs', 'UPPER', ['tpl' => 'UPPER({0} COLLATE "C")', 'ret' => 'TEXT']);
echo ' upper => ',
Sql::translate(Sel::compile('UPPER(NAME)'), 'pg-libpq', $bindings)->asValue(), "\n";Map::define("pg-libpq", Section::Funcs, "UPPER",
EntrySpec::tpl("UPPER({0} COLLATE \"C\")", "TEXT"));
const std::string upper =
Sql::translate(sel::compile("UPPER(NAME)"), "pg-libpq", bindings).as_value();
std::cout << " upper => " << upper << "\n";(sel.sql:define-entry "pg-libpq" :funcs "UPPER"
'(:tpl "UPPER({0} COLLATE \"C\")" :ret "TEXT"))
(format t " upper => ~a~%"
(sel.sql:as-value
(sel.sql:translate (sel:compile-source "UPPER(NAME)") "pg-libpq" bindings)))Withdrawing what a server does not have
An entry of nothing withdraws the function: a rule using it is refused on this dialect, rather than emitted against a function the server lacks.
map.define('pg-libpq', 'funcs', 'RMATCH', None)
re = compile('RMATCH(\'^a\', NAME)')
print(' postgresql =>', 'refused' if Sql.try_translate(re, 'postgresql', bindings)
is None else 'translated')
print(' pg-libpq =>', 'refused' if Sql.try_translate(re, 'pg-libpq', bindings)
is None else 'translated')map.define('pg-libpq', 'funcs', 'RMATCH', null);
const re = compile('RMATCH(\'^a\', NAME)');
console.log(' postgresql =>', Sql.tryTranslate(re, 'postgresql', bindings) === null
? 'refused' : 'translated');
console.log(' pg-libpq =>', Sql.tryTranslate(re, 'pg-libpq', bindings) === null
? 'refused' : 'translated');Map::define('pg-libpq', 'funcs', 'RMATCH', null);
$re = Sel::compile("RMATCH('^a', NAME)");
echo ' postgresql => ', Sql::tryTranslate($re, 'postgresql', $bindings) === null
? 'refused' : 'translated', "\n";
echo ' pg-libpq => ', Sql::tryTranslate($re, 'pg-libpq', $bindings) === null
? 'refused' : 'translated', "\n";Map::define("pg-libpq", Section::Funcs, "RMATCH", EntrySpec::withdraw());
const sel::Program re = sel::compile("RMATCH('^a', NAME)");
std::cout << " postgresql => "
<< (Sql::try_translate(re, "postgresql", bindings).has_value()
? "translated"
: "refused")
<< "\n";
std::cout << " pg-libpq => "
<< (Sql::try_translate(re, "pg-libpq", bindings).has_value()
? "translated"
: "refused")
<< "\n";(sel.sql:define-entry "pg-libpq" :funcs "RMATCH" nil)
(let ((re (sel:compile-source "RMATCH('^a', NAME)")))
(format t " postgresql => ~a~%"
(if (null (sel.sql:try-translate re "postgresql" bindings))
"refused" "translated"))
(format t " pg-libpq => ~a~%"
(if (null (sel.sql:try-translate re "pg-libpq" bindings))
"refused" "translated")))A builder, for what a template cannot say
A builder receives the emitter and the arguments already rendered, and returns a fragment. Splice the arguments' parts rather than their text, so a bound value stays bound.
map.define_builder('pg-libpq', 'funcs', 'LEN', lambda emit, args, _at:
Fragment(['length(', *args[0].parts, ')'], 'NUM', emit.dialect()))
print(' len =>',
Sql.translate(compile('LEN(NAME)'), 'pg-libpq', bindings).as_value())map.defineBuilder('pg-libpq', 'funcs', 'LEN', (emit, args) =>
new Fragment(['length(', ...args[0].parts, ')'], 'NUM', emit.dialect()));
console.log(' len =>',
Sql.translate(compile('LEN(NAME)'), 'pg-libpq', bindings).asValue());Map::defineBuilder('pg-libpq', 'funcs', 'LEN', fn (Emit $emit, array $args) =>
new Fragment(['length(', ...$args[0]->parts, ')'], 'NUM', $emit->dialect()));
echo ' len => ',
Sql::translate(Sel::compile('LEN(NAME)'), 'pg-libpq', $bindings)->asValue(), "\n";Map::define_builder(
"pg-libpq", Section::Funcs, "LEN",
std::make_shared<Builder>(
[](Emit& emit, std::span<const Fragment> args, Pos) {
std::vector<Fragment::Part> parts;
parts.push_back({.sql = "length("});
for (const Fragment::Part& part : args[0].parts()) parts.push_back(part);
parts.push_back({.sql = ")"});
return Fragment(std::move(parts), SqlKind::Num, emit.dialect());
}));
const std::string len =
Sql::translate(sel::compile("LEN(NAME)"), "pg-libpq", bindings).as_value();
std::cout << " len => " << len << "\n";(sel.sql:define-builder
"pg-libpq" :funcs "LEN"
(lambda (dialect args pos)
(declare (ignore pos))
(sel.sql::%fragment (append (list "length(")
(sel.sql:fragment-parts (first args))
(list ")"))
:num dialect)))
(format t " len => ~a~%"
(sel.sql:as-value
(sel.sql:translate (sel:compile-source "LEN(NAME)") "pg-libpq" bindings)))Registrations are checked against the same rules the shipped map is held to
(sql/MAP.md), and a malformed one is a start-up error of the
host, never an SQL refusal. map.reset() and its spellings drop every
registration — worth calling between tests.
A builtin for everyone
A function in SEL itself is a change to the language, and it arrives in all five hosts at once or not at all — a function in one host is a function nobody has compared with anything. The order of work:
spec/SPEC.md §7 and spec/builtins.json say what it does, and its arity
conformance/*.selt cases that fail
js/ php/ python/ cpp/ lisp/ implement, in that order or any other
node tools/gen-builtins.mjs render the manifest into every host
sql/dialects/*.json + sql/cases/*.sqlt a SQL spelling, if it has an exact one
tools/check.sh ALL GREEN, or it isn't doneMost functions are strict — they receive values, and the argument accessor
does the arity, type and position work, so a function is a few lines per host.
A function that must not evaluate something — a branch, a body per element —
is lazy and receives syntax. Both are worked end to end, in all five hosts, in
Contributing, with the reference fragments in
examples/fn-simple and
examples/fn-complex; giving a builtin a SQL spelling
is examples/fn-sql.
Adding an operator, and adding a sixth host, are in Contributing too.