diff --git a/README.md b/README.md index a3c9afd..0bcad9f 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,18 @@ value's doc comment names and the operator's `CREATE OPERATOR` declaration. `columnRight` is null for an operator declaring no commutator, where an engine scans. See [`docs/index-search.md`](docs/index-search.md). +## Alternative SQL names + +Every SQL signature whose wrapper states `@altsqlfn` carries the name a Spark or +Flink binding publishes where the engine owns the PostgreSQL one: +`altSqlName: floatRound` on `round(tfloat, integer)`. A wrapper with several +`@sqlfn` names pairs each with the alternative name in its position; one listing +several alternative names under one `@sqlfn` name gives each signature the name +whose prefix names the base type of its first argument, a temporal type's base +being the value its `startValue` returns, so `trgeometry`, a reference geometry +and a temporal pose, takes `geoRound`. A signature selecting no name or several +stops the catalog. See [`parser/altsqlfn.py`](parser/altsqlfn.py). + ## OpenAPI generation The enriched catalog (the `network` / `wire` / `typeEncodings` produced by the diff --git a/parser/altsqlfn.py b/parser/altsqlfn.py new file mode 100644 index 0000000..28c49eb --- /dev/null +++ b/parser/altsqlfn.py @@ -0,0 +1,156 @@ +"""State, on each SQL signature, the name a binding publishes where its engine cannot take the +PostgreSQL one. + +A PostgreSQL wrapper states that name beside its SQL name: ``@sqlfn round()`` and +``@altsqlfn floatRound(), geoRound(), ...`` on ``Set_round``. Spark and Flink define or reserve +``round``, ``lower``, ``hash``, ``unnest`` and their kin, so a binding of either engine +registers the function under the alternative name alone, and the catalog states it per SQL +signature: + + altSqlName: floatRound + +A tag lists one name or several, in the grammar of ``@sqlfn tintSeq(), tfloatSeq()``: every +``name()`` from the tag to the next tag or the comment's close, as #_tag_names reads it. The +name a signature takes follows from three cases: + + * the wrapper states several ``@sqlfn`` names: each pairs with the alternative name in its + position, as ``@sqlop`` pairs with ``@sqlfn``, so ``Tgeo_rotate_z`` behind ``rotateZ()`` + and ``rotate()`` gives ``geoRotateZ`` and ``geoRotate``; + * the wrapper states one alternative name: every signature of the wrapper takes it; + * the wrapper states several alternative names under one ``@sqlfn`` name, one per kind of + value it serves (``Tnumber_abs``: ``intAbs(), bigintAbs(), floatAbs()``): a signature takes + the name whose prefix names the base type of its first argument. The prefix names a base + type as the catalog spells it (``cbuffer``, ``pose``), through its set type (``int`` for + ``intset``, ``float`` for ``floatset``) or through the class predicate + ``_basetype`` of meos_catalog.c (``geo`` for geometry and geography). The base + type of a temporal argument is the value it answers at an instant, the return type of + its ``startValue``: a ``trgeometry``, the concatenation of a reference geometry and a + temporal pose, answers a geometry. A set, span or span set reaches its base through the + type registry, and an array is read through its element type. + +Every signature resolves to exactly one name or the catalog stops: an alternative name that +no signature's base type selects, or that several select, would hand a binding a wrong or +ambiguous name. +""" +from __future__ import annotations + +import re +from pathlib import Path + +from parser.sqlfn import _CSQLFN_END, _meos_to_mdb, _wrapper_sql_sigs +from parser.typescope import SQL_ALIASES, TypeFacts + +# A doxygen block closing right before the wrapper's `Datum Name(PG_FUNCTION_ARGS)`. +_WRAPPER_BLOCK = re.compile(r"/\*\*((?:(?!\*/).)*)\*/\s*Datum\s+(\w+)\s*\(\s*PG_FUNCTION_ARGS", + re.S) +_NAME = re.compile(r"(\w+)\s*\(\)") +# The MEOS spelling of a type PostgreSQL spells otherwise (`integer` is `int4`). +_MEOS_SPELLING = {sql: meos for meos, sql in SQL_ALIASES.items()} + + +def _tag_names(block: str, tag: str) -> list[str]: + """Every ``name()`` a tag of the block lists, from the tag to the next tag or the block's + end, as #_meos_to_mdb reads the value of ``@csqlfn``.""" + names = [] + for m in re.finditer(rf"@{tag}\b", block): + tail = block[m.end():] + end = _CSQLFN_END.search(tail) + names += _NAME.findall(tail[:end.start()] if end else tail) + return names + + +def wrapper_names(mdb_src: str | Path) -> dict[str, tuple[list[str], list[str]]]: + """``{wrapper: (@sqlfn names, @altsqlfn names)}`` for every wrapper stating ``@altsqlfn``.""" + out = {} + for cf in sorted(Path(mdb_src).rglob("*.c")): + for m in _WRAPPER_BLOCK.finditer(cf.read_text(errors="ignore")): + alts = _tag_names(m.group(1), "altsqlfn") + if alts: + out[m.group(2)] = (_tag_names(m.group(1), "sqlfn"), alts) + return out + + +def _meos_name(sqltype: str) -> str: + """The MEOS spelling of a SQL type, which the catalog's type names and registry use, without + the type modifier (``geometry(Point)`` is ``geometry``), as #_type of + parser/compositions.py drops it before taking the SQL spelling the other way.""" + t = re.sub(r"\s*\(.*\)$", "", sqltype.strip().lower()) + return _MEOS_SPELLING.get(t, t) + + +def value_bases(idl: dict) -> dict[str, str]: + """``{SQL type: base type}``: a temporal type's ``startValue`` return type, a set, span or + span set type's base in the type registry, each in its MEOS spelling.""" + out = {} + for base, rel in ((idl.get("typeRelations") or {}).get("byBase") or {}).items(): + for role in ("set", "span", "spanset"): + if rel.get(role): + out[rel[role]] = base + for f in idl.get("functions", []): + for s in f.get("sqlSignatures") or (): + if (s.get("sqlName") or f.get("sqlfn")) == "startValue" and len(s["args"]) == 1 \ + and s.get("ret"): + out.setdefault(s["args"][0], _meos_name(s["ret"])) + return out + + +def prefix_bases(prefix: str, idl: dict, facts: TypeFacts) -> set[str]: + """The base types an alternative name's prefix names: the type of that name, the base whose + set type is ``set``, and the members of the class predicate ``_basetype``.""" + bases = {prefix} if prefix in facts.names else set() + for base, rel in ((idl.get("typeRelations") or {}).get("byBase") or {}).items(): + if rel.get("set") == prefix + "set": + bases.add(base) + return bases | facts.klass.get(prefix + "_basetype", set()) + + +def _prefix(alt: str, sqlname: str) -> str | None: + """The prefix an alternative name puts before the PostgreSQL name it stands for.""" + if alt.lower().endswith(sqlname.lower()) and len(alt) > len(sqlname): + return alt[:-len(sqlname)] + return None + + +def attach_alt_sql_names(idl: dict, meos_src: str | Path, mdb_src: str | Path, + sql_src: str | Path, facts: TypeFacts | None = None + ) -> tuple[dict, int, list[str]]: + """(idl, signatures stated, errors): set ``altSqlName`` on every SQL signature whose + wrapper states ``@altsqlfn``. ``facts`` defaults to the type facts of the MEOS tree above + ``meos_src``, as #attach_row_sources defaults its declared columns.""" + names = wrapper_names(mdb_src) + if not names: + return idl, 0, [] + m2d = _meos_to_mdb(meos_src) + w2sig = _wrapper_sql_sigs(sql_src) + facts = facts or TypeFacts(Path(meos_src).parent) + bases = value_bases(idl) + n, errors = 0, [] + for f in idl.get("functions", []): + for s in f.get("sqlSignatures") or (): + sqlname = s.get("sqlName") or f.get("sqlfn") + wrapper = next((w for w in m2d.get(f["name"]) or () if w in names and any( + ws["sqlName"] == sqlname and ws["args"] == s["args"] for ws in w2sig.get(w, ()))), + None) + if wrapper is None: + continue + sqlfns, alts = names[wrapper] + where = f"{wrapper} {sqlname}({', '.join(s['args'])})" + if len(sqlfns) > 1: + if len(alts) != len(sqlfns) or sqlname not in sqlfns: + errors.append(f"{where}: @sqlfn {sqlfns} does not pair with @altsqlfn {alts}") + continue + alt = alts[sqlfns.index(sqlname)] + elif len(alts) == 1: + alt = alts[0] + else: + arg = s["args"][0].removesuffix("[]") if s["args"] else "" + base = bases.get(arg, _meos_name(arg)) + hits = [a for a in alts + if (p := _prefix(a, sqlname)) and base in prefix_bases(p, idl, facts)] + if len(hits) != 1: + errors.append(f"{where}: base {base} selects {hits or 'none'} of {alts}") + continue + alt = hits[0] + s["altSqlName"] = alt + n += 1 + return idl, n, errors diff --git a/run.py b/run.py index 0424a84..f70286f 100644 --- a/run.py +++ b/run.py @@ -18,6 +18,7 @@ from parser.boundargs import (attach_call_literals, merge_boundargs, resolve_bound_names, strip_call_literals) from parser.aggregates import attach_aggregates +from parser.altsqlfn import attach_alt_sql_names from parser.compositions import attach_compositions from parser.indexsearch import attach_index_search from parser.enrich import enrich_idl, restate_wire @@ -376,6 +377,17 @@ def main(): print(f" shared SQL signatures given up by a function they do not fit: {nclaims}", file=sys.stderr) + # State on each SQL signature the name a Spark or Flink binding publishes where the + # engine owns the PostgreSQL one (@altsqlfn), resolved through the type relations and + # the startValue signatures above; a signature selecting no name or several stops the + # catalog. + if MEOS_SRC.exists() and MDB_SRC.exists() and SQL_SRC.exists(): + idl, nalt, alt_errors = attach_alt_sql_names(idl, MEOS_SRC, MDB_SRC, SQL_SRC) + if alt_errors: + raise ValueError("SQL signatures whose @altsqlfn does not resolve to one name:\n " + + "\n ".join(alt_errors)) + print(f" SQL signatures stating their alternative name: {nalt}", file=sys.stderr) + # Name the C value feeding each column of every row a SQL signature returns, matched # by type once the object model and the type relations state the C type of each SQL # type; a row with a column nothing feeds stops the catalog. diff --git a/tests/test_altsqlfn.py b/tests/test_altsqlfn.py new file mode 100644 index 0000000..fa93f32 --- /dev/null +++ b/tests/test_altsqlfn.py @@ -0,0 +1,251 @@ +"""The name each SQL signature takes where Spark or Flink cannot take the PostgreSQL one. + +The wrappers, their SQL declarations and the MEOS functions claiming them are written to a +temporary root, as #IndexSearchTests of tests/test_indexsearch.py writes its declarations; the +type facts are built in the fields #TypeFacts reads, as #cell_facts of tests/test_typescope.py +builds them; the contract tests read the generated catalog. Plain unittest, no pytest +dependency. +""" +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from parser.altsqlfn import _tag_names, attach_alt_sql_names, wrapper_names +from parser.typescope import TypeFacts + +IDL = Path(__file__).resolve().parents[1] / "output" / "meos-idl.json" + +WRAPPERS = """ +/** + * @brief Return a temporal value rounded + * @sqlfn round() + * @altsqlfn floatRound(), geoRound(), poseRound() + */ +Datum +Temporal_round(PG_FUNCTION_ARGS) +{ +} + +/** + * @brief Return a temporal geo rotated around the z axis + * @sqlfn rotateZ(), rotate() + * @altsqlfn geoRotateZ(), geoRotate() + */ +Datum +Tgeo_rotate_z(PG_FUNCTION_ARGS) +{ +} + +/** + * @brief Return the absolute value of a temporal number + * @sqlfn abs() + * @altsqlfn intAbs(), floatAbs() + */ +Datum +Tnumber_abs(PG_FUNCTION_ARGS) +{ +} + +/** + * @brief Return the hash of a set + * @sqlfn hash() + * @altsqlfn setHash() + */ +Datum +Set_hash(PG_FUNCTION_ARGS) +{ +} + +/** + * @brief Return the start value + * @sqlfn startValue() + */ +Datum +Temporal_start_value(PG_FUNCTION_ARGS) +{ +} +""" + +SQL = """ +CREATE FUNCTION round(tfloat, integer) RETURNS tfloat AS 'MODULE_PATHNAME', 'Temporal_round' LANGUAGE C; +CREATE FUNCTION round(tgeompoint, integer) RETURNS tgeompoint AS 'MODULE_PATHNAME', 'Temporal_round' LANGUAGE C; +CREATE FUNCTION round(trgeometry, integer) RETURNS trgeometry AS 'MODULE_PATHNAME', 'Temporal_round' LANGUAGE C; +CREATE FUNCTION round(tpose[], integer) RETURNS tpose[] AS 'MODULE_PATHNAME', 'Temporal_round' LANGUAGE C; +CREATE FUNCTION round(tbool, integer) RETURNS tbool AS 'MODULE_PATHNAME', 'Temporal_round' LANGUAGE C; +CREATE FUNCTION rotateZ(tgeompoint, float) RETURNS tgeompoint AS 'MODULE_PATHNAME', 'Tgeo_rotate_z' LANGUAGE C; +CREATE FUNCTION rotate(tgeompoint, float) RETURNS tgeompoint AS 'MODULE_PATHNAME', 'Tgeo_rotate_z' LANGUAGE C; +CREATE FUNCTION abs(tint) RETURNS tint AS 'MODULE_PATHNAME', 'Tnumber_abs' LANGUAGE C; +CREATE FUNCTION abs(tfloat) RETURNS tfloat AS 'MODULE_PATHNAME', 'Tnumber_abs' LANGUAGE C; +CREATE FUNCTION hash(intset) RETURNS integer AS 'MODULE_PATHNAME', 'Set_hash' LANGUAGE C; +CREATE FUNCTION hash(floatset) RETURNS integer AS 'MODULE_PATHNAME', 'Set_hash' LANGUAGE C; +""" + +MEOS = """ +/** + * @csqlfn #Temporal_round() + */ +Temporal * +temporal_round(const Temporal *temp, int maxdd) +{ +} + +/** + * @csqlfn #Tgeo_rotate_z() + */ +Temporal * +tgeo_rotate_z(const Temporal *temp, double angle) +{ +} + +/** + * @csqlfn #Tnumber_abs() + */ +Temporal * +tnumber_abs(const Temporal *temp) +{ +} + +/** + * @csqlfn #Set_hash() + */ +uint32 +set_hash(const Set *s) +{ +} +""" + + +def facts(): + """The type facts the prefixes resolve against, in the fields #TypeFacts reads from + meos_catalog.c: the base types, their sets and the class predicate of the geo types.""" + f = TypeFacts.__new__(TypeFacts) + f.name = {} + f.names = {'int4', 'float8', 'geometry', 'geography', 'pose', 'intset', 'floatset'} + f.klass = {'geo_basetype': {'geometry', 'geography'}} + f.validate = {} + f.container = {} + return f + + +def sig(args, ret, name=None): + s = {"args": list(args), "ret": ret} + if name: + s["sqlName"] = name + return s + + +def catalog(): + """The functions as #attach_sqlfn_map leaves them, with the startValue signatures stating the + value of each temporal type at an instant and the type registry the base of each set.""" + return { + "typeRelations": {"byBase": { + "int4": {"set": "intset", "temporal": ["tint"]}, + "float8": {"set": "floatset", "temporal": ["tfloat"]}, + "geometry": {"temporal": ["tgeompoint"]}, + "pose": {"temporal": ["tpose", "trgeometry"]}}}, + "functions": [ + {"name": "temporal_round", "sqlfn": "round", "sqlSignatures": [ + sig(["tfloat", "integer"], "tfloat"), sig(["tgeompoint", "integer"], "tgeompoint"), + sig(["trgeometry", "integer"], "trgeometry"), + sig(["tpose[]", "integer"], "tpose[]"), sig(["tbool", "integer"], "tbool")]}, + {"name": "tgeo_rotate_z", "sqlfn": "rotateZ", "sqlSignatures": [ + sig(["tgeompoint", "float"], "tgeompoint"), + sig(["tgeompoint", "float"], "tgeompoint", "rotate")]}, + {"name": "tnumber_abs", "sqlfn": "abs", "sqlSignatures": [ + sig(["tint"], "tint"), sig(["tfloat"], "tfloat")]}, + {"name": "set_hash", "sqlfn": "hash", "sqlSignatures": [ + sig(["intset"], "integer"), sig(["floatset"], "integer")]}, + {"name": "temporal_start_value", "sqlfn": "startValue", "sqlSignatures": [ + sig(["tint"], "integer"), sig(["tfloat"], "float"), + sig(["tgeompoint"], "geometry(Point)"), sig(["tpose"], "pose"), + sig(["trgeometry"], "geometry"), sig(["tbool"], "boolean")]}]} + + +class AltSqlNameTests(unittest.TestCase): + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + for sub, name, text in (("mdb", "w.c", WRAPPERS), ("sql", "s.in.sql", SQL), + ("meos/src", "m.c", MEOS)): + (root / sub).mkdir(parents=True) + (root / sub / name).write_text(text) + self.idl, self.n, self.errors = attach_alt_sql_names( + catalog(), root / "meos" / "src", root / "mdb", root / "sql", facts()) + self.fns = {f["name"]: f["sqlSignatures"] for f in self.idl["functions"]} + + def tearDown(self): + self.tmp.cleanup() + + def alt(self, fn, i): + return self.fns[fn][i].get("altSqlName") + + def test_a_tag_lists_every_name_to_the_next_tag(self): + block = " * @sqlfn rotateZ(), rotate()\n * @altsqlfn geoRotateZ(),\n * geoRotate()\n" + self.assertEqual(_tag_names(block, "sqlfn"), ["rotateZ", "rotate"]) + self.assertEqual(_tag_names(block, "altsqlfn"), ["geoRotateZ", "geoRotate"]) + self.assertEqual(wrapper_names(Path(self.tmp.name) / "mdb")["Tgeo_rotate_z"], + (["rotateZ", "rotate"], ["geoRotateZ", "geoRotate"])) + self.assertNotIn("Temporal_start_value", wrapper_names(Path(self.tmp.name) / "mdb")) + + def test_several_sqlfn_names_pair_by_position(self): + self.assertEqual(self.alt("tgeo_rotate_z", 0), "geoRotateZ") + self.assertEqual(self.alt("tgeo_rotate_z", 1), "geoRotate") + + def test_one_alternative_name_reaches_every_signature(self): + self.assertEqual(self.alt("set_hash", 0), "setHash") + self.assertEqual(self.alt("set_hash", 1), "setHash") + + def test_the_base_type_of_the_first_argument_selects_among_several(self): + self.assertEqual(self.alt("tnumber_abs", 0), "intAbs") + self.assertEqual(self.alt("tnumber_abs", 1), "floatAbs") + self.assertEqual(self.alt("temporal_round", 0), "floatRound") + # the startValue return type, its modifier dropped, names the geometry + self.assertEqual(self.alt("temporal_round", 1), "geoRound") + # an array is read through its element type + self.assertEqual(self.alt("temporal_round", 3), "poseRound") + + def test_a_trgeometry_takes_the_geometry_it_answers_at_an_instant(self): + # a reference geometry and a temporal pose: its instants store a pose, its value is a geometry + self.assertEqual(self.alt("temporal_round", 2), "geoRound") + + def test_a_signature_no_name_selects_stops_the_catalog(self): + self.assertIsNone(self.alt("temporal_round", 4)) + self.assertEqual(len(self.errors), 1) + self.assertIn("round(tbool, integer)", self.errors[0]) + self.assertEqual(self.n, 10) + + def test_a_wrapper_with_no_alternative_name_states_none(self): + self.assertFalse(any("altSqlName" in s for s in self.fns["temporal_start_value"])) + + +class AltSqlNameContractTests(unittest.TestCase): + """Over the generated catalog.""" + + def setUp(self): + if not IDL.exists(): + self.skipTest(f"{IDL} not generated; run `python run.py` first") + idl = json.loads(IDL.read_text()) + self.alts = {} + for f in idl["functions"]: + for s in f.get("sqlSignatures") or (): + if "altSqlName" in s: + key = ((s.get("sqlName") or f["sqlfn"]), tuple(s["args"])) + self.alts.setdefault(key, set()).add(s["altSqlName"]) + + def test_every_signature_takes_one_name(self): + self.assertTrue(self.alts) + self.assertEqual([k for k, v in self.alts.items() if len(v) != 1], []) + + def test_the_rounding_takes_the_base_type_of_its_value(self): + self.assertEqual(self.alts[("round", ("tfloat", "integer"))], {"floatRound"}) + self.assertEqual(self.alts[("round", ("trgeometry", "integer"))], {"geoRound"}) + self.assertEqual(self.alts[("round", ("tcbuffer", "integer"))], {"cbufferRound"}) + + +if __name__ == "__main__": + unittest.main()