From 1326d0376f127a7462508efbbde6e144cab0caa2 Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Mon, 26 Oct 2026 15:23:04 +0100 Subject: [PATCH] Read the @nullable tag inside a parameter's own Doxygen entry A MEOS parameter that accepts NULL is marked by the @nullable tag inside its own @param entry, `@param[in] newjb New value @nullable`, so renaming the parameter edits the line that carries both. extract_nullable reads the tag anywhere in the entry's description, its continuation lines included, on an input and on an output a caller may pass as NULL to skip it, beside the prose note of the sources not yet converted to the tag. A line opening with @nullable ends the description before it and qualifies no parameter. extract_param_docs hands on the description without the tag. Witness: tests/test_nullable.py reads the tag on an entry's line and on its continuation line behind an #if MEOS guard, reads the tag on an @param[out], and reads no nullability from a line opening with @nullable. The catalog derived from MobilityDB master f3d480c80a, which carries no @nullable tag, is unchanged in every one of its 6721 functions. The 511 tests pass. --- parser/nullable.py | 41 ++++++++++++++++++++++++++----------- tests/test_nullable.py | 46 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/parser/nullable.py b/parser/nullable.py index c828eb2..f50b952 100644 --- a/parser/nullable.py +++ b/parser/nullable.py @@ -1,14 +1,19 @@ """Extract per-parameter nullability from the MEOS C Doxygen as the SoT. -A MEOS function parameter accepts NULL iff its Doxygen ``@param`` line says so, -e.g. ``@param[in] srs Spatial reference system, may be `NULL```. This is the -single source of truth the codegens consume — grounded in the C code, keyed by -parameter name, and cross-checked in MobilityDB against the PG layer (a SQL -function declared without ``STRICT`` + the wrapper's ``PG_ARGISNULL`` guards). +A MEOS function parameter accepts NULL iff its Doxygen ``@param`` entry carries +the ``@nullable`` tag, e.g. ``@param[in] newjb New value @nullable``, or, in the +sources not yet converted to the tag, says so in prose, e.g. +``@param[in] srs Spatial reference system, may be `NULL```. The tag sits inside the +entry of the parameter it qualifies, so renaming the parameter edits the line that +carries both; on an output it states that a caller may pass NULL to skip it. This is the single source of truth the codegens consume — grounded in +the C code, keyed by parameter name, and cross-checked in MobilityDB against the PG +layer (a SQL function declared without ``STRICT`` + the wrapper's ``PG_ARGISNULL`` +guards). The extractor walks the MEOS sources, pairs each Doxygen block with the function -it documents, and records the params whose description carries a NULL note. The -result feeds ``shape.nullable`` in the IDL so every binding can guard the param. +it documents, and records the params whose description carries the tag or a NULL +note. The result feeds ``shape.nullable`` in the IDL so every binding can guard the +param. """ from __future__ import annotations @@ -20,14 +25,25 @@ # by an `#if MEOS` guard in pgtypes/ is paired with it here too. from parser.outparam import _FUNC, doxygen_sources -# One @param entry: capture the (possibly comma-separated) names + description. +# One @param entry: capture the direction, the (possibly comma-separated) names and +# the description, which runs to the next line opening a Doxygen command. _PARAM = re.compile( - r'@param\[[^\]]*\]\s+(?P\w+(?:\s*,\s*\w+)*)\s+(?P.*?)' + r'@param\[(?P[^\]]*)\]\s+(?P\w+(?:\s*,\s*\w+)*)\s+(?P.*?)' r'(?=\n\s*\*\s*@|\*/|\Z)', re.S) +# The @nullable tag inside a @param description. A line opening with @nullable ends +# the description before it, so the tag is read only where it qualifies one entry. +_TAG = re.compile(r'(? bool: + """Whether an @param entry states that its parameter accepts NULL: the @nullable + tag, on an input or on an output a caller may pass as NULL to skip it (the + direction #extract_outparams reads from ``@param[out]``), or the prose note.""" + return bool(_TAG.search(desc) or _NULLISH.search(desc)) + + def extract_nullable(meos_root: str | Path) -> dict[str, list[str]]: """Return ``{function: [nullable params]}`` from the MEOS C sources (#doxygen_sources).""" @@ -37,7 +53,7 @@ def extract_nullable(meos_root: str | Path) -> dict[str, list[str]]: for m in _FUNC.finditer(txt): name = m.group("name") for pm in _PARAM.finditer(m.group("doc")): - if not _NULLISH.search(pm.group("desc")): + if not _is_nullable(pm.group("dir"), pm.group("desc")): continue for p in (n.strip() for n in pm.group("names").split(",")): out.setdefault(name, []) @@ -48,14 +64,15 @@ def extract_nullable(meos_root: str | Path) -> dict[str, list[str]]: 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.""" + ``@param`` text #extract_nullable reads, whitespace collapsed and the @nullable tag + removed, over the same files.""" out: dict[str, dict[str, str]] = {} for f in doxygen_sources(meos_root): 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()) + desc = " ".join(_TAG.sub("", pm.group("desc")).split()) for p in (n.strip() for n in pm.group("names").split(",")): if p: docs.setdefault(p, desc) diff --git a/tests/test_nullable.py b/tests/test_nullable.py index 0d37bc4..76c237a 100644 --- a/tests/test_nullable.py +++ b/tests/test_nullable.py @@ -1,8 +1,9 @@ """Regression tests for parser/nullable.py. -Nullability is read from the C Doxygen `@param ... may be NULL` notes (the -source of truth) and folded into each function's ``shape.nullable`` for the -params that actually exist on the IDL function. +Nullability is read from the C Doxygen (the source of truth), the `@nullable` tag +inside a `@param` entry or the `@param ... may be NULL` note, and folded into each +function's ``shape.nullable`` for the params that actually exist on the IDL +function. Plain unittest, no pytest dependency; writes a tiny synthetic source tree. """ @@ -10,7 +11,7 @@ import unittest from pathlib import Path -from parser.nullable import extract_nullable, merge_nullable +from parser.nullable import extract_nullable, extract_param_docs, merge_nullable SAMPLE = ''' /** @@ -36,6 +37,25 @@ { return NULL; } + +/** + * @brief Replace a value at a path + * @param[in] jb JSONB value + * @param[in] newjb New value @nullable + * @param[in] handle_null Null value treatment, one of the four names the + * function accepts @nullable + * @param[in] create When true, create the missing key + * @nullable create + * @param[out] count Number of values @nullable + */ +#if MEOS +Jsonb * +jsonb_set_lax(const Jsonb *jb, const Jsonb *newjb, const text *handle_null, + bool create, int *count) +{ + return NULL; +} +#endif /* MEOS */ ''' @@ -57,6 +77,24 @@ def test_extracts_only_may_be_null_params(self): # `temp`, `inst`, `interp` carry no NULL note -> not nullable self.assertNotIn("temp", nul["temporal_as_mfjson"]) + def test_reads_the_tag_inside_the_param_entry(self): + """The @nullable tag, as #test_extracts_only_may_be_null_params reads the + prose note.""" + nul = extract_nullable(self.tmp.name) + # On the entry's line and on its continuation line, behind an `#if MEOS` + # guard, and on an output a caller may pass as NULL + self.assertEqual(nul["jsonb_set_lax"], ["newjb", "handle_null", "count"]) + # A line opening with @nullable qualifies no entry + self.assertNotIn("create", nul["jsonb_set_lax"]) + self.assertNotIn("jb", nul["jsonb_set_lax"]) + + def test_param_docs_leave_out_the_tag(self): + """The description #test_extracts_only_may_be_null_params reads, without the + tag.""" + docs = extract_param_docs(self.tmp.name) + self.assertEqual(docs["jsonb_set_lax"]["newjb"], "New value") + self.assertNotIn("@nullable", docs["jsonb_set_lax"]["handle_null"]) + def test_merge_only_existing_params(self): idl = {"functions": [ {"name": "temporal_as_mfjson",