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
41 changes: 29 additions & 12 deletions parser/nullable.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<names>\w+(?:\s*,\s*\w+)*)\s+(?P<desc>.*?)'
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.
_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]]:
"""Return ``{function: [nullable params]}`` from the MEOS C sources
(#doxygen_sources)."""
Expand All @@ -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, [])
Expand All @@ -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)
Expand Down
46 changes: 42 additions & 4 deletions tests/test_nullable.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
"""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.
"""
import tempfile
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 = '''
/**
Expand All @@ -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 */
'''


Expand All @@ -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",
Expand Down
Loading