diff --git a/parser/boundargs.py b/parser/boundargs.py new file mode 100644 index 0000000..7e0fc33 --- /dev/null +++ b/parser/boundargs.py @@ -0,0 +1,167 @@ +"""Extract per-parameter BOUND LITERALS from the MobilityDB PostgreSQL wrappers. + +A MEOS function's C signature can be WIDER than the SQL surface a binding +exposes: the MobilityDB PG wrapper reads only some arguments from the caller +(via ``PG_GETARG_*``) and BINDS the remaining scalar inputs to fixed literals. +``valueAtTimestamp(temp, t)`` is 2-arg in SQL, yet the wrapper +``Temporal_value_at_timestamptz`` calls +``temporal_value_at_timestamptz(temp, t, true, &result)`` — ``strict`` is bound +to ``true`` and ``result`` is an out-param. + +``shape.outParams`` (``parser/outparam.py``) already folds the trailing +out-param. This pass captures the OTHER hidden inputs — the bound literals — as +``shape.boundArgs`` = ``{param_name: literal}``, the exact sibling of +``shape.outParams``. A downstream binding generator, which exposes only the SQL +args, emits the bound literal for each such param +(``fn(temp, t, /*strict*/true, &out)``) instead of hand-writing it. + +The wrapper C body is the single source of truth. For each positional argument +of the ``(...)`` call inside the wrapper: an argument sourced from +``PG_GETARG_*`` (directly, or via a local so-assigned) is a CALLER arg and is +skipped; ``&name`` is an out-param (already in ``outParams``) and is skipped; +only a genuine LITERAL (``true``/``false``, a number, ``NULL`` or an UPPERCASE +enum/macro) is recorded. A wrapper that does not call the MEOS function by name +(it delegates to a shared helper) yields no ``boundArgs``. +""" +from __future__ import annotations + +import re +from pathlib import Path + +# `Datum (PG_FUNCTION_ARGS) {` opens a PG wrapper. +_WRAP = re.compile(r"Datum\s+(?P\w+)\s*\(\s*PG_FUNCTION_ARGS\s*\)\s*\{") +# Any local the wrapper ASSIGNS (`var = ...`, excluding `==`): every value the +# wrapper feeds the MEOS call is either read from the caller (`PG_GETARG_*`) or +# derived into such a local (e.g. `char *hexwkb = text2cstring(PG_GETARG_TEXT_P(0))`), +# so an argument that names an assigned local is caller-sourced, never a literal. +_ASSIGNED = re.compile(r"\b(?P\w+)\s*=(?!=)") +# Literals worth binding (checked in order): boolean, number, UPPERCASE enum/macro. +_TRUE = re.compile(r"^(?:true|TRUE)$") +_FALSE = re.compile(r"^(?:false|FALSE)$") +_NUMBER = re.compile(r"^-?\d+(?:\.\d+)?$") +_ENUM = re.compile(r"^[A-Z][A-Z0-9_]+$") +_IDENT = re.compile(r"^\w+$") + + +def _body(text: str, brace_pos: int) -> str: + """Return the brace-balanced body starting at ``brace_pos`` (the ``{`` index).""" + depth = 0 + for i in range(brace_pos, len(text)): + c = text[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return text[brace_pos + 1:i] + return text[brace_pos + 1:] + + +def _split_args(s: str) -> list[str]: + """Split a call's argument list on top-level commas (paren/bracket aware).""" + args: list[str] = [] + depth = 0 + cur: list[str] = [] + for c in s: + if c in "([": + depth += 1 + cur.append(c) + elif c in ")]": + depth -= 1 + cur.append(c) + elif c == "," and depth == 0: + args.append("".join(cur).strip()) + cur = [] + else: + cur.append(c) + tail = "".join(cur).strip() + if tail: + args.append(tail) + return args + + +def _call_args(body: str, fn: str) -> list[str] | None: + """Return the positional args of the first ``fn(...)`` call in ``body``, else None. + + Word-boundary anchored so ``temporal_before_timestamptz`` does not match the + RGEO arm ``trgeometry_before_timestamptz``; case-sensitive so it does not + match the (Uppercase) wrapper name.""" + m = re.search(r"(? dict[str, str]: + """Return ``{wrapper_name: body_text}`` for every PG wrapper under ``mdb_src``.""" + out: dict[str, str] = {} + for cf in Path(mdb_src).rglob("*.c"): + text = cf.read_text(errors="ignore") + for m in _WRAP.finditer(text): + out[m.group("name")] = _body(text, m.end() - 1) + return out + + +def _literal(arg: str) -> str | None: + """Normalise a call argument to the literal to record, or None if not a literal.""" + if _TRUE.match(arg): + return "true" + if _FALSE.match(arg): + return "false" + if arg == "NULL" or _NUMBER.match(arg) or _ENUM.match(arg): + return arg + return None + + +def merge_boundargs(idl: dict, mdb_src: str | Path) -> tuple[dict, int, list]: + """Fold wrapper-bound literals into each function's ``shape.boundArgs``. + + Returns ``(idl, count, drift)`` where ``drift`` lists + ``(function, param, reason)`` call arguments the pass could not classify as a + caller arg / out-param / literal (a bare identifier that is neither) — a + signal to inspect, never trusted as a bound value.""" + wrappers = extract_wrappers(mdb_src) + n = 0 + drift: list[tuple[str, str, str]] = [] + for func in idl["functions"]: + wname = func.get("mdbC") + if not wname: + continue + body = wrappers.get(wname) + if body is None: + continue + args = _call_args(body, func["name"]) + if not args: + continue + assigned = {m.group("var") for m in _ASSIGNED.finditer(body)} + params = func.get("params", []) + bound: dict[str, str] = {} + for i, a in enumerate(args): + if i >= len(params): + break + pname = params[i].get("name") + if not pname: + continue + if a.startswith("&") or "PG_GETARG" in a or a in assigned: + continue # out-param or caller-sourced local + lit = _literal(a) + if lit is not None: + bound[pname] = lit + elif _IDENT.match(a): + # a bare identifier that is not caller-sourced and not a literal — + # cannot be trusted as a bound value; report for a look. + drift.append((func["name"], pname, "unclassified-arg: " + a)) + if bound: + func.setdefault("shape", {})["boundArgs"] = bound + n += len(bound) + return idl, n, drift diff --git a/run.py b/run.py index d082a6b..b4f794c 100644 --- a/run.py +++ b/run.py @@ -12,6 +12,7 @@ from parser.shapeinfer import infer_shapes from parser.nullable import merge_nullable from parser.outparam import merge_outparams +from parser.boundargs import merge_boundargs from parser.enrich import enrich_idl from parser.sqlfn import (attach_sqlfn_map, attach_aggfn_map, lint_ea_sqlfn, lint_positional_sqlfn, lint_sqlfn_case_collisions) @@ -191,6 +192,20 @@ def main(): print(f" Flagged {nbo} bbox-topological backing @sqlfn tag(s) " f"(sqlfnBackingOnly)", file=sys.stderr) + # A PG wrapper can BIND a MEOS input to a fixed literal instead of exposing + # it as a SQL argument (valueAtTimestamp hides `strict=true`). Capture those + # bound literals from the wrapper body as `shape.boundArgs`, the input-side + # sibling of `shape.outParams`, so a binding emits the literal it can no + # longer read off the (narrower) SQL signature. Needs `mdbC` (step 4). + idl, nba, ba_drift = merge_boundargs(idl, MDB_SRC) + print(f" Bound-literal args from PG wrappers `shape.boundArgs`: {nba}", + file=sys.stderr) + if ba_drift: + print(f" ⚠ {len(ba_drift)} wrapper call arg(s) unclassified " + f"(neither caller arg, out-param, nor literal — inspect):", file=sys.stderr) + for fn, pn, reason in ba_drift: + print(f" {fn}({pn}) — {reason}", file=sys.stderr) + # 5. Attach the doxygen module group (@ingroup) from the vendored source, so # bindings organize their generated surface like the reference manual. if MEOS_SRC.exists(): diff --git a/tests/test_boundargs.py b/tests/test_boundargs.py new file mode 100644 index 0000000..2b9118d --- /dev/null +++ b/tests/test_boundargs.py @@ -0,0 +1,139 @@ +"""Regression tests for parser/boundargs.py. + +A MobilityDB PG wrapper can BIND a MEOS input to a fixed literal instead of +exposing it as a SQL argument. ``merge_boundargs`` reads the wrapper body (the +source of truth) and folds those literals into ``shape.boundArgs``, the +input-side sibling of ``shape.outParams``. + +Plain unittest, no pytest dependency; writes a tiny synthetic wrapper tree. +""" +import tempfile +import unittest +from pathlib import Path + +from parser.boundargs import extract_wrappers, merge_boundargs + +# A synthetic MobilityDB wrapper source (mobilitydb/src/**/*.c shape). +SAMPLE = ''' +/** + * @sqlfn valueAtTimestamp() + */ +Datum +Temporal_value_at_timestamptz(PG_FUNCTION_ARGS) +{ + Temporal *temp = PG_GETARG_TEMPORAL_P(0); + TimestampTz t = PG_GETARG_TIMESTAMPTZ(1); + Datum result; + bool found = temporal_value_at_timestamptz(temp, t, true, &result); + if (! found) + PG_RETURN_NULL(); + PG_RETURN_DATUM(result); +} + +/** + * @sqlfn beforeTimestamp() + */ +Datum +Temporal_before_timestamptz(PG_FUNCTION_ARGS) +{ + Temporal *temp = PG_GETARG_TEMPORAL_P(0); + TimestampTz t = PG_GETARG_TIMESTAMPTZ(1); + bool strict = PG_GETARG_BOOL(2); + Temporal *result = temporal_before_timestamptz(temp, t, strict); + PG_RETURN_TEMPORAL_P(result); +} + +/** + * @sqlfn spanFromHexWKB() + */ +Datum +Span_from_hexwkb(PG_FUNCTION_ARGS) +{ + text *hexwkb_txt = PG_GETARG_TEXT_P(0); + char *hexwkb = text2cstring(hexwkb_txt); + Span *result = span_from_hexwkb(hexwkb); + PG_RETURN_SPAN_P(result); +} + +/** + * @sqlfn appendInstant() + */ +Datum +Temporal_append_tinstant(PG_FUNCTION_ARGS) +{ + Temporal *temp = PG_GETARG_TEMPORAL_P(0); + TInstant *inst = PG_GETARG_TINSTANT_P(1); + interpType interp = MEOS_FLAGS_GET_INTERP(temp->flags); + Temporal *result = temporal_append_tinstant(temp, inst, interp, 0.0, NULL, false); + PG_RETURN_TEMPORAL_P(result); +} +''' + + +def _idl(): + return {"functions": [ + {"name": "temporal_value_at_timestamptz", "mdbC": "Temporal_value_at_timestamptz", + "params": [{"name": "temp"}, {"name": "t"}, {"name": "strict"}, {"name": "result"}]}, + {"name": "temporal_before_timestamptz", "mdbC": "Temporal_before_timestamptz", + "params": [{"name": "temp"}, {"name": "t"}, {"name": "strict"}]}, + {"name": "span_from_hexwkb", "mdbC": "Span_from_hexwkb", + "params": [{"name": "hexwkb"}]}, + {"name": "temporal_append_tinstant", "mdbC": "Temporal_append_tinstant", + "params": [{"name": "temp"}, {"name": "inst"}, {"name": "interp"}, + {"name": "maxdist"}, {"name": "maxt"}, {"name": "expand"}]}, + # a function with no wrapper mapping -> untouched + {"name": "orphan_fn", "params": [{"name": "x"}]}, + ]} + + +class BoundArgsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + src = Path(self.tmp.name) / "src" + src.mkdir() + (src / "sample.c").write_text(SAMPLE) + + def tearDown(self): + self.tmp.cleanup() + + def test_extract_wrappers_finds_bodies(self): + w = extract_wrappers(self.tmp.name) + self.assertIn("Temporal_value_at_timestamptz", w) + self.assertIn("temporal_value_at_timestamptz(temp, t, true, &result)", + w["Temporal_value_at_timestamptz"]) + + def test_hidden_literal_is_captured(self): + idl, n, drift = merge_boundargs(_idl(), self.tmp.name) + va = idl["functions"][0] + self.assertEqual(va["shape"]["boundArgs"], {"strict": "true"}) + + def test_caller_arg_is_not_captured(self): + # beforeTimestamp exposes `strict` (PG_GETARG_BOOL(2)) -> NOT a bound literal + idl, n, drift = merge_boundargs(_idl(), self.tmp.name) + before = idl["functions"][1] + self.assertNotIn("shape", before) + + def test_transform_local_is_not_drift(self): + # `hexwkb` is caller-derived through text2cstring -> neither boundArg nor drift + idl, n, drift = merge_boundargs(_idl(), self.tmp.name) + hexf = idl["functions"][2] + self.assertNotIn("shape", hexf) + self.assertFalse([d for d in drift if d[0] == "span_from_hexwkb"]) + + def test_multiple_literals_including_null_and_number(self): + idl, n, drift = merge_boundargs(_idl(), self.tmp.name) + app = idl["functions"][3] + self.assertEqual(app["shape"]["boundArgs"], + {"maxdist": "0.0", "maxt": "NULL", "expand": "false"}) + # `interp` (a derived enum local) is not recorded as a literal + self.assertNotIn("interp", app["shape"]["boundArgs"]) + + def test_orphan_and_count(self): + idl, n, drift = merge_boundargs(_idl(), self.tmp.name) + # orphan (no mdbC) untouched; total = strict + 3 append literals = 4 + self.assertNotIn("shape", idl["functions"][4]) + self.assertEqual(n, 4) + + +if __name__ == "__main__": + unittest.main()