From e3608cb89900701e17479e4f1026665556851f2c Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Thu, 1 Oct 2026 01:23:40 +0200 Subject: [PATCH] State the SQL functions composed over the functions of another type A SQL function with no C symbol whose body casts its arguments and calls one other SQL function is a composition: eDisjoint(tpose, geometry) is SELECT eDisjoint($1::tgeompoint, $2). The catalog states each in the top-level list compositions, every step named by the MEOS function it is, so a binding builds the function from the steps and never reads SQL: {sqlName: eDisjoint, args: [tpose, geometry], operands: [{arg: 0, casts: [tpose_to_tpoint], param: temp}, {arg: 1, param: gs}], call: edisjoint_tgeo_geo}. operands holds one entry per C input parameter of the call, in its order: an argument through its casts, or a value, the literal the body passes or the callee's signature binds. A signature whose wrapper passes its arguments in another order than the C function takes them maps each operand by type: nearestApproachDistance(geometry, tpcpoint) feeds temp from argument 1 and gs from argument 0 of nad_tgeo_geo. A composition without call answers the end of its cast chain, centroid(tpcpoint) being SELECT $1::tgeompoint. restore returns an answer over the cast type to the type of argument of: a split restricts it to the time of each fragment, {column: tpose, from: tpoint, of: 0, time: temporal_time, at: temporal_at_tstzspanset}, and nearestApproachInstant(tpcpoint, geometry) to the instant it returns. A body calling a composition takes its steps, so spaceSplit(tpose, xsize, sorigin, ...) forwarding to spaceSplit(tpose, xsize, 0, 0, sorigin, ...) states that function's cast, call and restore over its own arguments and the two literals. The bodies are read from the deployed .in.sql statements. A cast $1::T names the function of the CREATE CAST to T, and a call or a function of one argument names the SQL signature of its name and argument types, the type modifier aside; the step is the public MEOS function carrying it. A body passing its arguments to a C-backed function as they are, a body calling PostGIS, and a program of several steps are no composition. A composition with a step that resolves to no public MEOS function stops the catalog. The SQL types a C parameter can take include interval, as Interval, and each set, span and span set of the type relations, as the C type of its class, so an operand of either type finds its parameter. Why. The derived types reach most of their operations only through these bodies: without them a binding offers a tpose no spatial relationship, no split and no box, and a cell no relationship at all. Measured. Over MobilityDB 72abdf86c2 the bodies hold 338 compositions, every step resolving: 323 with a call, 14 restoring the answer (4 splits, their 8 shorter forms and nearestApproachInstant over tpcpoint in both orders, through tinstant_timestamptz) and 1 without a call; 4 pass their operands in another order than the C function takes them. No body stops the catalog. The 16 other bodies are no composition: 6 selectivity functions, 4 PostGIS forwards, 3 swaps and 3 frame lookups. Beside the compositions, the catalog is byte for byte the one derived without this change. Witness. tests/test_compositions.py states a cast and a call, a cast through a composed type, a cell through its boundary, a commuted call, a cast to the box, a cast alone, a split and its shorter form, an instant restored, the literals a callee binds and the public function among its internal twins; leaves out a forward, a swap, a PostGIS forward and a program; and stops on an internal step and on a cast no CREATE CAST declares. The suite floor goes from 354 to 368. --- .github/workflows/pytest.yml | 2 +- parser/compositions.py | 496 +++++++++++++++++++++++++++++++++++ parser/sqlfn.py | 86 ++++-- parser/typescope.py | 2 +- run.py | 10 + tests/test_compositions.py | 318 ++++++++++++++++++++++ 6 files changed, 887 insertions(+), 27 deletions(-) create mode 100644 parser/compositions.py create mode 100644 tests/test_compositions.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index d1024c1..6951d1c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -96,7 +96,7 @@ jobs: # carries, or a change to them is not exercised until after it merges. # Consumers use the action; this repository owns the rules. - name: Refuse a skip, and a suite that shrank - run: tools/check-test-outcome.py "$RUNNER_TEMP/pytest.log" --min-tests 354 + run: tools/check-test-outcome.py "$RUNNER_TEMP/pytest.log" --min-tests 368 # The rules earn their place by refusing a log that carries what they # name. Both fixtures are written here rather than tracked, and the diff --git a/parser/compositions.py b/parser/compositions.py new file mode 100644 index 0000000..6bc78f7 --- /dev/null +++ b/parser/compositions.py @@ -0,0 +1,496 @@ +"""State the SQL functions composed over the functions of another type. + +A SQL function with no C symbol whose body converts its arguments and calls one other +SQL function is a composition: `eDisjoint(tpose, geometry)` is +`SELECT eDisjoint($1::tgeompoint, $2)`, a `tpose` answered by the relationship of the +temporal point it casts to. Every step is a MEOS function the catalog carries, so a +binding builds the function from the steps and never reads SQL. The catalog states each +composition in the top-level list `compositions`: + + {"sqlName": "eDisjoint", "args": ["tpose", "geometry"], "ret": "boolean", ..., + "operands": [{"arg": 0, "casts": ["tpose_to_tpoint"]}, {"arg": 1}], + "call": "edisjoint_tgeo_geo"} + +`operands` holds one entry per argument of the call: the argument `arg` of the +composition (counting from 0) passed through the MEOS functions `casts` in order, or a +`value` the body passes as it is. `call` is the MEOS function called; a composition +without it answers the end of the cast chain of its one operand (`centroid(tpcpoint)` is +`SELECT $1::tgeompoint`). `restore` returns an answer over the cast type to the type of +argument `of`: the time of the answer, read by the MEOS function `time`, restricts that +argument through the MEOS function `at`. A split restores the column `from` of each row +the call returns into the column `column` of the row the composition returns: + + SELECT r.point, atTime($1, getTime(r.tpoint)) + FROM spaceSplit($1::tgeompoint, $2, $3, $4, $5, $6, $7) AS r + +and `nearestApproachInstant(tpcpoint, geometry)` restores the instant the call returns, +`SELECT atTime($1, getTimestamp(nearestApproachInstant($1::tgeompoint, $2)))`. + +The body is read from the deployed `.in.sql` statements, the surface PostgreSQL creates. +Each step resolves through the SQL signatures the catalog already carries: `$1::T` names +the function of the `CREATE CAST` from the argument's type to `T`, `f($1)` and the call +name a SQL function by name and argument types, and the step is the public MEOS function +carrying that signature. A composition whose call is itself a composition takes the +steps of that composition: `spaceSplit(tpose, xsize, sorigin, ...)`, which forwards to +`spaceSplit(tpose, xsize, 0, 0, sorigin, ...)`, states that function's cast, call and +restore over its own arguments and the two literals. + +A body is a composition when it takes this form and converts an argument, restores, or +calls a composition. A body of this form with a step that resolves to no public MEOS +function stops the catalog: a binding would call something MEOS does not provide. Every +other body is no composition: one passing its arguments to a C-backed function as they +are, and a program of several steps (a set difference, a CASE, a subquery).""" +import re +from itertools import permutations + +from parser.sqlfn import _c_base, _fits, _sql_ctypes, sql_signature, sql_statements +from parser.typescope import SQL_ALIASES + +_SCHEMA = re.compile(r"@extschema@\.") +_CAST = re.compile(r"CREATE\s+CAST\s*\(\s*([\w.]+)\s+AS\s+([\w.]+)\s*\)\s*" + r"WITH\s+FUNCTION\s+([\w.]+)\s*\(([^)]*)\)", re.I) +_TOKEN = re.compile(r"\s*(?:(\$\d+)|('(?:[^']|'')*')|(-?\d+(?:\.\d+)?(?![\w.]))|" + r"([A-Za-z_]\w*)|(::|[(),.;]))") +_KEYWORDS = {"select", "from", "as"} +_LITERALS = {"true", "false", "null"} + + +def _type(t): + """One spelling for each SQL type (`float8` and `float`), through #SQL_ALIASES of + parser/typescope.py, without the type modifier PostgreSQL ignores when it resolves + a function (`geometry(Point)` is `geometry`).""" + t = re.sub(r"\s*\(.*\)$", "", t.strip().lower()) + return SQL_ALIASES.get(t, t) + + +class _Misfit(Exception): + """A body outside the form of a composition.""" + + +class _Parser: + """The expressions of a composition body: `$k`, a literal, `f(e, ...)`, `e::T` and a + row column `r.c`, in a `SELECT e, ... [FROM f(...) AS r]`.""" + + def __init__(self, body): + text, self.toks, i = _SCHEMA.sub("", body).strip().rstrip(";").strip(), [], 0 + while i < len(text): + m = _TOKEN.match(text, i) + if not m or m.end() == i: + raise _Misfit(f"no token at {text[i:i + 20]!r}") + arg, string, number, word, punct = m.groups() + if arg: + self.toks.append(("arg", int(arg[1:]) - 1)) + elif string or number: + self.toks.append(("lit", string or number)) + elif word: + low = word.lower() + self.toks.append(("kw", low) if low in _KEYWORDS else + ("lit", low.upper()) if low in _LITERALS else + ("word", word)) + else: + self.toks.append(("p", punct)) + i = m.end() + self.i = 0 + + def peek(self, kind=None, value=None): + tok = self.toks[self.i] if self.i < len(self.toks) else (None, None) + return tok if ((kind is None or tok[0] == kind) + and (value is None or tok[1] == value)) else None + + def take(self, kind, value=None): + tok = self.peek(kind, value) + if tok is None: + raise _Misfit(f"expected {value or kind}") + self.i += 1 + return tok[1] + + def expr(self): + if self.peek("arg"): + node = ("arg", self.take("arg")) + elif self.peek("lit"): + node = ("lit", self.take("lit")) + else: + name = self.take("word") + if self.peek("p", "."): + self.take("p", ".") + node = ("col", name, self.take("word")) + else: + self.take("p", "(") + args = [] + while not self.peek("p", ")"): + args.append(self.expr()) + if not self.peek("p", ")"): + self.take("p", ",") + self.take("p", ")") + node = ("call", name, args) + while self.peek("p", "::"): + self.take("p", "::") + node = ("cast", node, self.take("word")) + return node + + def statement(self): + """(select list, FROM call or None, FROM alias or None).""" + self.take("kw", "select") + items = [self.expr()] + while self.peek("p", ","): + self.take("p", ",") + items.append(self.expr()) + source = alias = None + if self.peek("kw", "from"): + self.take("kw", "from") + source = self.expr() + self.take("kw", "as") + alias = self.take("word") + if self.i != len(self.toks): + raise _Misfit("text after the statement") + return items, source, alias + + +class _Resolver: + """Names each step of a composition by the public MEOS function carrying its SQL + signature.""" + + def __init__(self, idl, stmts, vocab, composites, text_casts): + self.by_sig, self.sqlc = {}, _sql_ctypes(idl) + for f in idl.get("functions", []): + for s in f.get("sqlSignatures") or (): + name = (s.get("sqlName") or f.get("sqlfn") or "").lower() + self.by_sig.setdefault(name, []).append((f, s)) + self.casts = text_casts + self.bodies = {} + for sqlname, argdecls, ret, wrapper, retset, body in stmts: + if wrapper is None and body: + sig = sql_signature(sqlname, argdecls, ret, retset, vocab, composites) + self.bodies.setdefault(sqlname.lower(), []).append((sig, body)) + # The SQL functions MobilityDB declares: those of the statements read and those + # whose signatures the catalog carries, read from the same statements. + self.declared = {stmt[0].lower() for stmt in stmts} | set(self.by_sig) + self.done, self.active = {}, set() + + def foreign(self, node): + """Whether expression `node` calls a function MobilityDB does not declare (a + PostGIS function such as `ST_Transform`): a body calling one composes over + PostGIS, whose functions MEOS does not carry.""" + if node[0] == "call": + return (node[1].lower() not in self.declared + or any(self.foreign(a) for a in node[2])) + return node[0] == "cast" and self.foreign(node[1]) + + @staticmethod + def _fits(sig, types): + """Whether a call passing arguments of `types` (None for a literal) reaches + `sig`, its defaults allowing fewer.""" + args, dflts = sig["args"], sig.get("argDefaults") or [None] * len(sig["args"]) + if not len(types) <= len(args) or any(d is None for d in dflts[len(types):]): + return False + return all(t is None or _type(t) == _type(a) for t, a in zip(types, args)) + + def function(self, name, types): + """(MEOS function, its SQL signature) of the C-backed SQL function `name` over + `types`, or None when no C-backed signature fits. Raises when two signatures + fit, or when the one that fits is carried by no public MEOS function or by two.""" + fits = {} + for f, s in self.by_sig.get(name.lower(), ()): + if self._fits(s, types): + fits.setdefault(tuple(s["args"]), []).append(f) + if not fits: + return None + if len(fits) > 1: + raise _Misfit(f"{name}({', '.join(t or '?' for t in types)}) fits " + + "; ".join(f"({', '.join(a)})" for a in fits)) + (args, funcs), = fits.items() + public = [f for f in funcs if f.get("api") == "public"] + if len(public) != 1: + raise _Misfit(f"{name}({', '.join(args)}) is carried by " + f"{len(public)} public MEOS functions") + sig = next(s for f, s in self.by_sig[name.lower()] + if f is public[0] and tuple(s["args"]) == args) + return public[0], sig + + def params(self, func, sig, ops): + """The operands of a call of `func` through `sig`, one per C input parameter of + `func` in its order, each naming the parameter it feeds, `param`. A literal + `sig` binds (`boundArgs`) is a `value` operand. The others are `ops`, in order + when every operand's SQL type fits its parameter's C type (#_fits of + parser/sqlfn.py), else in the one order in which each fits, as a signature + whose wrapper passes its arguments in another order than the C function takes + them (`nearestApproachDistance(geometry, tgeompoint)` of `nad_tgeo_geo(temp, + gs)`). Arguments the call leaves out take the defaults of `sig`.""" + shape = func.get("shape") or {} + bound = sig.get("boundArgs") or shape.get("boundArgs") or {} + out = set(shape.get("outParams") or ()) + inputs = [p for p in func.get("params") or () + if p["name"] not in out and p["name"] not in bound] + dflts = sig.get("argDefaults") or [None] * len(sig["args"]) + ops = ops + [{"value": dflts[i], "type": None} for i in range(len(ops), len(sig["args"]))] + if len(ops) != len(inputs): + raise _Misfit(f"{func['name']} takes {len(inputs)} arguments, the call " + f"passes {len(ops)}") + types = [o["type"] or a for o, a in zip(ops, sig["args"])] + + def fit(order): + return all(_fits(_type(t), *_c_base(p.get("cType")), self.sqlc) + for t, p in zip(types, order)) + if fit(inputs): + order = inputs + else: + orders = [o for o in permutations(inputs) if fit(o)] + if len(orders) != 1: + raise _Misfit(f"{func['name']}({', '.join(types)}): {len(orders)} " + f"orders of its parameters fit") + order = orders[0] + fed = {p["name"]: {**o, "param": p["name"]} for o, p in zip(ops, order)} + return [fed.get(p["name"]) or {"value": bound[p["name"]], "type": None, + "param": p["name"]} + for p in func.get("params") or () if p["name"] not in out] + + def composition(self, name, types): + """The composition `name` over `types` states, or None when no body fits.""" + fits = [(sig, body) for sig, body in self.bodies.get(name.lower(), ()) + if self._fits(sig, types)] + if len(fits) != 1: + return None + sig, body = fits[0] + return self.compose(sig, body) + + def compose(self, sig, body): + """The composition entry of `sig` with `body`, None for a body that is no + composition. Raises _Misfit for a composition whose steps do not resolve.""" + key = (sig["sqlName"].lower(), tuple(sig["args"])) + if key in self.done: + return self.done[key] + if key in self.active: + raise _Misfit("calls itself") + self.active.add(key) + try: + entry = self._compose(sig, body) + finally: + self.active.discard(key) + self.done[key] = entry + return entry + + def step(self, operand, name, conv): + """`operand` passed through the SQL function `name` of one argument (a cast when + `conv` names its target type): the MEOS function, appended to its casts.""" + src = operand["type"] + if conv: + if _type(src) == _type(name): + return operand # a cast to its own type + fn = self.casts.get((_type(src), _type(name))) + if fn is None: + raise _Misfit(f"no CREATE CAST from {src} to {name}") + name = fn + found = self.function(name, [src]) + if found is None: + raise _Misfit(f"{name}({src}) has no C-backed signature") + return {**operand, "casts": operand.get("casts", []) + [found[0]["name"]], + "type": found[1]["ret"]} + + def operand(self, node, sig): + """The operand an argument expression of a call states: {arg, casts, type} or + {value, type: None}.""" + kind = node[0] + if kind == "arg": + if node[1] >= len(sig["args"]): + raise _Misfit(f"${node[1] + 1} beyond the arguments") + return {"arg": node[1], "type": sig["args"][node[1]]} + if kind == "lit": + return {"value": node[1], "type": None} + if kind == "cast": + return self.step(self.operand(node[1], sig), node[2], conv=True) + if kind == "call" and len(node[2]) == 1: + return self.step(self.operand(node[2][0], sig), node[1], conv=False) + raise _Misfit("an argument computed by more than a cast chain") + + def call(self, node, sig): + """(operands, callee MEOS function or None, callee return, callee columns, + restore of the callee's own composition) of the call `node` over `sig`'s + arguments. A callee that is a composition lends its steps.""" + name, args = node[1], node[2] + ops = [self.operand(a, sig) for a in args] + types = [o["type"] for o in ops] + found = self.function(name, types) + if found is not None: + func, csig = found + return (self.params(func, csig, ops), func["name"], csig["ret"], + csig.get("columns"), None) + inner = self.composition(name, types) + if inner is None: + raise _Misfit(f"{name}({', '.join(t or '?' for t in types)}) resolves to " + f"neither a C-backed signature nor a composition") + merged = [] + for o in inner["operands"]: + if "value" in o: + merged.append(dict(o)) + continue + outer = ops[o["arg"]] + both = outer.get("casts", []) + o.get("casts", []) + if "value" in outer: + if both: + raise _Misfit("a literal passed through a cast") + merged.append({"value": outer["value"], "param": o["param"]}) + else: + merged.append({"arg": outer["arg"], **({"casts": both} if both else {}), + "param": o["param"]}) + restore = inner.get("restore") + if restore: + outer = ops[restore["of"]] + if "value" in outer or outer.get("casts"): + raise _Misfit("a restore of an argument the call converts") + restore = {**restore, "of": outer["arg"]} + return merged, inner.get("call"), inner["ret"], inner.get("columns"), restore + + def _compose(self, sig, body): + try: + items, source, alias = _Parser(body).statement() + except _Misfit: + return None # a program, not a form + if any(self.foreign(n) for n in items + ([source] if source else [])): + return None # composed over PostGIS + restore = None + if source is not None: + # SELECT r.a, [r.b, ...] at($k, time(r.c)) FROM f(...) AS r + if source[0] != "call": + return None + ops, fn, cret, ccols, inner_restore = self.call(source, sig) + if inner_restore or not ccols or len(items) != len(ccols): + raise _Misfit("a restore over a call whose rows it does not match") + cnames = [c["name"] if isinstance(c, dict) else c[0] for c in ccols] + ctypes = [c["type"] if isinstance(c, dict) else c[1] for c in ccols] + onames = [c[0] if isinstance(c, (list, tuple)) else c["name"] + for c in (sig.get("columns") or ())] + for pos, item in enumerate(items): + if item == ("col", alias, cnames[pos]): + continue + if restore is not None: + raise _Misfit("two restored columns") + restore = self.restore(item, sig, ("col", alias, cnames[pos]), ctypes[pos]) + restore = {"column": onames[pos] if pos < len(onames) else None, + "from": cnames[pos], **restore} + if restore is None: + return None # a pass-through query + else: + if len(items) != 1: + return None + top = items[0] + scalar = self.scalar_restore(top, sig) + if scalar is not None: + ops, fn, cret, restore = scalar + elif top[0] == "call": + if not any(self.converts(a) for a in top[2]): + # No argument is converted: a forward, unless it reaches a composition. + try: + if self.function(top[1], [self.operand(a, sig)["type"] + for a in top[2]]) is not None: + return None + except _Misfit: + return None + ops, fn, cret, _, restore = self.call(top, sig) + elif top[0] in ("arg", "cast"): + ops, fn = [self.operand(top, sig)], None + if not ops[0].get("casts"): + return None + cret = ops[0]["type"] + else: + return None + # A call reaching a composition takes its casts, so it converts as well. + if not (any(o.get("casts") for o in ops) or restore): + return None # a plain forward + used = {o["arg"] for o in ops if "arg" in o} | ({restore["of"]} if restore else set()) + if used != set(range(len(sig["args"]))): + raise _Misfit(f"arguments {sorted(set(range(len(sig['args']))) - used)} unused") + if restore is None and _type(cret or "") != _type(sig["ret"] or ""): + raise _Misfit(f"answers {cret} where the function returns {sig['ret']}") + entry = {k: sig[k] for k in ("sqlName", "args", "required", "argDefaults", "ret")} + if sig.get("retSet"): + entry["retSet"] = True + if sig.get("columns"): + entry["columns"] = [{"name": n, "type": t} for n, t in sig["columns"]] + entry["operands"] = [{k: v for k, v in o.items() if k != "type"} for o in ops] + if fn is not None: + entry["call"] = fn + if restore: + entry["restore"] = restore + return entry + + @staticmethod + def converts(node): + """Whether the argument expression `node` passes an argument through a cast or + a function of one argument.""" + return node[0] == "cast" or (node[0] == "call" and len(node[2]) == 1) + + def restore(self, node, sig, column, ctype): + """{of, time, at} of `at($k, time())`, the answer at `column` restored to + argument k.""" + if not (node[0] == "call" and len(node[2]) == 2 and node[2][0][0] == "arg" + and node[2][1][0] == "call" and len(node[2][1][2]) == 1 + and node[2][1][2][0] == column): + raise _Misfit("a column computed by more than a restore") + k = node[2][0][1] + time = self.function(node[2][1][1], [ctype]) + if time is None: + raise _Misfit(f"{node[2][1][1]}({ctype}) has no C-backed signature") + at = self.function(node[1], [sig["args"][k], time[1]["ret"]]) + if at is None: + raise _Misfit(f"{node[1]}({sig['args'][k]}, {time[1]['ret']}) has no " + f"C-backed signature") + return {"of": k, "time": time[0]["name"], "at": at[0]["name"]} + + def scalar_restore(self, node, sig): + """(operands, callee, callee return, restore) of `at($k, time(f(...)))`, the + value the call returns restored to argument k; None for another expression.""" + if not (node[0] == "call" and len(node[2]) == 2 and node[2][0][0] == "arg" + and node[2][1][0] == "call" and len(node[2][1][2]) == 1 + and node[2][1][2][0][0] == "call"): + return None + inner = node[2][1][2][0] + ops, fn, cret, _, inner_restore = self.call(inner, sig) + if inner_restore: + raise _Misfit("a restore of a restored answer") + k = node[2][0][1] + time = self.function(node[2][1][1], [cret]) + if time is None: + raise _Misfit(f"{node[2][1][1]}({cret}) has no C-backed signature") + at = self.function(node[1], [sig["args"][k], time[1]["ret"]]) + if at is None: + raise _Misfit(f"{node[1]}({sig['args'][k]}, {time[1]['ret']}) has no " + f"C-backed signature") + return ops, fn, cret, {"of": k, "time": time[0]["name"], "at": at[0]["name"]} + + +def _text_casts(sql_src): + """{(source type, target type): cast function SQL name} of every `CREATE CAST ... + WITH FUNCTION f(source)` under `sql_src`; a cast whose function takes more than the + source (a typmod) is left out.""" + from pathlib import Path + from parser.sqlfn import _strip_sql_comments + out = {} + for sf in sorted(Path(sql_src).rglob("*.sql")): + text = _SCHEMA.sub("", _strip_sql_comments(sf.read_text(errors="ignore"))) + for src, dst, fn, fargs in _CAST.findall(text): + if len([a for a in fargs.split(",") if a.strip()]) == 1: + out[(_type(src), _type(dst))] = fn + return out + + +def attach_compositions(idl, sql_src): + """(idl with its top-level `compositions`, count, bodies of several steps left out). + Raises ValueError naming every composition a step of which resolves to no public + MEOS function.""" + stmts, vocab, composites = sql_statements(sql_src) + res = _Resolver(idl, stmts, vocab, composites, _text_casts(sql_src)) + out, errors = [], [] + for name in sorted(res.bodies): + for sig, body in res.bodies[name]: + try: + entry = res.compose(sig, body) + except _Misfit as e: + errors.append(f"{sig['sqlName']}({', '.join(sig['args'])}): {e}") + continue + if entry is not None: + out.append(entry) + if errors: + raise ValueError("SQL compositions with a step no public MEOS function takes:\n " + + "\n ".join(errors)) + idl["compositions"] = out + return idl, len(out) diff --git a/parser/sqlfn.py b/parser/sqlfn.py index d2fc21f..57e0487 100644 --- a/parser/sqlfn.py +++ b/parser/sqlfn.py @@ -68,6 +68,9 @@ # binds is in the trailing `AS 'MODULE_PATHNAME', ''`. _CREATE_FN = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(\w+)\s*\(", re.I) _AS_WRAPPER = re.compile(r"AS\s+'[^']*'\s*,\s*'(\w+)'", re.I) +# The body of a function with no C symbol: `AS 'SELECT ...'` (quotes doubled inside) +# or `AS $$ SELECT ... $$`. +_AS_BODY = re.compile(r"\bAS\s+(?:'((?:[^']|'')*)'|\$\$(.*?)\$\$)", re.I | re.S) # A CREATE FUNCTION attribute that may follow RETURNS before the body. _RET_ATTR = re.compile( r"\b(?:SUPPORT|LANGUAGE|WINDOW|IMMUTABLE|STABLE|VOLATILE|LEAKPROOF|CALLED|RETURNS\s+NULL|" @@ -202,7 +205,7 @@ def _strip_sql_comments(text): return "".join(out) -def _create_fn_stmts(text): +def _create_fn_stmts(text, bodies=False): """Yield (sqlName, [raw arg decls], returnType|None, wrapper|None, retSet) for every CREATE FUNCTION in `text`, each parsed STATEMENT-BOUNDED (to its terminating `;`). returnType is the type of one returned row; retSet is True for `RETURNS SETOF`, @@ -210,7 +213,10 @@ def _create_fn_stmts(text): Bounding to the `;` is what stops a `LANGUAGE SQL` default-arg overload (whose own `AS 'SELECT ...'` has no C symbol) from bleeding its RETURNS/AS across the boundary into the next C-backed statement — the cross-statement mis-attribution that produced - garbage return types. wrapper is None for a LANGUAGE SQL / $$ body (no C symbol).""" + garbage return types. wrapper is None for a LANGUAGE SQL / $$ body (no C symbol). + With `bodies`, each tuple ends with that body, read from the same `AS` clause + #_AS_WRAPPER reads a C symbol from, its quotes undoubled; None for a function + with a C symbol.""" for m in _CREATE_FN.finditer(text): sqlname = m.group(1) i, depth, start = m.end(), 1, m.end() @@ -233,7 +239,14 @@ def _create_fn_stmts(text): # return type `boolean SUPPORT tspatial_supportfn`. Keep only the type. ret = _RET_ATTR.split(ret, maxsplit=1)[0].strip() or ret argdecls = [a for a in _split_top_commas(text[start:arg_close]) if a.strip()] - yield sqlname, argdecls, ret, wrapper, retset + if not bodies: + yield sqlname, argdecls, ret, wrapper, retset + continue + bm = None if wrapper else _AS_BODY.search(tail) + body = None + if bm: + body = bm.group(1).replace("''", "'") if bm.group(1) is not None else bm.group(2) + yield sqlname, argdecls, ret, wrapper, retset, body def _wrapper_sql_sigs(sql_src): @@ -251,36 +264,51 @@ def _wrapper_sql_sigs(sql_src): the type vocabulary and the composite types, then resolve every arg's type against the vocabulary.""" out = {} + stmts, vocab, composites = sql_statements(sql_src) + for sqlname, argdecls, ret, wrapper, retset, _ in stmts: + if wrapper is None: + continue # LANGUAGE SQL / $$ body — no C symbol + out.setdefault(wrapper, []).append( + sql_signature(sqlname, argdecls, ret, retset, vocab, composites)) + return out + + +def sql_statements(sql_src): + """(statements, type vocabulary, composite types) of the CREATE FUNCTION statements + under `sql_src`, each statement as #_create_fn_stmts yields it with its body. The + vocabulary holds every RETURNS type and every single-token argument type, so an + argument's type is read against it (#_arg_type).""" + stmts, vocab, composites = [], set(), {} sql_src = Path(sql_src) if not sql_src.exists(): - return out - stmts, vocab, composites = [], set(), {} + return stmts, vocab, composites for sf in sorted(sql_src.rglob("*.sql")): text = _strip_sql_comments(sf.read_text(errors="ignore")) composites.update(_composite_types(text)) - for sqlname, argdecls, ret, wrapper, retset in _create_fn_stmts(text): - stmts.append((sqlname, argdecls, ret, wrapper, retset)) + for stmt in _create_fn_stmts(text, bodies=True): + stmts.append(stmt) + argdecls, ret = stmt[1], stmt[2] if ret: vocab.add(ret) # a RETURNS clause is always a type for a in argdecls: bt = _bare_type(a) if bt and " " not in bt: vocab.add(bt) # a single-token arg is always a type - for sqlname, argdecls, ret, wrapper, retset in stmts: - if wrapper is None: - continue # LANGUAGE SQL / $$ body — no C symbol - indecls = [a for a in argdecls if _is_in_arg(a)] - args = [_arg_type(a, vocab) for a in indecls] - arg_defaults = [_arg_default(a) for a in indecls] - required = sum(1 for a in indecls if not re.search(r"\bDEFAULT\b", a, re.I)) - outcols = [_column(_OUTMODE.sub("", a.strip())) for a in argdecls - if _OUTMODE.match(a.strip())] - columns = outcols or composites.get(ret) - out.setdefault(wrapper, []).append( - {"sqlName": sqlname, "args": args, "required": required, - "argDefaults": arg_defaults, "ret": ret, "retSet": retset, - "columns": columns if columns and len(columns) > 1 else None}) - return out + return stmts, vocab, composites + + +def sql_signature(sqlname, argdecls, ret, retset, vocab, composites): + """The SQL signature {sqlName, args, required, argDefaults, ret, retSet, columns} a + CREATE FUNCTION states, as #_wrapper_sql_sigs lists it.""" + indecls = [a for a in argdecls if _is_in_arg(a)] + outcols = [_column(_OUTMODE.sub("", a.strip())) for a in argdecls + if _OUTMODE.match(a.strip())] + columns = outcols or composites.get(ret) + return {"sqlName": sqlname, "args": [_arg_type(a, vocab) for a in indecls], + "required": sum(1 for a in indecls if not re.search(r"\bDEFAULT\b", a, re.I)), + "argDefaults": [_arg_default(a) for a in indecls], + "ret": ret, "retSet": retset, + "columns": columns if columns and len(columns) > 1 else None} def _meos_to_mdb(meos_src): @@ -447,8 +475,10 @@ def _sql_ctypes(idl): catalog: a class is its `cType` (the object model names `TInt`, `TsTzSpanSet`, `Geometry` after the SQL types, lower-cased), every temporal type a `Temporal`, a base type its C spelling (#C_BASE_TYPES of parser/typescope.py, in - PostgreSQL's spelling through #sql_spellings), a cell id `uint64`, and every - base type of the type relations also a `Datum`.""" + PostgreSQL's spelling through #sql_spellings), a cell id `uint64`, every + base type of the type relations also a `Datum`, and every set, span and span set + the type relations build over a base type the `cType` of the class `Set`, `Span` + or `SpanSet` (`h3indexset` is a `Set`).""" out = {} for cls, rec in ((idl.get("objectModel") or {}).get("classes") or {}).items(): if rec.get("cType"): @@ -461,9 +491,15 @@ def _sql_ctypes(idl): for m in (meos if isinstance(meos, tuple) else (meos,)): for name in sql_spellings({m}): out.setdefault(name, set()).add(c) - for base in ((idl.get("typeRelations") or {}).get("byBase") or {}): + classes = (idl.get("objectModel") or {}).get("classes") or {} + containers = {"set": "Set", "span": "Span", "spanset": "SpanSet"} + for base, rel in ((idl.get("typeRelations") or {}).get("byBase") or {}).items(): for name in sql_spellings({base}): out.setdefault(name, set()).add("Datum") + for kind, cls in containers.items(): + ctype = (classes.get(cls) or {}).get("cType") + if rel.get(kind) and ctype: + out.setdefault(rel[kind], set()).add(_c_base(ctype)[0]) return out diff --git a/parser/typescope.py b/parser/typescope.py index 134f7ab..9e5fa26 100644 --- a/parser/typescope.py +++ b/parser/typescope.py @@ -53,7 +53,7 @@ C_BASE_TYPES = { 'int': 'int4', 'int32': 'int4', 'int64': 'int8', 'double': 'float8', 'bool': 'bool', 'text': 'text', 'DateADT': 'date', - 'TimestampTz': 'timestamptz', + 'TimestampTz': 'timestamptz', 'Interval': 'interval', 'GSERIALIZED': ('geometry', 'geography'), } diff --git a/run.py b/run.py index 2e186b6..d35a4ac 100644 --- a/run.py +++ b/run.py @@ -16,6 +16,7 @@ from parser.nullresult import attach_null_result from parser.outparam import extract_param_names, merge_outparams from parser.boundargs import merge_boundargs, resolve_bound_names +from parser.compositions import attach_compositions from parser.enrich import enrich_idl from parser.sqlfn import (attach_sqlfn_map, attach_aggfn_map, attach_row_sources, attach_sqlaggfn_map, lint_container_family_csqlfn, @@ -342,6 +343,15 @@ def main(): idl, nrows = attach_row_sources(idl) print(f" SQL rows with every column's C source: {nrows}", file=sys.stderr) + # State each SQL function composed over the functions of another type, its casts, + # call and restore named by MEOS function, read from the deployed SQL bodies and + # resolved through the signatures above; a step no public MEOS function takes + # stops the catalog. + if SQL_SRC.exists(): + idl, ncomp = attach_compositions(idl, SQL_SRC) + print(f" SQL compositions over another type's functions: {ncomp}", + file=sys.stderr) + # Stamp the MobilityDB source commit so the catalog is SELF-DESCRIBING about its freshness: # a consumer proves it is current by comparing sourceCommit to live upstream master, never by # inspecting whatever directory a vendored copy sits in. None when the source is not a git diff --git a/tests/test_compositions.py b/tests/test_compositions.py new file mode 100644 index 0000000..276d9da --- /dev/null +++ b/tests/test_compositions.py @@ -0,0 +1,318 @@ +"""The SQL functions composed over the functions of another type, stated step by step. + +A SQL function with no C symbol whose body casts its arguments and calls one other SQL +function is a composition, stated in the catalog's top-level `compositions` with its +operands (each an argument through its casts, or a value), its call and its restore, all +named by MEOS function. Each step resolves through the SQL signatures the catalog +carries, and one that resolves to no public MEOS function stops the catalog. + +The catalog is synthetic, as the one #FamilyClassificationTests of tests/test_family.py +reads is generated, and the SQL sits in a temp dir, as #RowSourceTests of +tests/test_sqlfn_rows.py writes its declarations. Plain unittest, no pytest dependency. +""" +import tempfile +import unittest +from pathlib import Path + +from parser.compositions import attach_compositions + + +def _fn(name, sqlfn, params, sigs, api="public", shape=None): + f = {"name": name, "sqlfn": sqlfn, "api": api, + "params": [{"name": n, "cType": c} for n, c in params], + "sqlSignatures": [{"sqlName": sqlfn, **s} for s in sigs]} + if shape: + f["shape"] = shape + return f + + +T, G, B = "const Temporal *", "const GSERIALIZED *", "const STBox *" + +FUNCTIONS = [ + _fn("tpose_to_tpoint", "tgeompoint", [("temp", T)], + [{"args": ["tpose"], "ret": "tgeompoint"}]), + _fn("tposechain_to_tpose", "tpose", [("temp", T)], + [{"args": ["tposechain"], "ret": "tpose"}]), + _fn("tpcpoint_to_tgeompoint", "tgeompoint", [("temp", T)], + [{"args": ["tpcpoint"], "ret": "tgeompoint"}]), + _fn("tpcpatch_to_tgeometry", "tgeometry", [("temp", T)], + [{"args": ["tpcpatch"], "ret": "tgeometry"}]), + _fn("tcellindex_cell_to_boundary", "cellToBoundary", [("temp", T)], + [{"args": ["th3index"], "ret": "tgeography"}]), + _fn("tgeography_to_tgeometry", "tgeometry", [("temp", T)], + [{"args": ["tgeography"], "ret": "tgeometry"}]), + _fn("tspatial_to_stbox", "stbox", [("temp", T)], + [{"args": ["tgeompoint"], "ret": "stbox"}]), + _fn("edisjoint_tgeo_geo", "eDisjoint", [("temp", T), ("gs", G)], + [{"args": ["tgeompoint", "geometry"], "ret": "boolean"}]), + _fn("econtains_tgeo_geo", "eContains", [("temp", T), ("gs", G)], + [{"args": ["tgeometry", "geometry"], "ret": "boolean"}]), + # one function carrying both argument orders, its wrapper swapping them + _fn("nad_tgeo_geo", "nearestApproachDistance", [("temp", T), ("gs", G)], + [{"args": ["tgeompoint", "geometry(Point)"], "ret": "float"}, + {"args": ["geometry(Point)", "tgeompoint"], "ret": "float"}]), + _fn("stbox_expand_space", "expandSpace", [("box", B), ("d", "double")], + [{"args": ["stbox", "float"], "ret": "stbox"}]), + _fn("tgeo_space_split", "spaceSplit", + [("temp", T), ("xsize", "double"), ("ysize", "double"), ("zsize", "double"), + ("sorigin", G), ("bitmatrix", "bool"), ("border_inc", "bool"), + ("space_bins", "GSERIALIZED ***"), ("count", "int *")], + [{"args": ["tgeompoint", "float", "float", "float", "geometry", "boolean", + "boolean"], "ret": "point_tgeo", "retSet": True, + "argDefaults": [None, None, None, None, "'Point(0 0 0)'", "TRUE", "TRUE"], + "columns": [{"name": "point", "type": "geometry"}, + {"name": "tpoint", "type": "tgeompoint"}]}], + shape={"outParams": ["space_bins", "count"]}), + _fn("temporal_time", "getTime", [("temp", T)], + [{"args": ["tgeompoint"], "ret": "tstzspanset"}]), + # the per-subtype twin carries the same signature, and is internal + _fn("tinstant_time", "getTime", [("inst", T)], + [{"args": ["tgeompoint"], "ret": "tstzspanset"}], api="internal"), + _fn("temporal_at_tstzspanset", "atTime", [("temp", T), ("ss", "const SpanSet *")], + [{"args": ["tpose", "tstzspanset"], "ret": "tpose"}]), + _fn("temporal_at_timestamptz", "atTime", [("temp", T), ("t", "TimestampTz")], + [{"args": ["tpcpoint", "timestamptz"], "ret": "tpcpoint"}]), + _fn("nai_tgeo_geo", "nearestApproachInstant", [("temp", T), ("gs", G)], + [{"args": ["tgeompoint", "geometry"], "ret": "tgeompoint"}]), + _fn("temporal_start_timestamptz", "getTimestamp", [("temp", T)], + [{"args": ["tgeompoint"], "ret": "timestamptz"}]), + _fn("tgeo_space_time_boxes", "timeBoxes", + [("temp", T), ("xsize", "double"), ("ysize", "double"), ("zsize", "double"), + ("duration", "const Interval *"), ("sorigin", G), ("torigin", "TimestampTz"), + ("bitmatrix", "bool"), ("border_inc", "bool"), ("count", "int *")], + [{"args": ["tgeompoint", "interval", "timestamptz", "boolean", "boolean"], + "ret": "stbox[]", + "boundArgs": {"xsize": "0.0", "ysize": "0.0", "zsize": "0.0", + "sorigin": "NULL"}}], + shape={"outParams": ["count"]}), + _fn("temporal_stops", "stops", [("temp", T), ("maxdist", "double"), + ("minduration", "const Interval *")], + [{"args": ["tgeompoint", "float", "interval"], "ret": "tgeompoint"}]), + _fn("acontains_tgeo_tgeo", "aContains", [("temp1", T), ("temp2", T)], + [{"args": ["tgeometry", "tgeometry"], "ret": "boolean"}], api="internal"), +] + + +def _idl(): + return {"functions": [dict(f) for f in FUNCTIONS], + "objectModel": {"classes": {"Geometry": {"cType": "GSERIALIZED *"}, + "STBox": {"cType": "STBox *"}, + "TsTzSpanSet": {"cType": "SpanSet *"}}}, + "temporalTypes": {t: {} for t in ( + "tgeompoint", "tgeometry", "tgeography", "tpose", "tposechain", + "tpcpoint", "tpcpatch", "th3index")}} + + +CASTS = """ +CREATE CAST (tpose AS tgeompoint) WITH FUNCTION tgeompoint(tpose); +CREATE CAST (tposechain AS tpose) WITH FUNCTION tpose(tposechain); +CREATE CAST (tpcpoint AS tgeompoint) WITH FUNCTION tgeompoint(tpcpoint); +CREATE CAST (tpcpatch AS tgeometry) WITH FUNCTION tgeometry(tpcpatch); +CREATE CAST (tgeography AS tgeometry) WITH FUNCTION tgeometry(tgeography); +CREATE CAST (tgeompoint AS stbox) WITH FUNCTION stbox(tgeompoint); +CREATE TYPE point_tpose AS (point geometry, tpose tpose); +""" + +COMPOSED = CASTS + """ +CREATE FUNCTION eDisjoint(tpose, geometry) + RETURNS boolean + AS 'SELECT @extschema@.eDisjoint($1::@extschema@.tgeompoint, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION eDisjoint(tposechain, geometry) + RETURNS boolean + AS 'SELECT @extschema@.eDisjoint($1::@extschema@.tpose, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION eContains(th3index, geometry) + RETURNS boolean + AS 'SELECT @extschema@.eContains(@extschema@.cellToBoundary($1)::@extschema@.tgeometry, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION nearestApproachDistance(geometry, tpcpoint) + RETURNS float + AS $$ SELECT @extschema@.nearestApproachDistance($1, $2::@extschema@.tgeompoint) $$ + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION expandSpace(tgeompoint, float) + RETURNS stbox + AS 'SELECT @extschema@.expandSpace($1::@extschema@.stbox, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION centroid(tpcpoint) + RETURNS tgeompoint + AS 'SELECT $1::@extschema@.tgeompoint' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION spaceSplit(tpose, xsize float, ysize float, zsize float, + sorigin geometry DEFAULT 'Point(0 0 0)', bitmatrix boolean DEFAULT TRUE, + borderInc boolean DEFAULT TRUE) + RETURNS SETOF point_tpose + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT r.point, @extschema@.atTime($1, @extschema@.getTime(r.tpoint)) + FROM @extschema@.spaceSplit( + $1::@extschema@.tgeompoint, $2, $3, $4, $5, $6, $7) AS r + $$; +CREATE FUNCTION spaceSplit(tpose, xsize float, sorigin geometry DEFAULT 'Point(0 0 0)', + bitmatrix boolean DEFAULT TRUE, borderInc boolean DEFAULT TRUE) + RETURNS SETOF point_tpose + AS 'SELECT @extschema@.spaceSplit($1, $2, 0, 0, $3, $4, $5)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION nearestApproachInstant(tpcpoint, geometry) + RETURNS tpcpoint + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT @extschema@.atTime($1, + @extschema@.getTimestamp( + @extschema@.nearestApproachInstant($1::@extschema@.tgeompoint, $2))) + $$; +CREATE FUNCTION timeBoxes(tpose, duration interval, + torigin timestamptz DEFAULT '2000-01-03', bitmatrix boolean DEFAULT TRUE, + borderInc boolean DEFAULT TRUE) + RETURNS stbox[] + AS 'SELECT @extschema@.timeBoxes($1::@extschema@.tgeompoint, $2, $3, $4, $5)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION stops(tgeompoint, interval) + RETURNS tgeompoint + AS 'SELECT @extschema@.stops($1, 0.0, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION eDisjoint(geometry, tgeompoint) + RETURNS boolean + AS 'SELECT @extschema@.eDisjoint($2, $1)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION asEWKT(geometry, integer) + RETURNS text + AS 'SELECT @extschema@.ST_AsEWKT($1, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION douglasPeuckerSimplify(tpose, float) + RETURNS tpose + AS 'SELECT COALESCE(@extschema@.deleteTime($1, @extschema@.set(@extschema@.timestamps($1)) + - @extschema@.set(@extschema@.timestamps( + @extschema@.douglasPeuckerSimplify($1::@extschema@.tgeompoint, $2)))), $1)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +""" + + +class CompositionTests(unittest.TestCase): + """Each composition the SQL bodies state, and the bodies that are none.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.sql = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def _attach(self, text): + (self.sql / "composed.in.sql").write_text(text) + return attach_compositions(_idl(), self.sql) + + def _entries(self): + idl, n = self._attach(COMPOSED) + self.assertEqual(n, len(idl["compositions"])) + return {(e["sqlName"], tuple(e["args"])): e for e in idl["compositions"]} + + def test_a_cast_then_a_call(self): + e = self._entries()[("eDisjoint", ("tpose", "geometry"))] + self.assertEqual(e["operands"], [ + {"arg": 0, "casts": ["tpose_to_tpoint"], "param": "temp"}, + {"arg": 1, "param": "gs"}]) + self.assertEqual(e["call"], "edisjoint_tgeo_geo") + self.assertNotIn("restore", e) + + def test_a_cast_to_a_composed_type_takes_its_casts(self): + e = self._entries()[("eDisjoint", ("tposechain", "geometry"))] + self.assertEqual(e["operands"][0]["casts"], + ["tposechain_to_tpose", "tpose_to_tpoint"]) + self.assertEqual(e["call"], "edisjoint_tgeo_geo") + + def test_a_cell_reaches_the_call_through_its_boundary(self): + e = self._entries()[("eContains", ("th3index", "geometry"))] + self.assertEqual(e["operands"][0]["casts"], + ["tcellindex_cell_to_boundary", "tgeography_to_tgeometry"]) + + def test_operands_follow_the_parameters_of_the_c_function(self): + # the SQL call passes the geometry first; nad_tgeo_geo takes it second + e = self._entries()[("nearestApproachDistance", ("geometry", "tpcpoint"))] + self.assertEqual(e["operands"], [ + {"arg": 1, "casts": ["tpcpoint_to_tgeompoint"], "param": "temp"}, + {"arg": 0, "param": "gs"}]) + + def test_a_cast_to_the_box(self): + e = self._entries()[("expandSpace", ("tgeompoint", "float"))] + self.assertEqual(e["operands"][0]["casts"], ["tspatial_to_stbox"]) + self.assertEqual(e["call"], "stbox_expand_space") + + def test_a_cast_alone(self): + e = self._entries()[("centroid", ("tpcpoint",))] + self.assertEqual(e["operands"], [{"arg": 0, "casts": ["tpcpoint_to_tgeompoint"]}]) + self.assertNotIn("call", e) + + def test_a_split_restores_each_fragment_to_the_input_type(self): + e = self._entries()[("spaceSplit", ("tpose", "float", "float", "float", + "geometry", "boolean", "boolean"))] + self.assertEqual(e["call"], "tgeo_space_split") + self.assertEqual(e["restore"], {"column": "tpose", "from": "tpoint", "of": 0, + "time": "temporal_time", + "at": "temporal_at_tstzspanset"}) + self.assertEqual([c["name"] for c in e["columns"]], ["point", "tpose"]) + self.assertTrue(e["retSet"]) + + def test_a_forward_to_a_composition_takes_its_steps(self): + e = self._entries()[("spaceSplit", ("tpose", "float", "geometry", "boolean", + "boolean"))] + self.assertEqual(e["operands"], [ + {"arg": 0, "casts": ["tpose_to_tpoint"], "param": "temp"}, + {"arg": 1, "param": "xsize"}, + {"value": "0", "param": "ysize"}, {"value": "0", "param": "zsize"}, + {"arg": 2, "param": "sorigin"}, {"arg": 3, "param": "bitmatrix"}, + {"arg": 4, "param": "border_inc"}]) + self.assertEqual(e["restore"]["of"], 0) + + def test_an_instant_restored_to_the_input_type(self): + e = self._entries()[("nearestApproachInstant", ("tpcpoint", "geometry"))] + self.assertEqual(e["call"], "nai_tgeo_geo") + self.assertEqual(e["restore"], {"of": 0, "time": "temporal_start_timestamptz", + "at": "temporal_at_timestamptz"}) + + def test_the_literals_the_callee_binds_are_value_operands(self): + e = self._entries()[("timeBoxes", ("tpose", "interval", "timestamptz", + "boolean", "boolean"))] + self.assertEqual([(o.get("arg"), o.get("value"), o["param"]) + for o in e["operands"]], [ + (0, None, "temp"), (None, "0.0", "xsize"), (None, "0.0", "ysize"), + (None, "0.0", "zsize"), (1, None, "duration"), (None, "NULL", "sorigin"), + (2, None, "torigin"), (3, None, "bitmatrix"), (4, None, "border_inc")]) + + def test_the_public_function_is_the_step(self): + # getTime(tgeompoint) is carried by temporal_time and its internal twin + e = self._entries()[("spaceSplit", ("tpose", "float", "float", "float", + "geometry", "boolean", "boolean"))] + self.assertEqual(e["restore"]["time"], "temporal_time") + + def test_bodies_that_convert_nothing_are_no_composition(self): + entries = self._entries() + for key in (("stops", ("tgeompoint", "interval")), # a literal forward + ("eDisjoint", ("geometry", "tgeompoint")), # a swap + ("asEWKT", ("geometry", "integer")), # a PostGIS forward + ("douglasPeuckerSimplify", ("tpose", "float"))): # a program + self.assertNotIn(key, entries) + self.assertEqual(len(entries), 10) + + def test_a_step_no_public_function_takes_stops_the_catalog(self): + with self.assertRaises(ValueError) as cm: + self._attach(CASTS + """ +CREATE FUNCTION aContains(tpcpatch, tpcpatch) + RETURNS boolean + AS 'SELECT @extschema@.aContains($1::@extschema@.tgeometry, $2::@extschema@.tgeometry)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +""") + self.assertIn("aContains(tpcpatch, tpcpatch)", str(cm.exception)) + self.assertIn("0 public MEOS functions", str(cm.exception)) + + def test_a_cast_no_create_cast_declares_stops_the_catalog(self): + with self.assertRaises(ValueError) as cm: + self._attach(CASTS + """ +CREATE FUNCTION eDisjoint(tnpoint, geometry) + RETURNS boolean + AS 'SELECT @extschema@.eDisjoint($1::@extschema@.tgeompoint, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +""") + self.assertIn("no CREATE CAST from tnpoint to tgeompoint", str(cm.exception)) + + +if __name__ == "__main__": + unittest.main()