The SEL → SQL dialect map
Normative. This document defines the format of sql/dialects/*.json. Where a
dialect document and this description disagree, tools/gen-sql-map.mjs is the
arbiter — it validates every rule stated here at generation time, so a malformed
map never reaches a host.
The design rationale, the translation pipeline these files feed, and the list of
places SEL and SQL genuinely differ are in docs/internals/sql-translation.md. This file
is only the format.
1. Files and the chain
One document per dialect, named for the dialect it declares.
ansi ─┬─ mysql-family ─┬─ mariadb <- the reference
│ └─ mysql
├─ postgresql
└─ sqlite{
"dialect": "mariadb", // must equal the file's basename
"extends": "mysql-family", // absent only for "ansi"
"version": "10.5", // the minimum server this document assumes
"target": true, // may this be named in a translate() call?
"lexical": { ... }, // §3
"ops": { ... }, // §4, keyed by SEL operator token
"funcs": { ... }, // §4, keyed by SEL function name, upper case
"skel": { ... }, // §5, multi-part constructs
"notes": { ... } // §6, free prose, not consumed by hosts
}"target": false (or absent) marks a base: a document that exists to be
inherited and names no real server. ansi and mysql-family are bases.
Naming one in translate() is E_SQL_DIALECT, because a dialect no database
implements is not a dialect anyone should be able to aim at.
Every section is inherited key by key, not section by section: a leaf that
overrides one function does not restate the other thirty-one. Chains are acyclic
and at most eight deep, and every document must reach ansi.
version is dotted-numeric. It is compared only against an entry's since
(§4.5), and always the target's version — a base's version is never consulted,
which is why a version-gated entry belongs in the leaf that has the version.
1.1 ansi says what the standard says, and nothing else
ansi is the root, so it is what a dialect inherits when it overrides nothing,
and defineDialect(name, ['extends' => 'ansi']) is the documented way to add a
server this map has never heard of. That makes it a claim about standard SQL
rather than a convenient place to put shared defaults, and the two are not the
same thing.
They had drifted apart. Every shipped target overrides most of what ansi
declares, so nothing ever ran its version of an entry, and what accumulated
there was the MySQL spelling — the reference dialect's — sitting at the root
where a reader would take it for the portable one. Measured against
PostgreSQL 17:
ansi said |
on a conformant server | now |
|---|---|---|
CAST({0} AS CHAR) |
CHAR is CHAR(1); 'abcdef' became 'a', so "5.00" $== "5" was true |
CAST({0} AS CHARACTER VARYING) |
CAST({0} AS DECIMAL(38,10)) |
38 digits overflow at 22003, and a scale of 10 truncated silently |
CAST({0} AS NUMERIC) |
"" (no collation) |
linguistic ordering, so "B" $< "a" was false where SEL says true |
" COLLATE UCS_BASIC" |
({0} + {1}), CHAR_LENGTH({0}), ({0} || {1}) |
SEL numbers are text, so operands arrive untyped: char_length(numeric) and integer || integer do not exist, and '10' / '4' is ambiguous |
operands wrapped in {textCast:N} or {numericCast:N} |
The through-line is that standard SQL is strongly typed and MySQL is not. SEL's numbers are text (spec §4), so a template is routinely handed an operand of the "wrong" SQL type; MySQL coerces silently and a conformant server refuses. Bare templates therefore looked correct for as long as only MySQL inherited them.
MySQL's spelling did not change — it moved. mysql-family.json now states
UPPER, LOWER, +, -, /, %, NEG and LEN itself instead of
inheriting them, so the emitted SQL for mariadb and mysql is byte-identical
to before and the deviation is written down where a reader meets both. That is
the rule this section is really about: a dialect that differs from the
standard says so in its own file.
NUMERIC and CHARACTER VARYING carry no precision or length on purpose.
Standard SQL leaves both implementation-defined, and SEL's values have no fixed
width, so any number written here is a ceiling somebody eventually meets —
which is exactly how the DECIMAL(38,10) above was found.
None of this changed a shipped target's output: every one of these entries was
either already overridden or was moved to mysql-family in the same commit.
What changed is what a new dialect inherits.
1.2 How that is checked, with no ANSI server to check against
Nobody ships an ANSI server — it is a standard, not a product — so ansi is
target: false and the semantic oracle, which walks targets, had never
evaluated a single one of its entries. Two lanes cover it now:
- Strings, with no server:
sql/cases/20-ansi-fallback.sqltregisters a probe dialect extendingansiand pins what it emits. - Semantics, borrowing a connection:
php/bin/sqloregisters the same probe and runs the whole closed corpus through it against PostgreSQL, chosen because it is the conformant one of the four. SQLite's type affinity accepts nearly any cast and MariaDB reads||as logical OR, so either would be choosing the server that hides the answer. The probe is registered insidesqloand nowhere else, soSql::dialects()still answers with the four real targets everywhere.
What that establishes is "ansi's entries agree with SEL on the one conformant engine available", not "ansi is portable". A green run is evidence for Oracle or SQL Server, not proof.
It also made trim-charset a measurement. The caveat says TRIM(BOTH FROM x)
strips only the pad character where SEL strips space, tab, CR and LF; every
shipped target overrides the entry, so the caveat had never once fired, and
caveat_pins could not ask for it because that gate walks targets too. The
corpus already contained TRIM("\t\r\n x \n"), so the probe witnessed it on
its first run without a single corpus line being added.
2. Entries
Three forms, and the distinction between the last two is the whole of the graceful-refusal story.
| Form | Means |
|---|---|
| an object | supported; see §4 |
| a string | refused, and the string is the reason |
null |
refused, no reason given |
A string is strongly preferred over null. It becomes the message on the
E_SQL_UNSUPPORTED a caller sees:
"RREPLACE": "SEL replacement syntax is $0-$9; MariaDB's is \\1, and rewriting one into the other needs the replacement to be a literal"E_SQL_UNSUPPORTED at 1:14: RREPLACE has no mapping in dialect mariadb —
SEL replacement syntax is $0-$9; MariaDB's is \1, and rewriting one into
the other needs the replacement to be a literalThat message is the entire diagnostic apparatus. There is no second reporting
channel and no explain() API; a reason written once, here, next to the
decision it explains, reaches every host and every caller.
Refusing in a child withdraws a parent's support. Absent means "ask the
parent"; a string or null means "stop, this dialect cannot".
null withdraws wherever it appears, and every lookup tests presence rather
than non-nullness so that it can. A null lexical value withdraws that lexical
entry — §3's "a null binaryLiteral refuses BIN literals" is exactly this — and
a null at one count of an arity-keyed template withdraws that form, which the
* fallback does not rescue. Both were read as absent until a cross-host
review asked what a withdrawal actually did: the base's live value was inherited
in its place, so the documented withdrawal was unwritable in both hosts, and on
one of them the null reached the renderer and emitted the literal text None
into the SQL.
3. lexical
Everything about a dialect that is not a per-operator template. Inherited key by
key. Every key below must resolve for a target dialect; the generator checks.
| Key | Type | Means |
|---|---|---|
identQuote |
string | the identifier quote character |
identEscape |
string | what that character becomes inside a quoted identifier |
textQuote |
string | the string-literal quote character |
textEscape |
object | character → replacement, applied to every text literal |
true / false |
string | the BOOL literals |
binaryLiteral |
string or null | template for a BIN literal, {hex} filled with lower-case hex; null refuses BIN literals |
textCollate |
string | appended to each operand of the $ comparison family |
textCharset |
string or null | the charset name a dialect spells when it converts bytes to text; null where the dialect names none |
textCast |
string | template wrapping {0} to cast an operand to text |
numericCast |
string | template wrapping {0} for numeric coercion |
numericGuard |
string, optional | template wrapping {0}, yielding the number or NULL; a target may leave it undeclared |
numericCastScale |
digit string, optional | the fractional digits numericCast and numericGuard keep, where their type fixes a scale; a value read through them may lose digits past it, and the translator marks it scale-limit (SEL-0059). The generator requires it to equal the DECIMAL(p,s) scale those two templates name, and requires it wherever they name one |
binaryCast |
string | template wrapping {0} to cast a text or num operand to bytes |
isTrue / isNotTrue |
string | templates folding SQL's third truth value into two |
placeholder |
string | params-mode placeholder; {n} is the 1-based ordinal, absent for positional ? |
sargablePrefilter |
string | "true" if the engine requires a coarse equality prefilter on sargable text equality (col = 'val' AND ...), "false" where bare equality is exact |
Four of these are load-bearing rather than cosmetic:
textEscapecarries the backslash entry for MySQL and MariaDB and omits it for PostgreSQL and SQLite, because MySQL treats\as an escape inside string literals under its defaultsql_modeand the other two do not. Getting this wrong is an injection, not a formatting nit, so it is data rather than a branch someone can forget to write.textCollateis what makes$==honest. SEL's$family compares bytes; MySQL's and MariaDB's default collation is case- and accent-insensitive, so a bare=would make"A" $== "a"true in the database and false in SEL. Binary alone is insufficient: a PAD SPACE collation also merges"a"and"a ". The shipped family/MariaDB map usesutf8mb4_nopad_bin; MySQL 8.4 overrides it withutf8mb4_0900_bin. Both retain text types and code-point regex behavior. Their SQL spellings differ intentionally; case mirroring pins this one substitution rather than assuming byte-identical leaf SQL.sargablePrefiltercontrols whether sargable text comparisons emit a coarse index prefilter. MySQL and MariaDB set"true"to emit((col = 'val') AND (CAST(col...) = CAST('val'...)))to enable index seeks under case-insensitive collations while preserving exact binary semantics; PostgreSQL and SQLite set"false"where text comparisons are exact by default. Derived dialects inherit this automatically through theirextendschain. Whenprefilter: 'separate'is configured on relation or column bindings,ANYsubqueries on dialects withsargablePrefilter: "true"emit separate siblingEXISTSpreconditions conjoined byANDto allow query optimizers to plan composite indexes before evaluating collation-sensitive residuals.numericGuardis what makes==honest about data it was not promised.numericCastalone answers 0 for'x'on three of the four servers, so a comparison against 0 matched every row of a text column. The guard tests the value first and yields NULL when it is not a SEL number, and NULL is not selected. It wraps only an operand the binding did not declareNUM: a declared NUM is vouched for, emits no cast and keeps its index.It is the one lexical key a
targetmay leave undeclared, and the absence is the answer rather than an oversight:sqlitehas noREGEXPandansihas no regex, which is already whyfuncs.ISNUMis unmapped on both. A dialect that cannot ask the question refuses the translation instead of guessing.Its pattern is SEL's own numeral grammar, and it is the same pattern
funcs.ISNUMcarries for that dialect — see §7.10.numericCastis what makes==honest. SEL's==compares numerically after aligning scale, so"5.00" == "5"isTRUE; SQL's=between two text columns compares text. §4.3 says exactly when the cast is applied.
4. ops and funcs
ops is keyed by the SEL operator token exactly as spec/grammar.md spells it
(+, &, $<=, AND, BAND, IN, EQL), plus NEG for unary minus.
funcs is keyed by the upper-case SEL function name.
The constructs that stage 2 lowers — IF, COND, ABORT, COUNT, INDEXES,
HAS, ALL, ANY, MAP, FILTER, SUM, JOIN — never appear in funcs.
They are not template-shaped; they use skel (§5) or are refused by the
lowering with a message of its own. The generator rejects a document that lists
one of them.
4.1 A supported entry
"UPPER": { "tpl": "UPPER({0})", "ret": "TEXT", "caveat": "unicode-case" },
"LEN": { "tpl": "CHAR_LENGTH({0})", "ret": "NUM" },
"FIND": { "tpl": "INSTR({1}, {0})", "ret": "NUM", "arity": [2, 2] },
"REPLACE": { "tpl": "REPLACE({2}, {0}, {1})", "ret": "TEXT" },
"MIN": { "tpl": "LEAST({*})", "ret": "NUM" },
"SUBSTR": { "tpl": { "2": "SUBSTRING({0} FROM {1})",
"3": "SUBSTRING({0} FROM {1} FOR {2})" }, "ret": "TEXT" }| Field | Required | Means |
|---|---|---|
tpl |
one of tpl/variants |
a template, or an object keyed by argument count, with * as the fallback |
variants |
one of tpl/variants |
named templates chosen by §4.3 |
ret |
yes | the static kind produced; §4.4 |
arity |
no | [min, max], narrowing SEL's own arity for this dialect |
caveat |
no | mapped but inexact; §4.6 |
since |
no | requires at least this target version; §4.5 |
An arity-keyed tpl must resolve for every count the entry accepts: the
generator lists the counts it does not cover, and an entry with an unbounded
arity must carry a *. MIN is the case that needs it — LEAST and GREATEST
are variadic but require two arguments, and SEL's MIN takes one:
"MIN": { "tpl": { "1": "{0}", "*": "LEAST({*})" }, "ret": "NUM" }FIND and REPLACE reorder their arguments, because SEL is
FIND(needle, hay) and SQL is INSTR(hay, needle). arity on FIND is how
PostgreSQL refuses the three-argument form its POSITION cannot express while
MariaDB still accepts it — graceful degradation as data, with no host code
involved.
4.2 Template syntax
| Form | Fills with |
|---|---|
{n} |
argument n, zero-based, already rendered |
{*} |
every argument, joined with , |
{n:} |
arguments n onward, joined with , |
{key} |
the lexical string named key |
{key:n} |
argument n wrapped in the lexical template named key |
{{ }} |
a literal brace |
{key} and {key:n} are validated by the generator and left in place, and
every host expands them when it fills the template.
Baking them in would be one fewer thing to do at render time and would quietly
break the reason lexical keys exist. textCollate is stated once and used by
thirteen comparison entries; an application on a server with a different binary
collation should be able to override that one key and have all thirteen follow.
With the templates pre-expanded the key is gone by then, and the application
would have to re-register every entry that mentioned it.
Deploying on a connection charset the dialect does not assume
The shipped mariadb and mysql dialects assume a utf8mb4 connection.
On any other, every $ comparison is MariaDB error 1253,
ER_COLLATION_CHARSET_MISMATCH — reported from the field on utf8mb3.
The cause is not the column. textCast emits CAST({0} AS CHAR), and a bare
CHAR is in the connection's charset; a collation has to belong to the
charset it is applied to. So textCollate must agree with the connection,
whatever the columns are.
Derive a dialect and override two keys:
Map::defineDialect('cms-mariadb', [
'extends' => 'mariadb',
'lexical' => [
'textCollate' => ' COLLATE utf8mb3_nopad_bin',
'textCharset' => 'utf8mb3',
],
]);version and target are inherited from the dialect being extended, so two
keys is the whole registration. Measured on MariaDB 11.8, "A" $== "a":
| utf8mb4 connection | utf8mb3 connection | |
|---|---|---|
shipped mariadb |
answers | 1253 |
| the two keys above | 1253 | answers |
The override is therefore per deployment, not a portability improvement: a dialect fixed for utf8mb3 is broken on utf8mb4, and vice versa.
Deriving it from the connection instead
Which is a good reason not to write the charset down at all. defineDialect is
ordinary run-time API, so an application can ask the connection it actually got
and register the matching dialect at start-up:
/**
* Register a dialect matching the charset this connection actually uses, and
* return its name. Call once at start-up, before the first translate().
*/
function dialect_for(PDO $pdo, string $base = 'mariadb', string $name = 'app'): string
{
$charset = (string) $pdo->query('SELECT @@character_set_connection')->fetchColumn();
// Do not manufacture a *_bin name: it may be PAD SPACE. This example
// supports the two verified utf8mb4 combinations; other connections need
// an explicitly verified byte-exact, no-padding collation.
$collation = match ([$base, $charset]) {
['mariadb', 'utf8mb4'] => 'utf8mb4_nopad_bin',
['mysql', 'utf8mb4'] => 'utf8mb4_0900_bin',
default => throw new RuntimeException('Configure a verified NO PAD collation'),
};
Map::defineDialect($name, [
'extends' => $base,
'lexical' => [
'textCollate' => " COLLATE {$collation}",
'textCharset' => $charset,
],
]);
return $name;
}The earlier version manufactured *_bin names and checked only "A" versus
"a"; it missed PAD SPACE. This version refuses an unverified connection
instead. The explicit utf8mb3 example above is MariaDB-specific (verified on
11.8); do not assume that MySQL ships that same collation spelling.
Four things it is worth knowing before using it:
- Registration is process-global and happens once. It must run before the
first
translate(). Under PHP-FPM that is per request, so either accept one extra round trip or pass the charset the application already put in its own DSN. Asking the server is the safer of the two: the connection can end up on a charset nobody asked for, which is the whole failure being avoided. - MySQL family only.
@@character_set_connectiondoes not exist on PostgreSQL or SQLite, so branch on$baseif the helper is ever shared. Map::reset()drops every registration, not just this one.- Redefinition is last writer wins (§4.2, deliberately), so two subsystems registering the same name will disagree in silence.
Unlike the worked programs under examples/, this block is illustrative: no
lane executes it. It was run against the pinned MariaDB on both charsets before
being written down, which is not the same as staying true.
textCharset is invisible until textCast is also set to {0} — the note on
that key recommends it where columns already carry a binary collation — because
until then the outer cast converts FROM_UTF8's result to the connection
charset before the collation applies. Override both anyway: they are one
decision, and splitting them is how the field report happened.
Do not reach for textCollate: "". It is legal, it silences 1253, and it
silently restores the case- and accent-insensitive comparison the key exists to
prevent — "A" $== "a" becomes true in the database and stays false in SEL.
The generator rejects a template referring to an argument the entry's arity
cannot supply, and a {key} naming a lexical entry that does not resolve.
4.3 variants
For operators whose spelling depends on the kinds of their operands. Which variant applies is not in the data — it is one of three fixed selectors, named by the operator family and implemented identically in every host.
| Family | Variants | Selector |
|---|---|---|
== != < <= > >= |
num, coerce |
num when both operands infer NUM, else coerce |
$== $!= $< $<= $> $>=, EQL |
text |
always text |
& |
text, bin |
bin when either operand infers BIN, else text |
IN |
list, scalar |
list when the right operand is a list, else scalar |
"==": { "variants": { "num": "({0} = {1})",
"coerce": "({numericCast:0} = {numericCast:1})" },
"ret": "BOOL" },
"$<": { "variants": { "text": "({0}{textCollate} < {1}{textCollate})" },
"ret": "BOOL" },
"IN": { "variants": { "list": "({0} IN ({1:}))",
"scalar": "({0}{textCollate} = {1}{textCollate})" },
"ret": "BOOL" }IN's list variant sees a flattened argument vector — the left operand at 0
and each element of the right from 1 — which is what {1:} joins.
A missing variant is a refusal for that shape alone: a dialect that provides
num but not coerce accepts 1 == 2 and refuses A == B on untyped columns.
4.4 ret
The static kind the entry produces, consumed by stage 3.
NUM · TEXT · BOOL · BIN · UNKNOWN, or one of two computed forms:
@concat— BIN if any argument is BIN, else TEXT.@unify:i,j— the common kind of those arguments,UNKNOWNif they disagree.
UNKNOWN is not an error. It means "ask the database", which for an untyped
binding is the honest answer.
4.5 since
"RMATCH": { "tpl": "REGEXP_LIKE({1}, {0})", "ret": "BOOL", "since": "8.0.4" }Reserved, and used by no dialect today. The mechanism is implemented and
checked — Translator compares the entry's since against the target's declared
version and refuses E_SQL_DIALECT below it — but every entry in every dialect
written so far is available in the version that dialect declares, so nothing
exercises it against a real server. It is documented here as the answer for older
servers rather than as something in use: mysql.json notes that a MySQL 5.7 leaf
would need most of the regex family gated or refused, and writing that leaf is
what would first make since live data. Treat the example below as a shape, not
as a citation.
Below that version, E_SQL_DIALECT. Comparison is dotted-numeric and nothing
cleverer. The version compared is always the target's, never a base's, so a
version-gated entry belongs in the leaf that declares the version — putting one
in a base makes it depend on whichever leaf inherited it, which is a fact about
the chain rather than about the server.
A versioned dialect is the general mechanism for older servers, and needs no special code anywhere because it is only another link:
{ "dialect": "mariadb-10.1", "extends": "mariadb", "version": "10.1", "target": true,
"funcs": { "RMATCH": "MariaDB 10.1 has no REGEXP_REPLACE-based anchoring rewrite" } }4.5¼ A registered dialect can be a root, and carries only four keys
defineDialect takes extends, version, target and lexical, and nothing
else. ops, funcs and skel entries are defined one at a time with define,
and passing them in the dialect spec is refused rather than ignored — it used
to be accepted and silently dropped, which is a registration that looks like it
worked.
extends must be present, and may be null for a dialect with no parent, as
ansi has. Present-and-null rather than absent, because forgetting the key is a
typo and must not quietly produce a root that inherits nothing. A root has no
version to inherit, so it must declare one.
That a root is declarable is what makes this true:
Anything the shipped map contains, an application could have registered.
Which is the property a host relies on when it ships its map as generated code
rather than as data to be read: the generator emits a sequence of defineDialect
and define calls, the runtime executes them at start-up, and no file is
deployed alongside the application. 217 calls rebuild the whole shipped map —
the chain preserved, so no dialect repeats what it inherits — and the result is
indistinguishable from the shipped one, entry for entry.
4.5½ Registration is checked against this document, at run time
Everything §4 and §5 require of a shipped entry, Map::define and
Map::defineDialect require of a registered one — against the same
vocabulary, because tools/gen-sql-map.mjs emits it into the generated map
as RULES rather than each host retyping the list.
That is not tidiness. Nothing checked a runtime registration, so an entry with
no ret, a tpl that was a JSON list, an arity of strings, a since of
"abc" and a caveat somebody invented were all accepted — and the hosts then
improvised differently over each one, because improvising is what code does when
it has no rule. Two of those improvisations were wrong in both hosts at once:
a textEscape given as a string made both skip escaping entirely and emit
'it's' unquoted, and a null lexical value — which §3 documents as a
withdrawal — was read as absent and inherited from the base, so the documented
withdrawal was unimplementable.
A malformed registration raises the host's startup-error class
(LogicException, RuntimeError, …) and never SqlError: it is a mistake in
the application's start-up, not a rule that cannot be translated, and
tryTranslate() must not swallow it. sql/cases/18-host-neutrality.sqlt pins
one case per rule, so every future host inherits them.
4.6 caveat
Marks an entry that is mapped but does not match SEL exactly. Advisory by
default; fatal under translate(…, ['strict' => true]), which turns every
caveated entry into E_SQL_UNSUPPORTED.
The vocabulary is closed — the generator rejects a name not on this list —
so that Fragment::$caveats is something an application can branch on rather
than a bag of prose. Each is described in full in docs/internals/sql-translation.md §11.
| Caveat | Short form |
|---|---|
unicode-case |
UPPER/LOWER are ASCII-only in SEL, Unicode-aware in the server |
division-scale |
/ yields a different scale, or truncates |
numeric-scale |
the result's decimal scale differs from SEL's, though the value is equal |
scale-limit |
the server's decimal type caps the result scale, and a result needing more fractional digits is truncated to it; also carried by a value read through a numericCast/numericGuard that declares numericCastScale |
decimal-float |
the server has no exact decimal type; arithmetic is integer or binary floating point |
rounding-mode |
rounding is not half-away-from-zero — in the vocabulary, declared by no dialect: mysql-family carried it on ROUND and was probed not to need it, because numericCast means the operands are DECIMAL and both servers round DECIMAL SEL's way |
modulo-integer |
% is integer-only |
power-float |
POWER returns a float |
text-collation |
a declared column collation can defeat textCollate |
regex-engine |
the regex dialect is not SEL's PCRE∩ECMAScript subset |
concat-null |
concatenation yields NULL if any operand is NULL |
trim-charset |
TRIM strips a different character set than SEL's space/tab/CR/LF |
length-units |
a length or position is counted in something other than code points — in the vocabulary, declared by no dialect: every target's LEN counts what SEL counts. A mutation adds it to ansi's LEN to prove the check would notice |
input-laxity |
the server accepts input SEL rejects, though it agrees on everything SEL accepts |
text-order |
a sort by a text key orders it by its bytes, where SEL sorts number-shaped text as numbers; carried by the sort, not declared by an entry |
host-function |
the entry spells an application's own function (§4.7): the application asserts it computes what the function computes, and SEL cannot check it; carried by every such entry, not declared by one |
4.7 Spelling a host function
An application's own function (spec/SPEC.md §8.1) can be given a funcs
entry, at run time, like a builtin can be respelled:
// after register_function("SLUG", 1, 1, …)
define("postgresql", "funcs", "SLUG",
{ "tpl": "trim(both '-' from regexp_replace(lower({0}), '[^a-z0-9]+', '-', 'g'))",
"ret": "TEXT", "args": ["TEXT"] })
// after register_function("VAT_RATE", 2, 2, …) and CREATE FUNCTION vat_rate(text, text)
define("postgresql", "funcs", "VAT_RATE", { "tpl": "vat_rate({0}, {1})", "ret": "NUM" })Only at run time: the shipped documents in sql/dialects/ spell SEL's own
functions and nothing else, and the generator refuses any other name.
The function comes first. define and defineBuilder accept a funcs key
that is a SEL function this layer maps or a function registered with
register_function when the entry is defined. Any other name is refused with
the host's start-up error, naming both possibilities — so a spelling written
before its function, or for a misspelt name, fails where it is written rather
than lying unused.
Arity is the registration's. The entry is checked against the function's
[min, max] exactly as a builtin's is against SEL's (§4.1, §4.5½): an
arity-keyed tpl naming a count the function cannot be called with is refused,
and an entry's own arity may only narrow it. The arity is recorded with the
entry. If the function is later registered again with a different arity, the
spelling no longer describes it: translating a call refuses it
(E_SQL_UNSUPPORTED) for as long as the two arities differ — define the
spelling again for the new arity. Registering it again
with the same arity keeps the spelling — the function's code is the
application's business, and so is keeping the two in step.
reset() drops the spellings, not the functions. A host function's SQL
spelling is a map registration like any other, and reset() removes it; the
function itself belongs to the evaluator and stays registered.
ret is a scalar kind. The vocabulary is §4.4's; there is no list kind, so
a function that returns a list has no spelling. Give its uses a scalar
companion — TAG_COUNT(x) beside TAGS(x) — and spell that.
args declares what each argument must be. A builtin's argument rules are
SEL's own and live in the translator; a host function's are whatever the
application says, so its entry may carry one more field, which a builtin's may
not:
args[i] |
The argument at position i |
Rendered as |
|---|---|---|
ANY (or absent) |
any scalar | itself |
TEXT |
any scalar but a BOOL or a BIN (E_SQL_SHAPE) |
itself |
NUM |
a number: a constant is checked now (E_SQL_INVALID when it is not one), a BOOL or BIN is refused (E_SQL_SHAPE) |
an operand not declared NUM goes through the dialect's numeric guard, as a builtin's numeric argument does (§3) |
BOOL |
an operand of kind BOOL (E_SQL_SHAPE otherwise, including an undeclared column) |
itself |
BIN |
an operand of kind BIN | itself |
LIST |
a list known when translating: a literal list, a columns binding, or a value binding holding a list; a scalar is a list of one (spec §7.3). A relation, an empty list, a list with a list in it, or a filtered list is refused (E_SQL_SHAPE) |
its elements, each rendered, joined with , — so the template supplies the brackets: ARRAY[{0}], ({0}), GREATEST({0}) |
args is a list no longer than the function's max; a position past its end is
ANY. A list argument in any position not declared LIST is refused, as it is
for a builtin. None of the builtins' own argument rules — which of them take a
BOOL or a BIN, which read a number — apply to a host function.
The entry decides before the arguments render. For a host function the
entry is looked up first, because its args say how each argument is rendered.
So a call with no spelling in the dialect is refused at the call's own position,
before any argument is examined.
Every use carries the caveat host-function (§4.6), in addition to any the
entry declares. An empty caveats list is this layer's promise that the SQL
means what SEL means; a host spelling is the application's promise instead, and
the fragment says so. Strict translation therefore refuses it.
A spelling can call anything the server can evaluate in an expression — a
built-in function, an extension's, or one the application created. On
PostgreSQL that is a FUNCTION (SQL or PL/pgSQL); a PROCEDURE cannot appear
in an expression. A function marked STABLE or IMMUTABLE lets the planner
treat it like any other expression; that is the server's business, not SEL's.
5. skel
Templates for the multi-part constructs no single entry can express. Placeholders
are named rather than numbered, and values follow §2 exactly: an object is
support, a string is a refusal carrying its reason, null is a refusal without
one.
A skeleton has no ret, no arity and no variants, so { "tpl": "…" } is a
wrapper around its only field and spelling it as a bare string is the obvious
economy. It is also a trap, and the reason the rule is uniform instead: a
refusal is a string too, so a bare-string skeleton would make
"EXISTS (SELECT 1 …)" and "no server spells this the same way" the same
shape. Neither the generator nor a host could tell them apart, and the failure
mode is a refusal reason emitted into a query as SQL.
| Key | Placeholders | Used by |
|---|---|---|
case |
{branches}, {else} |
IF, COND |
caseBranch |
{cond}, {then} |
one WHEN of the above |
all |
{from}, {corr}, {body} |
ALL over a relation binding |
any |
{from}, {corr}, {body} |
ANY |
sum |
{from}, {corr}, {body} |
SUM |
count |
{from}, {corr} |
COUNT |
join |
{from}, {corr}, {body}, {sep} |
JOIN |
inRelation |
{needle}, {from}, {corr}, {body} |
x IN relation |
prefilter |
{from}, {corr}, {body} |
Sibling EXISTS precondition for separate relation prefilters |
"all": { "tpl": "NOT EXISTS (SELECT 1 FROM {from} WHERE {corr} AND ({body}) IS NOT TRUE)" },
"any": { "tpl": "EXISTS (SELECT 1 FROM {from} WHERE {corr} AND ({body}) IS TRUE)" },
"prefilter": { "tpl": "EXISTS (SELECT 1 FROM {from} WHERE {corr} AND {body})" },
"join": "GROUP_CONCAT does not specify an order without an ORDER BY, and a relation binding has no key to order by"IS NOT TRUE rather than NOT (…) is the point of care in those two. SQL is
three-valued and SEL is not: if the body is NULL for some row, NOT (body) is
NULL, the WHERE rejects the row, and NOT EXISTS reports "every row satisfies
it" — silently the wrong answer for exactly the case a validation rule exists to
catch. IS NOT TRUE folds NULL into false, so a NULL body makes ALL false,
which is the conservative reading.
prefilter emits the coarse existential precondition directly without IS TRUE
folding, allowing database optimizers (such as MariaDB and MySQL) to recognize clean
composite equality predicates for B-tree index seek and semijoin decorrelation.
{corr} fills with the binding's correlate, or with lexical.true when the
binding omits one; an uncorrelated relation is a subquery over the whole table,
which is legal and occasionally what you want.
6. notes
Free prose, keyed by anything, consumed by nobody. It is where a decision that does not fit in an entry's reason string goes, so the next person reading the document finds it in the document rather than in a commit message.
"notes": {
"why-no-bitwise": "MySQL's & operates on integers, not byte strings. SEL's BAND is a byte-string operator over BIN of equal length, and there is no portable spelling of that.",
"trim": "MariaDB's TRIM(BOTH x FROM s) takes one string, not a character set, so the four-character strip SEL specifies needs REGEXP_REPLACE."
}7. What the generator guarantees
Every one of these is checked by tools/gen-sql-map.mjs, so no host has to
defend against a malformed map:
dialectequals the file's basename;extendsnames a document that exists; the chain is acyclic, at most eight deep, and reachesansi.- Every
targetdialect resolves everylexicalkey in §3, exceptnumericGuard, which is optional because two of the four targets cannot express it. Nothing else may be optional: a dialect that cannot quote an identifier is not a dialect. - Every entry is an object, a string, or
null; every object has exactly one oftpl/variantsand a validret. - Every
{n}in a template is within the entry's effective arity, and that arity is within the SEL function's own declared arity — the one inspec/builtins.json, the manifest every host's function table is held to, including its accepted-count rules (a range that includes a count SEL refuses is an error). No host's registry is consulted. - Every
{key}names alexicalentry that resolves for every dialect that inherits the template. - Every
caveatis on the closed list in §4.6; everysinceandversionis dotted-numeric. - No lowered construct (§4) appears in
funcs. - Every
variantsobject uses only the names its operator family defines. - Every
skelkey is one of §5's, and every named placeholder in a skeleton is one that key defines — a{frm}for{from}is an error, not literal text. - Where a dialect declares
numericGuard, it carries the same numeral pattern as that dialect'sfuncs.ISNUM. They ask the same question, and this map's rule is that one place defines a thing; two copies of a numeral grammar is the drift that rule exists to prevent.
Rule 10 at run time. It is checked when the map is generated, and a
generated map is not the only map: registering a derived dialect is the
documented way to adapt this one to a server, and the generator never sees the
result. So it is checked again, in every host, the first time a dialect's guard
is used — Map::checkNumericGuard, called from the one place numericGuard is
read. A guard that does not carry what its ISNUM tests raises the same
registration error a malformed define() does.
Not in defineDialect, because registration has no end: funcs.ISNUM is
defined one entry at a time, so at the moment a dialect is declared its ISNUM
may not exist yet. By the time a guard is being used, both sides are
registered.
This one key is checked and the others are not, and the asymmetry is the point.
Every other lexical value fails loudly when it is wrong — a template that cannot
expand raises at expansion. A wrong numericGuard fails silently: it emits SQL
that answers where SEL would not, which is the single outcome
docs/internals/sql-kinds.md exists to rule out. It is not the same
standing as a binding declared NUM over a column that is not. That is a claim
the caller makes about their own data; this is a claim about SEL's numeral
grammar, which the caller has no way to check.
Each host's sqlreplay registers a dialect whose guard accepts integers only —
narrower than ISNUM, so it passes the 2.5 it should stop — and requires the
translation to be refused.
The generator flattens each chain and emits one fully resolved table per dialect, so a host does no chain walking at all — a lookup is a hash access and nothing else. Runtime registration re-introduces the chain, and it is the only thing that does.