From 20b95e05ac10a86e57a752406c05ede17d93efd6 Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Sat, 17 Oct 2026 12:26:00 +0200 Subject: [PATCH] Read every name of a tag that lists several A doxygen block whose wrapper backs several SQL functions or aggregates lists them on one tag line, `@sqlfn mergeFinal(), tCountFinal(), tMinFinal()` and `@sqlaggfn merge(), tCount(), tMin()`, and the operators they carry the same way, `@sqlop @p ->, @p ->>` beside `@sqlfn a(), b()`. The wrapper maps read every name of such a line: `_mdb_to_agg` gives the wrapper each aggregate it names, and `_mdb_to_sql` gives it each SQL function with the operator of the same position. `_SQLFN`, `_SQLAGGFN` and `_SQLOP` still find the tag; `_tag_list` reads its values. Witness: `Temporal_tagg_finalfn` of MobilityDB carries `@sqlaggfn merge(), tAnd(), tCount(), tMax(), tMin(), tOr(), tSum(), tdensity(), tnpoints(), wCount(), wMax(), wMin(), wSum()`, and the catalog stated `sqlAgg = ["merge"]` for `temporal_tagg_finalfn`, a binding reading it finding the member in one aggregate of thirteen. Measured: over MobilityDB branch `sql/aggregate-combines` the catalog differs from the one of master (`bb78d0a94`) in one field of 14 functions, `sqlAgg`, each naming every aggregate its tag lists (`temporal_tagg_finalfn` 13, `taggstate_serialize` and `taggstate_deserialize` 16, `temporal_tcount_combinefn` 3, `spanset_union_finalfn` 2, ...); the aggregates section and every `sqlfn`, which keeps the first name, are identical. Two tests in `tests/test_sqlaggfn.py` read a listing `@sqlaggfn`/`@sqlfn` and a listing `@sqlop`; both fail on master's parser and pass here. The suite runs 560 tests with the same 3 failures as master over the same catalog (`test_family` and two of `test_struct_layout`, failing identically on master). Why: a member shared by several aggregates states all of them, and a generator building an aggregate from its roles reads `sqlAgg` to find them; the first name alone made every shared member belong to one aggregate. The operator of the second listed name reaches the catalog by its position, as the tag writes it. --- parser/sqlfn.py | 30 ++++++++++++++++++++------ tests/test_sqlaggfn.py | 49 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/parser/sqlfn.py b/parser/sqlfn.py index b1b7e84..c6a3055 100644 --- a/parser/sqlfn.py +++ b/parser/sqlfn.py @@ -66,6 +66,19 @@ # contains a comma. `(\S+)` ran past it and published the comma as part of the operator. _SQLOP = re.compile(r"@sqlop\s+@p\s+([^\s,]+)") _DATUM = re.compile(r"Datum\s+(\w+)\s*\(\s*PG_FUNCTION_ARGS") +# Every value of a tag that lists several, on the tag's own line: the names of +# `@sqlfn a(), b()` and `@sqlaggfn a(), b()`, and the operators of `@sqlop @p ->, @p ->>`. +# `_SQLFN`, `_SQLAGGFN` and `_SQLOP` find the tag, and its first value, as before. +_TAG_NAME = re.compile(r"(\w+)\s*\(\)") +_TAG_OP = re.compile(r"@p\s+([^\s,]+)") + + +def _tag_list(text, start, value): + """The values `value` matches on the line of the tag starting at `start`.""" + end = text.find("\n", start) + line = text[start:end if end != -1 else len(text)] + return value.findall(line[line.index(" "):]) if " " in line else [] + # `CREATE [OR REPLACE] FUNCTION name(` — the SQL-facing signature; the wrapper it # binds is in the trailing `AS 'MODULE_PATHNAME', ''`. _CREATE_FN = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(\w+)\s*\(", re.I) @@ -416,17 +429,21 @@ def _mdb_to_sql(mdb_src): for cf in Path(mdb_src).rglob("*.c"): text = cf.read_text(errors="ignore") for m in _SQLFN.finditer(text): - sqlfn = m.group(1) # @sqlop lives in the SAME doxygen block (before the closing */). close = text.find("*/", m.end()) block = text[m.start():close] if close != -1 else text[m.start():m.start() + 800] op = _SQLOP.search(block) + # A block naming several SQL functions lists their operators in the same + # order, `@sqlop @p ->, @p ->>` beside `@sqlfn a(), b()`: the i-th name + # takes the i-th operator. + ops = _tag_list(block, op.start(), _TAG_OP) if op else [] dm = _DATUM.search(text, close if close != -1 else m.end()) if dm: - entry = (sqlfn, op.group(1) if op else None) lst = out.setdefault(dm.group(1), []) - if entry not in lst: - lst.append(entry) + for i, sqlfn in enumerate(_tag_list(text, m.start(), _TAG_NAME)): + entry = (sqlfn, ops[i] if i < len(ops) else None) + if entry not in lst: + lst.append(entry) return out @@ -445,8 +462,9 @@ def _mdb_to_agg(mdb_src): dm = _DATUM.search(text, close if close != -1 else m.end()) if dm: lst = out.setdefault(dm.group(1), []) - if m.group(1) not in lst: - lst.append(m.group(1)) + for name in _tag_list(text, m.start(), _TAG_NAME): + if name not in lst: + lst.append(name) return out diff --git a/tests/test_sqlaggfn.py b/tests/test_sqlaggfn.py index 040e48e..bcfa42d 100644 --- a/tests/test_sqlaggfn.py +++ b/tests/test_sqlaggfn.py @@ -12,7 +12,7 @@ import unittest from pathlib import Path -from parser.sqlfn import _mdb_to_agg, attach_sqlaggfn_map, attach_sqlfn_map +from parser.sqlfn import _mdb_to_agg, _mdb_to_sql, attach_sqlaggfn_map, attach_sqlfn_map MEOS_C = """ /** @@ -101,6 +101,53 @@ def test_sqlfn_states_the_function_the_wrapper_backs(self): self.assertEqual(f["sqlfn"], "tcount_transfn") self.assertEqual(f["sqlAgg"], ["tCount"]) + def test_a_member_shared_by_several_aggregates_names_each(self): + """A final serving several aggregates lists them on one tag line, and + every name reaches the catalog, not the first alone. The shape of + #test_wrapper_map_reads_only_tagged_wrappers over a listing tag.""" + src = """ +/** + * @ingroup mobilitydb_temporal_agg + * @brief Generic final function for temporal aggregation + * @sqlfn mergeFinal(), tCountFinal(), tMinFinal() + * @sqlaggfn merge(), tCount(), tMin() + */ +Datum +Temporal_tagg_finalfn(PG_FUNCTION_ARGS) +{ +} +""" + with tempfile.TemporaryDirectory() as d: + (Path(d) / "y.c").write_text(src) + d2a = _mdb_to_agg(d) + d2s = _mdb_to_sql(d) + self.assertEqual(d2a.get("Temporal_tagg_finalfn"), ["merge", "tCount", "tMin"]) + self.assertEqual([s for s, _ in d2s.get("Temporal_tagg_finalfn")], + ["mergeFinal", "tCountFinal", "tMinFinal"]) + + def test_listed_names_take_their_operators_in_order(self): + """`@sqlop @p ->, @p ->>` beside `@sqlfn a(), b()`: the i-th name takes the + i-th operator. The wrapper-side twin of + #test_multi_op_tag_keeps_the_operator_without_its_comma of + test_sqlfn_direct.py, over the same tjsonb block.""" + src = """ +/** + * @ingroup mobilitydb_json_json + * @brief Extract a field from a temporal JSONB value + * @sqlfn tjsonbObjectField(), tjsonbObjectFieldText() + * @sqlop @p ->, @p ->> + */ +Datum +Tjsonb_object_field(PG_FUNCTION_ARGS) +{ +} +""" + with tempfile.TemporaryDirectory() as d: + (Path(d) / "j.c").write_text(src) + d2s = _mdb_to_sql(d) + self.assertEqual(d2s.get("Tjsonb_object_field"), + [("tjsonbObjectField", "->"), ("tjsonbObjectFieldText", "->>")]) + if __name__ == "__main__": unittest.main()