From e3d788303d3a229c7a05a3b11fa4d34a5ea55b6f Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Thu, 3 Sep 2026 00:36:12 +0200 Subject: [PATCH] Name what each class's instances are a pointer to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binding declares every wrapper in terms of the C type a class stands for, and the catalog leaves that to the binding to work out — so each carries a map of its own, and a class the model gains is one the binding cannot type until that map is edited by hand. `classes..cType` states it instead, over the 21 C types MEOS's own signatures name. It is read from those signatures rather than listed. A receiver-role method — accessor, predicate, conversion, restriction, output — takes the value it is called on first, so the pointee of that parameter names the type, and the class's own methods answer for it. A class holding constructors alone says nothing about itself that way: the concrete `` classes take the answer of the subtype they are a product of, `TFloatInst` reading `TInstant` from `tinstant_*`, and any other class takes its parent's, which is the same C type by construction. All 105 classes resolve one, and the nine the binding maps by hand today — `Temporal`, `TInstant`, `TSequence`, `TSequenceSet`, `Set`, `Span`, `SpanSet`, `TBox`, `STBox` — resolve to what that map says. Three tests hold the three routes: the receiver, the subtype a constructor-only class is a product of, and the parent a class with neither answers from. --- .github/workflows/pytest.yml | 2 +- docs/object-model.md | 11 +++++ parser/object_model.py | 85 ++++++++++++++++++++++++++++++++++++ tests/test_object_model.py | 39 +++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f87330d..babdf51 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -96,7 +96,7 @@ jobs: # carries, or a change to them is not exercised until after it merges. # Consumers use the action; this repository owns the rules. - name: Refuse a skip, and a suite that shrank - run: tools/check-test-outcome.py "$RUNNER_TEMP/pytest.log" --min-tests 289 + run: tools/check-test-outcome.py "$RUNNER_TEMP/pytest.log" --min-tests 292 # The rules earn their place by refusing a log that carries what they # name. Both fixtures are written here rather than tracked, and the diff --git a/docs/object-model.md b/docs/object-model.md index fa2b5ef..1dd0809 100644 --- a/docs/object-model.md +++ b/docs/object-model.md @@ -144,6 +144,17 @@ C-symbol guessing. A function with no prefix match (operator overloads, `datum_*` base helpers, plumbing) is recorded honestly with `class: null` and a reason — never force-fitted. +`objectModel.classes..cType` names what the class's instances are a +pointer to, since a binding declares every wrapper in terms of it and +working it out per binding is what makes a class the binding cannot type +until it is edited. It is read from the signatures MEOS publishes: a +receiver-role method — accessor, predicate, conversion, restriction, +output — takes the value it is called on first, so the pointee of that +parameter names the type. A class holding constructors alone takes the +answer of the subtype it is a product of (`TFloatInst` → `TInstant`), and +any other its parent's, which is the same C type by construction. All 105 +classes resolve one, over the 21 C types MEOS's own signatures name. + ## Dispatch metadata For 4 of the 6 temporal-type families the per-member argument→backing diff --git a/parser/object_model.py b/parser/object_model.py index 7f37187..102e48e 100644 --- a/parser/object_model.py +++ b/parser/object_model.py @@ -70,6 +70,23 @@ def find_mobilitydb_src(headers_dir: Path | None = None) -> Path | None: _COMPANION_PREFIX_ALIASES = {"GeomSet": ["geomset", "geoset"]} +#: The roles whose first parameter is the value the method is called on. A +#: constructor builds one out of something else and an aggregate takes the +#: accumulator, so neither says what the class's instances are. +_RECEIVER_ROLES = frozenset({"accessor", "predicate", "conversion", + "restriction", "output"}) + +_QUALIFIER_RE = re.compile(r"\b(?:const|struct)\b") + + +def _pointee(c_type: str) -> str | None: + """The type a single-pointer C declaration points at, or None.""" + bare = _QUALIFIER_RE.sub("", c_type).strip() + if bare.endswith("*") and bare.count("*") == 1: + return bare[:-1].strip() + return None + + def _companion_families(model: dict) -> list: """The companion hierarchies the model carries, in file order. @@ -410,6 +427,56 @@ def derive_membership(nodes: dict, cat_src: str, basetypes: dict) -> None: spec["cBaseType"] = basetypes[temptype] +def _class_ctypes(classes: dict, functions: dict, parents: dict, + subtype_classes: dict) -> dict: + """The C type each class's instances are a pointer to. + + A binding declares every wrapper in terms of it, so leaving it to each + binding to work out is what makes a class a binding cannot type unless it + is edited. It is read here from the signatures MEOS already publishes: a + receiver-role method takes the value it is called on first, so the pointee + of that parameter names the type, and the class's own methods answer for + it. A class whose methods build values rather than take them — the + concrete `` classes hold constructors alone — takes the + answer of the subtype it is a product of, and any other class its parent's, + which is the same C type by construction. + """ + own = {} + for cls, spec in classes.items(): + seen = {} + for method in spec["methods"]: + if method["role"] not in _RECEIVER_ROLES: + continue + fn = functions.get(method["function"]) + params = fn.get("params") if fn else None + if not params: + continue + pointee = _pointee(params[0]["cType"]) + if pointee: + seen[pointee] = seen.get(pointee, 0) + 1 + ranked = sorted(seen.items(), key=lambda kv: -kv[1]) + if ranked and (len(ranked) == 1 or ranked[0][1] > ranked[1][1]): + own[cls] = ranked[0][0] + + resolved: dict = {} + + def resolve(cls, walked=frozenset()): + if cls in resolved: + return resolved[cls] + if cls in own: + resolved[cls] = own[cls] + elif cls in subtype_classes: + resolved[cls] = resolve(subtype_classes[cls], walked | {cls}) + else: + parent = parents.get(cls) + resolved[cls] = (resolve(parent, walked | {cls}) + if parent and parent not in walked | {cls} + else None) + return resolved[cls] + + return {cls: resolve(cls) for cls in classes} + + def attach_object_model(idl: dict, path: Path, mobilitydb_src: Path | None = None) -> dict: """Attach ``idl["objectModel"]`` from the canonical lattice file.""" @@ -486,6 +553,24 @@ def attach_object_model(idl: dict, path: Path, function_to_class[name]["concreteOf"] = tgt["concreteOf"] function_to_class[name]["subtype"] = tgt["subtype"] + # What each class's instances are a pointer to, so a binding declares its + # wrappers from the model rather than from a map of its own. + parents = {n: s.get("parent") for n, s in lat.items()} + for fam in _companion_families(model): + for n, s in model["companions"][fam]["nodes"].items(): + if not n.startswith("_"): + parents[n] = s.get("parent") + subtype_classes = {} + for leaf in [n for n, s in lat.items() if s["kind"] == "leaf"]: + for _tok, suffix, subtype in _SUBTYPE_SUFFIX: + if leaf + suffix in classes: + subtype_classes[leaf + suffix] = subtype + ctypes = _class_ctypes(classes, {f["name"]: f for f in functions}, + parents, subtype_classes) + for cls, ctype in ctypes.items(): + if ctype: + classes[cls]["cType"] = ctype + # Error contract errors = dict(model["errors"]) if mobilitydb_src and Path(mobilitydb_src).exists(): diff --git a/tests/test_object_model.py b/tests/test_object_model.py index 70d031e..3595809 100644 --- a/tests/test_object_model.py +++ b/tests/test_object_model.py @@ -195,6 +195,45 @@ def test_internal_api_methods_are_excluded(self): self.assertNotIn("ooExclude", meths["temporal_num_instants"]) self.assertTrue(meths["temporal_inst_n"].get("ooExclude")) + def test_class_ctype_comes_from_the_receiver(self): + # A receiver-role method takes the value it is called on first, so its + # pointee names what the class's instances are — which is what a + # binding declares every wrapper in terms of. + om = attach_object_model({"functions": [ + {"name": "cbuffer_srid", + "params": [{"name": "cbuf", "cType": "const Cbuffer *"}]}, + {"name": "geom_to_geog", + "params": [{"name": "geo", "cType": "const GSERIALIZED *"}]}, + ]}, MODEL, None)["objectModel"] + self.assertEqual(om["classes"]["Cbuffer"]["cType"], "Cbuffer") + self.assertEqual(om["classes"]["Geometry"]["cType"], "GSERIALIZED") + + def test_a_constructor_only_class_takes_its_subtypes_ctype(self): + # `tfloatinst_make` builds a value out of a base value and a time, so + # its first parameter says nothing about the class; the concrete class + # is the product of a leaf and a subtype, and the subtype answers. + om = attach_object_model({"functions": [ + {"name": "tfloatinst_make", + "params": [{"name": "d", "cType": "double"}, + {"name": "t", "cType": "TimestampTz"}]}, + {"name": "tinstant_value", + "params": [{"name": "inst", "cType": "const TInstant *"}]}, + ]}, MODEL, None)["objectModel"] + self.assertEqual(om["classes"]["TFloatInst"]["cType"], "TInstant") + + def test_a_class_with_no_receiver_takes_its_parents_ctype(self): + # A collection leaf whose only method builds a set out of geographies + # says nothing about itself either, and it is no product of a subtype; + # its parent answers, which is the same C type by construction. + om = attach_object_model({"functions": [ + {"name": "geogset_make", + "params": [{"name": "values", "cType": "const GSERIALIZED **"}, + {"name": "count", "cType": "int"}]}, + {"name": "set_num_values", + "params": [{"name": "s", "cType": "const Set *"}]}, + ]}, MODEL, None)["objectModel"] + self.assertEqual(om["classes"]["GeogSet"]["cType"], "Set") + def test_tree_derived(self): om = self._attach(["temporal_merge"])["objectModel"] lat = om["lattice"]