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
29 changes: 10 additions & 19 deletions parser/nullable.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
"""Extract per-parameter nullability from the MEOS C Doxygen as the SoT.

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 ``@nullable`` tag, e.g. ``@param[in] newjb New value @nullable``. 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. A description saying ``NULL`` in prose states nothing here: it describes a
value written or another argument. 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 the tag or a NULL
note. The result feeds ``shape.nullable`` in the IDL so every binding can guard the
it documents, and records the params whose description carries the tag. The result feeds ``shape.nullable`` in the IDL so every binding can guard the
param.
"""
from __future__ import annotations
Expand All @@ -30,18 +29,10 @@
_PARAM = re.compile(
r'@param\[(?P<dir>[^\]]*)\]\s+(?P<names>\w+(?:\s*,\s*\w+)*)\s+(?P<desc>.*?)'
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.
# The @nullable tag inside a @param description, of an input or of an output a
# caller may pass as NULL to skip it. 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'(?<![\w@])@nullable\b')
_NULLISH = re.compile(r'may be\s+`?NULL`?|can be\s+`?NULL`?|`?NULL`?\s+is allowed'
r'|or\s+`?NULL`?', re.I)


def _is_nullable(direction: str, desc: str) -> 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]]:
Expand All @@ -53,7 +44,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 _is_nullable(pm.group("dir"), pm.group("desc")):
if not _TAG.search(pm.group("desc")):
continue
for p in (n.strip() for n in pm.group("names").split(",")):
out.setdefault(name, [])
Expand Down
6 changes: 3 additions & 3 deletions run.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,12 @@ def main():
# 1d. Generate the codegen `shape` from the signatures + Doxygen, replacing
# the hand-maintained meta stub. inputArrays/outputArrays/arrayReturn
# come from the parameter forms; nullable comes from the C
# `@param ... may be NULL` SoT.
# `@param ... @nullable` SoT.
idl, sh = infer_shapes(idl)
print(f" inferred shape: {sh['arrayReturn']} array returns, "
f"{sh['outputArrays']} output arrays, "
f"{sh['inputArrays']} input arrays", file=sys.stderr)
# The `may be NULL` / `@param[out]` Doxygen tags live in the MEOS C *source*
# The `@nullable` / `@param[out]` Doxygen tags live in the MEOS C *source*
# (meos/src/**/*.c), not the parsed header tree. On the build-libmeos path
# HEADERS_DIR is the INSTALLED headers (generated meos_export.h, no src/), so
# scan from the source checkout (MDB_SRC_ROOT/meos) — the same root the
Expand All @@ -160,7 +160,7 @@ def main():
if os.environ.get("MDB_SRC_ROOT") else HEADERS_DIR.parent)
_doxy_root = _src_root if (_src_root / "src").is_dir() else HEADERS_DIR.parent
idl, nn = merge_nullable(idl, _doxy_root)
print(f" nullable params from Doxygen `may be NULL`: {nn}",
print(f" nullable params from Doxygen `@nullable`: {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}",
Expand Down
37 changes: 27 additions & 10 deletions tests/test_nullable.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Regression tests for parser/nullable.py.

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.
inside a `@param` entry, and folded into each function's ``shape.nullable`` for
the params that actually exist on the IDL function. A description naming NULL in
prose states nothing.

Plain unittest, no pytest dependency; writes a tiny synthetic source tree.
"""
Expand All @@ -18,7 +18,7 @@
* @ingroup meos_temporal_inout
* @brief Return the MF-JSON representation
* @param[in] temp Temporal value
* @param[in] srs Spatial reference system, may be `NULL`
* @param[in] srs Spatial reference system @nullable
*/
char *
temporal_as_mfjson(const Temporal *temp, char *srs)
Expand All @@ -29,7 +29,7 @@
/**
* @brief Append an instant
* @param[in] inst Instant
* @param[in] maxt Maximum time interval, may be `NULL`
* @param[in] maxt Maximum time interval @nullable
* @param[in] interp Interpolation
*/
Temporal *
Expand All @@ -38,6 +38,17 @@
return NULL;
}

/**
* @brief Dissolve the parts of a geometry
* @param[in] geom Geometry, may be `NULL`
* @param[out] dissolved The dissolved geometry, or NULL where nothing dissolves
*/
bool
geom_dissolve(const LWGEOM *geom, LWGEOM **dissolved)
{
return false;
}

/**
* @brief Replace a value at a path
* @param[in] jb JSONB value
Expand Down Expand Up @@ -70,16 +81,22 @@ def setUp(self):
def tearDown(self):
self.tmp.cleanup()

def test_extracts_only_may_be_null_params(self):
def test_extracts_only_tagged_params(self):
nul = extract_nullable(self.tmp.name)
self.assertEqual(nul["temporal_as_mfjson"], ["srs"])
self.assertEqual(nul["temporal_append_tinstant"], ["maxt"])
# `temp`, `inst`, `interp` carry no NULL note -> not nullable
# `temp`, `inst`, `interp` carry no tag -> not nullable
self.assertNotIn("temp", nul["temporal_as_mfjson"])

def test_prose_naming_null_states_nothing(self):
"""A description naming NULL without the tag, as
#test_extracts_only_tagged_params reads the tag."""
nul = extract_nullable(self.tmp.name)
self.assertNotIn("geom_dissolve", nul)

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."""
"""The @nullable tag on its continuation line, behind a guard and on an
output, as #test_extracts_only_tagged_params reads it on one line."""
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
Expand All @@ -89,7 +106,7 @@ def test_reads_the_tag_inside_the_param_entry(self):
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
"""The description #test_extracts_only_tagged_params reads, without the
tag."""
docs = extract_param_docs(self.tmp.name)
self.assertEqual(docs["jsonb_set_lax"]["newjb"], "New value")
Expand Down
Loading