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()