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())