From 803c7a0ba6c4083141b50ab1e29eab7672003fa5 Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Thu, 1 Oct 2026 13:57:55 +0200 Subject: [PATCH] Read and write PostgreSQL's time types through their own functions A PostgreSQL type passed by value, TimestampTz, Timestamp, TimeADT and DateADT, is a class of typeEncodings like a pointer type, its decoder returning it and its encoder taking it without a pointer (timestamptz_in / timestamptz_out). A function taking or returning one states it on the wire through that codec. PostgreSQL's spelling pg_X yields to the name MEOS states it under, X, when both serve a class: timetz_in calls pg_timetz_in, and TimeTzADT and TimeADT read and write through timetz_in/timetz_out and time_in/time_out. A type passed by value that has no codec, Datum, states none. Why. The codec scan read pointer types alone, so every function taking or returning a timestamp, a date or a time had no wire form, and TimeTzADT read through pg_timetz_in, which MEOS does not publish. Measured. Over MobilityDB 72abdf86c2: TimestampTz, Timestamp, TimeADT and DateADT join typeEncodings, their readers taking typmod -1; TimeTzADT moves to timetz_in and timetz_out and every other class keeps its codec; 368 public functions become exposable, 2860 to 3228, and none stops being exposable. The wire of 527 functions changes, each through one of the five types. Read through libmeos with the trailing inputs the catalog states, each type returns its text unchanged: timestamptz_in '2026-10-01 12:34:56.789+02' writes back '2026-10-01 12:34:56.789+02', likewise the other four. Witness. tests/test_enrich.py reads and writes a type passed by value through its functions, states its parameter and result on the wire, prefers timetz_in over pg_timetz_in, registers nothing for a value type without a codec, and over the catalog finds the five codecs; four of the five fail on the code without this change. The suite floor goes from 390 to 395. --- .github/workflows/pytest.yml | 2 +- docs/enrichment.md | 5 +++ parser/enrich.py | 32 ++++++++++++++---- tests/test_enrich.py | 65 ++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 62af23d..e06b987 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 390 + run: tools/check-test-outcome.py "$RUNNER_TEMP/pytest.log" --min-tests 395 # 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/enrichment.md b/docs/enrichment.md index b44aa20..efab2ea 100644 --- a/docs/enrichment.md +++ b/docs/enrichment.md @@ -62,6 +62,11 @@ literals are in the catalog. `*_from_hexwkb`, …); **encoder** — a public `T (+ aux) → char *` (`*_out`, `*_as_mfjson`, `*_as_hexwkb`, …). A size the function writes back is an out-parameter, stated in its `shape.outParams` as for any other function. +- **A PostgreSQL type passed by value** (`TimestampTz`, `Timestamp`, `TimeADT`, + `DateADT`) is a class like a pointer type, its decoder returning it and its + encoder taking it without a pointer (`timestamptz_in` / `timestamptz_out`). + PostgreSQL's spelling `pg_X` yields to the name MEOS states it under, `X`, + when both serve a class (`timetz_in`, never `pg_timetz_in`). - **`readers` / `writers`** — a class several SQL types share (`Set`, `Span`, `SpanSet`, `Temporal`) reads and writes each type through that type's own public function, keyed by the SQL type its signature returns (a reader) or takes (a writer). diff --git a/parser/enrich.py b/parser/enrich.py index 5b2b9ff..47b5c71 100644 --- a/parser/enrich.py +++ b/parser/enrich.py @@ -207,7 +207,8 @@ def _aux_specs(params: list): return specs -def build_type_encodings(functions: list, structs: set) -> dict: +def build_type_encodings(functions: list, structs: set, + values: frozenset = frozenset()) -> dict: """Scan the catalog for the in/out functions of every opaque struct. A *decoder* turns a wire string into an object (returns ``struct T *``, @@ -216,6 +217,11 @@ def build_type_encodings(functions: list, structs: set) -> dict: *formatting* scalars are allowed and defaulted (see ``_aux_specs``); a non-defaultable trailing arg disqualifies the helper. Only declared structs qualify, so primitives never register by accident. + + A name in ``values`` is a type passed by value, as PostgreSQL passes its + ``TimestampTz`` and ``DateADT``: its decoder returns it and its encoder takes it + without a pointer (``TimestampTz timestamptz_in(const char *, int32_t)``, + ``char *timestamptz_out(TimestampTz)``). """ enc: dict[str, dict] = {} @@ -235,7 +241,7 @@ def slot(b: str) -> dict: aux = _aux_specs(params[1:]) # None => non-defaultable trailing arg # Decoder: const char* (+ defaultable scalar aux) -> opaque struct - if (aux is not None and rd >= 1 and rb in structs + if (aux is not None and (rd >= 1 or rb in values) and rb in structs and pd == 1 and pb in _STRING_PTR_BASES): for rx, encoding in _DECODERS: if rx.search(name): @@ -246,7 +252,7 @@ def slot(b: str) -> dict: # Encoder: const struct T* (+ defaultable scalar aux) -> char* if (aux is not None and rd == 1 and rb in _STRING_PTR_BASES - and pd >= 1 and pb in structs): + and (pd >= 1 or pb in values) and pb in structs): for rx, encoding in _ENCODERS: if rx.search(name): s = slot(pb) @@ -265,9 +271,13 @@ def slot(b: str) -> dict: enc_suffix = {"text": "_out", "mfjson": "_as_mfjson", "wkb": "_as_hexwkb"} + # PostgreSQL's own spelling `pg_X` yields to the name MEOS states it under, + # `X`, when both serve a type: `timetz_in` calls `pg_timetz_in`, and only the + # first is the MEOS function a binding calls. def choose(cands: dict, base: str, suffix: str) -> str: + pool = [c for c in cands if not (c.startswith("pg_") and c[3:] in cands)] generic = base.lower() + suffix - return generic if generic in cands else sorted(cands)[0] + return generic if generic in pool else sorted(pool)[0] # The byte codec of a type, beside its wire encodings: a reader # ``T *f(const uint8_t *wkb, size_t size)`` and a writer @@ -527,7 +537,7 @@ def assess(fn: dict, type_encodings: dict, enums: set) -> tuple: else: base, depth = _base(ret), _ptr_depth(ret) te = type_encodings.get(base) - if depth == 1 and te and te["out"]: + if depth <= 1 and te and te["out"]: wire_result = {"kind": "serialized", "cType": ret, "encode": te["out"], "encode_aux": te.get("out_aux", []), @@ -565,15 +575,23 @@ def enrich_idl(idl: dict) -> dict: # wrappers can register a codec instead of being dead `no-decoder`s. _scalarish = (_INT_BASES | _FLOAT_BASES | _BOOL_BASES | _STRING_PTR_BASES | enum_names | {"void", "text"}) + # A named type passed by value that is no scalar is PostgreSQL's + # (`TimestampTz`, `DateADT`, `TimeADT`, `Timestamp`): the slot keeps the name, + # as #normalize_canonical of parser/typerecover.py states it, and the type + # registers a codec from its own in/out functions like an opaque one. opaque_names = set(struct_names) + value_names = set() for fn in functions: for c in ([fn["returnType"]["canonical"]] + [p["canonical"] for p in fn.get("params", [])]): b = _base(c) - if _ptr_depth(c) >= 1 and b and b not in _scalarish: + if b and b not in _scalarish: opaque_names.add(b) + if _ptr_depth(c) == 0: + value_names.add(b) - type_encodings = build_type_encodings(functions, opaque_names) + type_encodings = build_type_encodings(functions, opaque_names, + frozenset(value_names)) for fn in functions: group = fn.get("group") diff --git a/tests/test_enrich.py b/tests/test_enrich.py index 0baa21e..9ac41d4 100644 --- a/tests/test_enrich.py +++ b/tests/test_enrich.py @@ -238,6 +238,53 @@ def test_lifecycle_and_index_not_exposable(self): self.assertIn("index", self.n("rtree_insert")["reason"]) +class ValueTypeTests(unittest.TestCase): + """A PostgreSQL type passed by value (`TimestampTz`, `DateADT`) registers a codec + from its own in/out functions, as a pointer type does in #TypeEncodingTests, and a + function taking or returning it is exposable.""" + + def setUp(self): + idl = {"functions": [ + fn("timestamptz_in", "TimestampTz", ("const char *", "str"), + ("int32_t", "typmod")), + fn("timestamptz_out", "char *", ("TimestampTz", "tstz")), + fn("timetz_in", "TimeTzADT *", ("const char *", "str"), + ("int32_t", "typmod")), + fn("pg_timetz_in", "TimeTzADT *", ("const char *", "str"), + ("int32_t", "typmod")), + fn("timetz_out", "char *", ("const TimeTzADT *", "timetz")), + fn("pg_timetz_out", "char *", ("const TimeTzADT *", "timetz")), + fn("temporal_start_timestamptz", "TimestampTz", (T, "temp")), + fn("tbool_at_timestamptz", "struct Temporal *", (T, "temp"), + ("TimestampTz", "t")), + fn("tbool_in", "struct Temporal *", ("const char *", "str")), + fn("temporal_out", "char *", (T, "temp")), + fn("datum_hash", "uint32_t", ("Datum", "d")), + ], "structs": [{"name": "Temporal", "fields": []}], "enums": []} + self.idl = enrich_idl(idl) + self.te = self.idl["typeEncodings"] + self.fns = by_name(self.idl) + + def test_a_value_type_reads_and_writes_through_its_functions(self): + self.assertEqual((self.te["TimestampTz"]["in"], self.te["TimestampTz"]["out"]), + ("timestamptz_in", "timestamptz_out")) + + def test_a_value_parameter_and_result_are_on_the_wire(self): + self.assertTrue(self.fns["tbool_at_timestamptz"]["network"]["exposable"]) + self.assertEqual(self.fns["tbool_at_timestamptz"]["wire"]["params"][1]["decode"], + "timestamptz_in") + self.assertEqual(self.fns["temporal_start_timestamptz"]["wire"]["result"]["encode"], + "timestamptz_out") + + def test_the_postgresql_spelling_yields_to_the_meos_name(self): + self.assertEqual((self.te["TimeTzADT"]["in"], self.te["TimeTzADT"]["out"]), + ("timetz_in", "timetz_out")) + + def test_a_value_type_without_a_codec_registers_nothing(self): + self.assertNotIn("Datum", self.te) + self.assertEqual(self.fns["datum_hash"]["network"]["reason"], "no-decoder:Datum") + + class StandardIntegerTests(unittest.TestCase): """An integer the catalog states by its C standard name (``int64_t``, ``uint8_t``), as #normalize_canonical of parser/typerecover.py states every integer typedef, @@ -302,6 +349,24 @@ def test_a_type_modifier_reads_minus_one(self): [{"name": "typmod", "kind": "integer", "default": -1}], cls) +class ValueTypeCatalogTests(unittest.TestCase): + """Over the generated catalog, PostgreSQL's time types read and write through the + MEOS functions #ValueTypeTests states, never their `pg_` spelling.""" + + def test_the_postgresql_time_types_read_through_their_meos_functions(self): + idl_path = Path(__file__).resolve().parents[1] / "output" / "meos-idl.json" + if not idl_path.exists(): + self.skipTest(f"{idl_path} not generated; run `python run.py` first") + te = json.loads(idl_path.read_text())["typeEncodings"] + self.assertEqual({c: (te[c]["in"], te[c]["out"]) for c in + ("TimestampTz", "Timestamp", "TimeADT", "DateADT", "TimeTzADT")}, + {"TimestampTz": ("timestamptz_in", "timestamptz_out"), + "Timestamp": ("timestamp_in", "timestamp_out"), + "TimeADT": ("time_in", "time_out"), + "DateADT": ("date_in", "date_out"), + "TimeTzADT": ("timetz_in", "timetz_out")}) + + class ApiClassificationTests(unittest.TestCase): def setUp(self): self.fns = by_name(make_idl())