From 6542ce591eac4950c8e27a57363770b24af03ef2 Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Fri, 9 Oct 2026 18:56:28 +0200 Subject: [PATCH] State a contiguous array of structs a function reads as an input array shape.inputArrays also names a single-pointer parameter MEOS documents as an array followed by a by-value integer it documents as its number of elements: spanset_make(Span *spans, int count) reads count spans, as npointset_make(const Npoint *values, int count) and cbufferset_make(const Cbuffer *values, int count) read count values, where stbox_round(const STBox *box, int maxdd) reads one box. The C type cannot tell them apart and the @param text can: attach_struct_input_arrays of parser/shapeinfer.py reads it through extract_param_docs of parser/nullable.py, the Doxygen #extract_nullable reads, and records the array with its length and its element as #_input_arrays records an array of pointers. An integer documented otherwise (Index in meos_array_get) is no length. _signature_fits of parser/sqlfn.py passes no SQL argument to the length of an input array, as to a bound parameter, so a SQL array fits the array by its element and attach_claims_fit leaves a signature on the claimant whose array it fits: Set_constructor's set(float[]) stays on floatset_make and leaves cbufferset_make. Witness: on MEOS-API master e84319b over MobilityDB master 2a1d52a7ee the catalog states no inputArrays for spanset_make, spanset_make_exp, spanset_make_free, npointset_make, cbufferset_make, stboxarr_round and geomval_arr_free, so a binding takes the count for an argument of its own and the typed Spark and Flink surfaces of JMEOS refuse spanset([]), set(npoint[]) and set(cbuffer[]) as arity:sql; and cbufferset_make carries the signature set(float[]) RETURNS floatset. Measured over MobilityDB master 2a1d52a7ee: run.py states inputArrays for those 7 functions, of 143 single-pointer struct parameters followed by an integer, and removes set(float[]) from cbufferset_make; every other key of every function equals that of master's parser. The suite passes 520 tests with none skipped, among them the three of tests/test_shapeinfer.py StructInputArrayTests and tests/test_claims_fit.py test_an_array_length_takes_no_argument, which fails with master's parser/sqlfn.py. Why: the catalog states what a function reads in a form every binding marshals, and the documentation of a parameter is where MEOS states that a pointer is the first of several values. --- parser/nullable.py | 19 ++++++++++++++++ parser/shapeinfer.py | 48 ++++++++++++++++++++++++++++++++++++++++ parser/sqlfn.py | 7 ++++-- run.py | 7 ++++-- tests/test_claims_fit.py | 21 ++++++++++++++++++ tests/test_shapeinfer.py | 34 +++++++++++++++++++++++++++- 6 files changed, 131 insertions(+), 5 deletions(-) diff --git a/parser/nullable.py b/parser/nullable.py index 41c411c..8407496 100644 --- a/parser/nullable.py +++ b/parser/nullable.py @@ -52,6 +52,25 @@ def extract_nullable(meos_root: str | Path) -> dict[str, list[str]]: return out +def extract_param_docs(meos_root: str | Path) -> dict[str, dict[str, str]]: + """Return ``{function: {parameter: description}}`` from the MEOS C Doxygen, the + ``@param`` text #extract_nullable reads, whitespace collapsed, over the same files.""" + root = Path(meos_root) + out: dict[str, dict[str, str]] = {} + files = glob.glob(str(root / "src/**/*.c"), recursive=True) + files += glob.glob(str(root / "include/**/*.h"), recursive=True) + for f in files: + txt = Path(f).read_text(errors="ignore") + for m in _FUNC.finditer(txt): + docs = out.setdefault(m.group("name"), {}) + for pm in _PARAM.finditer(m.group("doc")): + desc = " ".join(pm.group("desc").split()) + for p in (n.strip() for n in pm.group("names").split(",")): + if p: + docs.setdefault(p, desc) + return out + + def merge_nullable(idl: dict, meos_root: str | Path) -> tuple[dict, int]: """Fold the extracted nullability into each function's ``shape.nullable``.""" nul = extract_nullable(meos_root) diff --git a/parser/shapeinfer.py b/parser/shapeinfer.py index d017a01..ece31f2 100644 --- a/parser/shapeinfer.py +++ b/parser/shapeinfer.py @@ -29,6 +29,7 @@ """ from __future__ import annotations +import re # Parameters that accept NULL by MEOS convention regardless of the function. # ``srs`` is the optional spatial-reference string of every ``*_as_*json`` / @@ -164,6 +165,53 @@ def is_array(prm) -> bool: return out +_ARRAY_DOC = re.compile(r"(an\s+)?array\b", re.I) +_COUNT_DOC = re.compile(r"number of elements\b", re.I) + + +def attach_struct_input_arrays(idl: dict, docs: dict) -> tuple[dict, int]: + """(idl, number of arrays added) once ``shape.inputArrays`` also names each contiguous + array of structs a function reads. + + A pointer to one struct and a pointer to the first of several are the same C type: + ``spanset_make(Span *spans, int count)`` reads ``count`` spans, ``stbox_round(const + STBox *box, int maxdd)`` one box. The MEOS documentation tells them apart, which is + why this runs once the ``@param`` text is read (#extract_param_docs of + parser/nullable.py): a single-pointer parameter documented as an array (``Array of + spans``) followed by a by-value integer documented as its number of elements + (``Number of elements in the array``) is an input array with that length, as + #_input_arrays states an array of pointers or of scalars. An integer documented + otherwise (``Index`` in ``meos_array_get``) is not a length.""" + n = 0 + for func in idl["functions"]: + params = func.get("params", []) + fdocs = docs.get(func["name"]) or {} + shape = func.get("shape") or {} + known = {a["param"] for a in shape.get("inputArrays") or ()} + found = [] + for prm, nxt in zip(params, params[1:]): + ctype = _bare(prm.get("cType")) + if (prm["name"] in known or not ctype.endswith("*") or ctype.endswith("**") + or _bare(nxt.get("cType")) not in _LENGTH_TYPES): + continue + if not (_ARRAY_DOC.match(fdocs.get(prm["name"], "")) + and _COUNT_DOC.match(fdocs.get(nxt["name"], ""))): + continue + found.append({ + "param": prm["name"], + "lengthFrom": {"kind": "param", "name": nxt["name"]}, + "element": { + "c": _strip_one_ptr(ctype), + "canonical": _strip_one_ptr( + _bare(prm.get("canonical") or prm.get("cType"))), + }, + }) + if found: + func.setdefault("shape", {}).setdefault("inputArrays", []).extend(found) + n += len(found) + return idl, n + + def _is_index_pair_return(func: dict, count: str) -> bool: """Whether the ``int *`` return is a FLATTENED array of index PAIRS. diff --git a/parser/sqlfn.py b/parser/sqlfn.py index 686081a..d24c780 100644 --- a/parser/sqlfn.py +++ b/parser/sqlfn.py @@ -667,11 +667,14 @@ def _signature_fits(func, sig, sqlc): """Whether the arguments of `sig` fit the C input parameters of `func`, in their order or in the one other order a wrapper may pass them in, as #_Resolver.params of parser/compositions.py matches the operands of a call: True or False, or None when the - two counts differ (an array passed with its count, an argument left to its default). - The parameters `sig` binds and the out-parameters take no argument.""" + two counts differ (an argument left to its default). The parameters `sig` binds, the + out-parameters and the length of each input array take no argument: a SQL array is + the C array together with its count (`shape.inputArrays`).""" shape = func.get("shape") or {} bound = sig.get("boundArgs") or shape.get("boundArgs") or {} out = set(shape.get("outParams") or ()) + out |= {(a.get("lengthFrom") or {}).get("name") for a in shape.get("inputArrays") or () + if (a.get("lengthFrom") or {}).get("kind") == "param"} inputs = [p for p in func.get("params") or () if p["name"] not in out and p["name"] not in bound] if len(inputs) != len(sig["args"]): diff --git a/run.py b/run.py index 5b966f1..1cd0333 100644 --- a/run.py +++ b/run.py @@ -11,8 +11,8 @@ from parser.typerecover import (recover_collapsed_types, normalize_canonical, postgres_scalar_names) from parser.header_types import reconcile -from parser.shapeinfer import infer_shapes -from parser.nullable import merge_nullable +from parser.shapeinfer import infer_shapes, attach_struct_input_arrays +from parser.nullable import merge_nullable, extract_param_docs from parser.nullresult import attach_null_result from parser.outparam import extract_param_names, merge_outparams from parser.boundargs import (attach_call_literals, merge_boundargs, merge_sql_arg_params, @@ -159,6 +159,9 @@ def main(): idl, nn = merge_nullable(idl, _doxy_root) print(f" nullable params from Doxygen `may be NULL`: {nn}", file=sys.stderr) + idl, nsa = attach_struct_input_arrays(idl, extract_param_docs(_doxy_root)) + print(f" input arrays of structs from Doxygen `Array of`: {nsa}", + file=sys.stderr) idl, no, out_drift = merge_outparams(idl, _doxy_root) print(f" out params from Doxygen `@param[out]`: {no}", file=sys.stderr) if out_drift: diff --git a/tests/test_claims_fit.py b/tests/test_claims_fit.py index dcda959..4734fe3 100644 --- a/tests/test_claims_fit.py +++ b/tests/test_claims_fit.py @@ -89,6 +89,27 @@ def test_a_signature_no_claimant_fits_stays_on_each(self): _fn("two", "f", [("box", TB), ("a", "double")], sig))) self.assertEqual(n, 0) + def test_an_array_length_takes_no_argument(self): + # As #test_a_bound_parameter_takes_no_argument for a bound parameter: the count + # shape.inputArrays names for an array takes no SQL argument, so Set_constructor's + # set(float[]) stays on floatset_make and cbufferset_make gives it up + def shape(element): + return {"inputArrays": [{"param": "values", + "lengthFrom": {"kind": "param", "name": "count"}, + "element": {"c": element, "canonical": element}}]} + sig = [{"args": ["float[]"], "ret": "floatset"}] + idl = _idl( + _fn("floatset_make", "set", [("values", "const double *"), ("count", "int")], + sig, shape=shape("double")), + _fn("cbufferset_make", "set", [("values", "const Cbuffer *"), ("count", "int")], + sig, shape=shape("Cbuffer"))) + idl["objectModel"]["classes"]["Cbuffer"] = {"cType": "Cbuffer *"} + idl, n = attach_claims_fit(idl) + fns = {f["name"]: f for f in idl["functions"]} + self.assertEqual(_args(fns["floatset_make"]), [("float[]",)]) + self.assertEqual(fns["cbufferset_make"]["sqlSignatures"], []) + self.assertEqual(n, 1) + def test_an_internal_claimant_is_left_alone(self): idl, n = attach_claims_fit(_idl(_kernel("tintbox_value_tiles", "int"), _kernel("internal_tiles", "int64") | {"api": "internal"})) diff --git a/tests/test_shapeinfer.py b/tests/test_shapeinfer.py index d50d91b..ccaac84 100644 --- a/tests/test_shapeinfer.py +++ b/tests/test_shapeinfer.py @@ -11,7 +11,7 @@ """ import unittest -from parser.shapeinfer import infer_shapes +from parser.shapeinfer import infer_shapes, attach_struct_input_arrays def _fn(name, ret, params): @@ -234,5 +234,37 @@ def test_an_array_of_bare_meos_values_is_not_callee_allocated(self): self.assertEqual(stats["outputArrays"], 1) +class StructInputArrayTests(unittest.TestCase): + """#attach_struct_input_arrays reads a contiguous array of structs from the @param text.""" + + def test_a_documented_array_of_structs_is_an_input_array(self): + idl = {"functions": [_fn("spanset_make", "SpanSet *", + [("spans", "Span *"), ("count", "int")])]} + docs = {"spanset_make": {"spans": "Array of spans", + "count": "Number of elements in the array"}} + idl, n = attach_struct_input_arrays(idl, docs) + self.assertEqual(n, 1) + self.assertEqual(idl["functions"][0]["shape"]["inputArrays"], [{ + "param": "spans", + "lengthFrom": {"kind": "param", "name": "count"}, + "element": {"c": "Span", "canonical": "Span"}}]) + + def test_one_struct_and_an_integer_is_no_array(self): + idl = {"functions": [_fn("stbox_round", "STBox *", + [("box", "const STBox *"), ("maxdd", "int")])]} + docs = {"stbox_round": {"box": "Spatiotemporal box", + "maxdd": "Maximum number of decimal digits"}} + idl, n = attach_struct_input_arrays(idl, docs) + self.assertEqual(n, 0) + self.assertNotIn("shape", idl["functions"][0]) + + def test_an_array_read_at_an_index_is_no_input_array(self): + idl = {"functions": [_fn("meos_array_get", "void *", + [("array", "MeosArray *"), ("n", "int")])]} + docs = {"meos_array_get": {"array": "Array", "n": "Index"}} + idl, n = attach_struct_input_arrays(idl, docs) + self.assertEqual(n, 0) + + if __name__ == "__main__": unittest.main()