From bab6c4618c7aa62637a6ca19049b4a6362b5f0ac Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Sun, 4 Oct 2026 13:13:45 +0200 Subject: [PATCH] State on each SQL signature the name a Spark or Flink binding publishes A PostgreSQL wrapper states, beside its SQL name, the name a binding publishes where its engine owns the PostgreSQL one: @sqlfn round() and @altsqlfn floatRound(), geoRound(), ... on Set_round. The catalog carries that name on each SQL signature, altSqlName, read from the tags of the wrapper backing it, so a Spark or Flink generator registers the function under it rather than under a name its engine defines or reserves. A tag lists one name or several, every name() from the tag to the next tag, as @csqlfn is read. A wrapper with several @sqlfn names pairs each with the alternative name in its position, as @sqlop pairs with @sqlfn: Tgeo_rotate_z, behind rotateZ and rotate, gives geoRotateZ and geoRotate. A wrapper stating one alternative name gives it to every signature. A wrapper listing several under one @sqlfn name gives each signature the name whose prefix names the base type of its first argument: the type of that name (cbuffer, pose), the base whose set type it prefixes (int for intset, float for floatset) or the members of 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, so trgeometry, the concatenation of a reference geometry and a temporal pose, takes geoRound; a set, span or span set reaches its base through the type registry, and an array through its element type. A signature selecting no name or several stops the catalog. Measured over MobilityDB master f9c15d092a, the catalog derived from the installed headers of a libmeos built by tools/provision-meos.sh, as the provision-meos action derives it: 808 catalog signatures state an alternative name, 513 distinct SQL signatures by name and first argument type, each with one name. They are the 477 the @altsqlfn tags of MobilityDB #2948 reach, each with the name families 1 to 5 and 7 to 9 of the portable naming plan give it (floatRound over tfloat, geoRound over tgeompoint and trgeometry, intAbs over tint, spanLower over the spans, temporalHash over the temporal types, setUnnest over the sets), and the 36 set constructors taking setMake. The suite passes its 485 tests over that catalog, tests/test_altsqlfn.py and its contract tests included, and the 8 engine integration tests pass against that libmeos. Why: the name a binding publishes is a fact about the SQL function, stated once on its wrapper, so every generator reads it from the catalog rather than from a list kept in each binding. --- README.md | 12 ++ parser/altsqlfn.py | 156 +++++++++++++++++++++++++ run.py | 12 ++ tests/test_altsqlfn.py | 251 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100644 parser/altsqlfn.py create mode 100644 tests/test_altsqlfn.py 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()