Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions parser/boundargs.py
Original file line number Diff line number Diff line change
@@ -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 ``<meos_fn>(...)`` 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 <Name>(PG_FUNCTION_ARGS) {` opens a PG wrapper.
_WRAP = re.compile(r"Datum\s+(?P<name>\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<var>\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"(?<!\w)" + re.escape(fn) + r"\s*\(", body)
if not m:
return None
start = m.end() - 1 # the '('
depth = 0
for i in range(start, len(body)):
c = body[i]
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return _split_args(body[start + 1:i])
return None


def extract_wrappers(mdb_src: str | Path) -> 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
15 changes: 15 additions & 0 deletions run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand Down
139 changes: 139 additions & 0 deletions tests/test_boundargs.py
Original file line number Diff line number Diff line change
@@ -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()
Loading