diff --git a/docs/cross-repo-handoff.md b/docs/cross-repo-handoff.md index 86ae1ff..8e6d993 100644 --- a/docs/cross-repo-handoff.md +++ b/docs/cross-repo-handoff.md @@ -21,6 +21,7 @@ python tools/portable_parity.py # -> output/meos-portable-parity.json (bare-nam | `meos-idl.json#/functions[].{network,wire,api}` | per-function projectability + decode/encode/array/out-param wire model | | `meos-idl.json#/temporalTypes` | per `Temporal`: its `base`, its `bbox`, the `mfjson` type token `asMFJSON` writes, and the `number` / `spatial` / `linear` classes | | `meos-idl.json#/typeRelations/byBase` | each base type's `set`, `span`, `spanset` and its `temporal` types, the last a list since a base carries several | +| `meos-idl.json#/typeRelations/predicates` | each type category `meos_catalog.c` states as a membership predicate (`numspan_type`, `timespan_type`, `tnumber_spantype`, `tspatial_type`, ...) → its member types in `MeosType` order, with every family the catalog describes; the `ensure_*` validators state no category and are left out | | `meos-coverage.json#/worklist` | every non-exposable public function with a `class` + concrete upstream `suggest`, ranked by `byClass` | | `meos-portable-parity.json` | each bare name → backing family, with the verified parity headline | | OpenAPI / MCP / runtime server | generated from the same catalog | diff --git a/parser/typerelations.py b/parser/typerelations.py index 2df8fcf..4f7e5cf 100644 --- a/parser/typerelations.py +++ b/parser/typerelations.py @@ -133,6 +133,69 @@ def locate_catalog(src_root: Path | None) -> Path | None: return locate_temporal_source(src_root, "meos_catalog.c") +_PREDICATE_RE = re.compile(r'\nbool\s*\n(\w+)\(' + MEOSTYPE_PARAM + r' type\)\s*\{(.*?)\n\}', re.S) +_TERM_RE = re.compile(r'^(?:type\s*==\s*(T_\w+)|(\w+)\(\s*type\s*\))$') + + +def type_predicates(text: str, names: dict) -> dict: + """The members of every membership predicate of ``meos_catalog.c``, in MeosType order. + + A membership predicate is a ``bool (MeosType type)`` whose body returns a + disjunction of ``type == T_X`` tests and of calls to other membership predicates + (`numspan_type`, `tnumber_type`, `spatialset_type`, ...): it states a category of types, + the sets of numbers or the spans a temporal box carries, and the catalog publishes each + category with its members so that a binding reads which types a category holds where + MEOS states it. A predicate whose body does anything else is not a membership statement + and is left out rather than approximated, as #object_model.predicate_types reads only a + predicate it names. + """ + bodies = {} + for name, body in _PREDICATE_RE.findall(text): + body = re.sub(r'/\*.*?\*/', '', body, flags=re.S) + # A family guard (`#if POINTCLOUD` ... `#endif`) admits its members in the build with + # every family, which is the build the catalog describes; an alternative (`#else`) is + # not a membership the catalog can state. + if re.search(r'^\s*#\s*(else|elif)\b', body, re.M): + continue + body = re.sub(r'^\s*#\s*(if|ifdef|ifndef|endif)\b.*$', '', body, flags=re.M) + m = (re.fullmatch(r'\s*return\s*\(?(.*?)\)?\s*;\s*', body, re.S) + or re.fullmatch(r'\s*bool\s+(\w+)\s*=\s*\(?(.*?)\)?\s*;\s*return\s+\1\s*;\s*', + body, re.S)) + if not m: + continue + terms = [t.strip() for t in re.split(r'\|\|', m.group(m.lastindex))] + parsed = [_TERM_RE.match(t) for t in terms] + if all(parsed): + bodies[name] = [(p.group(1), p.group(2)) for p in parsed] + + order = {t: i for i, t in enumerate(names)} + resolved = {} + + def members(name, seen=()): + if name in resolved: + return resolved[name] + if name in seen or name not in bodies: + return None + out = set() + for literal, call in bodies[name]: + if literal: + out.add(literal) + else: + sub = members(call, seen + (name,)) + if sub is None: + return None + out |= sub + resolved[name] = out + return out + + result = {} + for name in bodies: + types = members(name) + if types and all(t in names for t in types): + result[name] = [names[t] for t in sorted(types, key=lambda t: order[t])] + return dict(sorted(result.items())) + + def attach_type_relations(idl: dict, src_root: Path | None) -> dict: """Attach ``idl["typeRelations"]`` from the ``MEOS_RELTYPE_CATALOG`` array. @@ -203,5 +266,6 @@ def attach_type_relations(idl: dict, src_root: Path | None) -> dict: f"{catalog}: no type relation parsed from MEOS_RELTYPE_CATALOG — the catalog's " "shape has changed and parser/typerelations.py no longer reads it") - idl["typeRelations"] = {"byBase": dict(sorted(by_base.items()))} + idl["typeRelations"] = {"byBase": dict(sorted(by_base.items())), + "predicates": type_predicates(text, names)} return idl diff --git a/tests/test_typerelations.py b/tests/test_typerelations.py index 5a05e5b..cd4faca 100644 --- a/tests/test_typerelations.py +++ b/tests/test_typerelations.py @@ -154,6 +154,86 @@ def test_mdb_src_root_resolves_when_object_model_root_is_absent(self): self.assertEqual(by_base["float8"]["spanset"], "floatspanset") +_PREDICATES = _FIXTURE + """ +bool +numspan_type(MeosType type) +{ + return (/* Dates are represented as integers */ type == T_FLOATSPAN); +} + +bool +span_type(MeosType type) +{ + return (numspan_type(type) || type == T_FLOATSET); +} + +bool +tspatial_type(MeosType type) +{ + return (type == T_TGEOMPOINT +#if GEOMETRY + || type == T_TGEOMETRY +#endif + ); +} + +bool +linear_type(MeosType type) +{ + bool result = (type == T_TFLOAT || type == T_TGEOMPOINT); + return result; +} + +bool +ensure_tspatial_type(MeosType type) +{ + if (tspatial_type(type)) + return true; + return false; +} + +bool +alternative_type(MeosType type) +{ +#if GEOMETRY + return type == T_TGEOMETRY; +#else + return type == T_TGEOMPOINT; +#endif +} +""" + + +class TypePredicatesParseTest(unittest.TestCase): + + def _predicates(self, text): + with tempfile.TemporaryDirectory() as d: + catalog = Path(d) / "temporal" + catalog.mkdir() + (catalog / "meos_catalog.c").write_text(text) + return attach_type_relations({}, Path(d))["typeRelations"]["predicates"] + + def test_a_predicate_lists_its_members_in_meostype_order(self): + preds = self._predicates(_PREDICATES) + self.assertEqual(preds["numspan_type"], ["floatspan"]) + self.assertEqual(preds["linear_type"], ["tfloat", "tgeompoint"]) + + def test_a_predicate_calling_another_takes_its_members(self): + self.assertEqual(self._predicates(_PREDICATES)["span_type"], ["floatset", "floatspan"]) + + def test_a_family_guard_admits_its_members(self): + # The catalog describes the build with every family, so a member behind `#if` is one + self.assertEqual(self._predicates(_PREDICATES)["tspatial_type"], + ["tgeompoint", "tgeometry"]) + + def test_what_states_no_membership_is_left_out(self): + # an ensure_ raises rather than states a category, and a body with an alternative + # (`#else`) holds no one membership + preds = self._predicates(_PREDICATES) + self.assertNotIn("ensure_tspatial_type", preds) + self.assertNotIn("alternative_type", preds) + + class TypeRelationsSourceTest(unittest.TestCase): def test_canonical_numeric_mappings(self): @@ -182,6 +262,21 @@ def test_canonical_numeric_mappings(self): for name in ("tpose", "tgeompoint", "tgeogpoint", "trgeometry", "tgeometry"): self.assertIn(name, reachable) + def test_canonical_categories(self): + # The live counterpart of TypePredicatesParseTest, as #test_canonical_numeric_mappings + # is of the relation parse + src = find_mobilitydb_src() + if src is None and locate_catalog(None) is None: + self.skipTest("MobilityDB source not available") + preds = attach_type_relations({}, src)["typeRelations"]["predicates"] + # The numbers of a temporal box and the temporal numbers whose box it is + self.assertEqual(preds["tnumber_spantype"], ["floatspan", "intspan", "bigintspan"]) + self.assertEqual(preds["tnumber_type"], ["tfloat", "tint", "tbigint"]) + self.assertEqual(preds["timespan_type"], ["datespan", "tstzspan"]) + # A member behind a family guard (`#if POINTCLOUD`) + self.assertIn("pcpatchset", preds["spatialset_type"]) + self.assertFalse(any(name.startswith("ensure_") for name in preds)) + if __name__ == "__main__": unittest.main()