diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e40d4cc..b5c899a 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 377 + run: tools/check-test-outcome.py "$RUNNER_TEMP/pytest.log" --min-tests 383 # 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 6f5ec6f..b44aa20 100644 --- a/docs/enrichment.md +++ b/docs/enrichment.md @@ -78,6 +78,13 @@ literals are in the catalog. `encoderAux`, `readerAux` and `writerAux` (and `in_aux` / `out_aux`, `bytes.encoderAux`). A binding builds each call from the function's own parameters, filling each trailing input by name, and refuses one the catalog does not fill. +- **Default of a trailing input** — the literal MEOS's own calls pass for a + parameter of its name, where every call passing a literal passes the same one + (`parser/boundargs.py` `extract_call_literals`): every literal `typmod` is -1, + PostgreSQL's unspecified type modifier, so `interval_in`, `pg_timetz_in` and + `numeric_in` read with -1. A name passed two literals (`srid`, `maxdd`) or one + macro takes the formatting default: `maxdd` and `precision` 15, flags 0, `srs` + NULL. - **`variant`** of a WKB writer — the value the type's own `send` binds (`WKB_EXTENDED`, 4, keeping the SRID), else the value its SQL hex writer passes when the byte order is left out (`asHexWKB(raster, endian DEFAULT '')` passes 0). diff --git a/parser/boundargs.py b/parser/boundargs.py index cd30f2d..1efaff8 100644 --- a/parser/boundargs.py +++ b/parser/boundargs.py @@ -587,3 +587,60 @@ def resolve_bound_names(idl: dict, include_root: str | Path) -> tuple[dict, int, "vendored": False, "value": val}) n += 1 return idl, n, unresolved + + +# A C comment, block or line. +_COMMENT = re.compile(r"/\*.*?\*/|//[^\n]*", re.S) + + +def extract_call_literals(meos_src: str | Path, functions: list) -> dict[str, str]: + """``{parameter name: literal}`` for every parameter name to which MEOS's own calls of + catalog functions pass one literal alone, read argument by argument as + #_wrapper_bound reads a wrapper's call. + + ``geom_in(wkt, -1)``, ``pg_timestamptz_in(str, -1)`` and every other call passing a + literal for a ``typmod`` pass ``-1``, PostgreSQL's unspecified type modifier, so + ``typmod`` reads ``-1``. A name receiving two literals (``srid`` is passed ``0`` and + ``SRID_UNKNOWN``) states none; an argument that is not a literal is not counted.""" + params = {f["name"]: [p["name"] for p in f.get("params", [])] for f in functions} + seen: dict[str, set[str]] = {} + for path in sorted(Path(meos_src).rglob("*.c")): + text = _COMMENT.sub(" ", path.read_text(errors="ignore")) + for m in _CALLEE.finditer(text): + names = params.get(m.group("name")) + if not names: + continue + start, depth = m.end() - 1, 0 + for i in range(start, len(text)): + depth += (text[i] == "(") - (text[i] == ")") + if depth == 0: + break + if re.match(r"\s*\{", text[i + 1:]): + continue # the definition, not a call + for name, arg in zip(names, _split_args(text[start + 1:i])): + lit = _literal(arg) + if lit is not None: + seen.setdefault(name, set()).add(lit) + return {name: next(iter(lits)) for name, lits in seen.items() if len(lits) == 1} + + +def attach_call_literals(idl: dict, meos_src: str | Path) -> tuple[dict, int]: + """Set ``_callLiteral`` on every parameter whose name #extract_call_literals reads one + literal for. The key is private: #_aux_specs of parser/enrich.py reads it as a trailing + input's default and run.py removes it before writing the catalog.""" + lits = extract_call_literals(meos_src, idl.get("functions", [])) + n = 0 + for f in idl.get("functions", []): + for p in f.get("params", []): + if p["name"] in lits: + p["_callLiteral"] = lits[p["name"]] + n += 1 + return idl, n + + +def strip_call_literals(idl: dict) -> dict: + """Remove the private ``_callLiteral`` #attach_call_literals sets.""" + for f in idl.get("functions", []): + for p in f.get("params", []): + p.pop("_callLiteral", None) + return idl diff --git a/parser/enrich.py b/parser/enrich.py index 98a9b38..5b2b9ff 100644 --- a/parser/enrich.py +++ b/parser/enrich.py @@ -145,6 +145,28 @@ def _is_scalar_pointer(c_type: str, enums: set) -> bool: return False +# No literal of the parameter's kind: #_call_default answers this rather than None, a value. +_UNSTATED = object() + + +def _call_default(literal, kind: str): + """The value of ``literal``, the ``_callLiteral`` #attach_call_literals of + parser/boundargs.py sets, as a ``kind`` (``integer``, ``number``, ``boolean`` or + ``string``) default: ``-1`` reads -1, ``NULL`` None for a string; a macro name or a + literal of another kind is #_UNSTATED.""" + if literal is None: + return _UNSTATED + if kind == "integer" and re.fullmatch(r"-?\d+", literal): + return int(literal) + if kind == "number" and re.fullmatch(r"-?\d+(?:\.\d+)?", literal): + return float(literal) + if kind == "boolean" and literal in ("true", "false"): + return literal == "true" + if kind == "string" and literal == "NULL": + return None + return _UNSTATED + + def _aux_specs(params: list): """Defaults for the trailing args of an in/out helper. @@ -168,7 +190,10 @@ def _aux_specs(params: list): if "type" in nm: # temptype/basetype/settype tag return None j = sc["json"] - if j == "integer": + passed = _call_default(p.get("_callLiteral"), j) + if passed is not _UNSTATED: + default = passed + elif j == "integer": default = (15 if any(k in nm for k in ("maxdd", "decimal", "digit", "precision")) else 0) diff --git a/run.py b/run.py index afb3dd6..9911a7f 100644 --- a/run.py +++ b/run.py @@ -15,7 +15,8 @@ from parser.nullable import merge_nullable from parser.nullresult import attach_null_result from parser.outparam import extract_param_names, merge_outparams -from parser.boundargs import merge_boundargs, resolve_bound_names +from parser.boundargs import (attach_call_literals, merge_boundargs, resolve_bound_names, + strip_call_literals) from parser.compositions import attach_compositions from parser.enrich import enrich_idl from parser.codecs import state_type_encodings @@ -172,6 +173,11 @@ def main(): _grp_root / "pgtypes") print(f" attached {ngrp} doxygen @ingroup groups", file=sys.stderr) + # A trailing input's default is the literal MEOS's own calls pass for a parameter + # of its name, where they pass one alone (typmod reads -1). + idl, ncall = attach_call_literals(idl, _grp_root / "meos" / "src") + print(f" parameters MEOS's calls pass one literal: {ncall}", file=sys.stderr) + # 1f. Derive service-projection metadata (category / encodings / network). # Runs before the merge so manual annotations override the heuristics. idl = enrich_idl(idl) @@ -377,6 +383,7 @@ def main(): idl["families"] = list(all_families()) print(f" families = {', '.join(idl['families'])}", file=sys.stderr) + idl = strip_call_literals(idl) idl_path = OUTPUT_DIR / "meos-idl.json" with open(idl_path, "w") as f: json.dump(idl, f, indent=2) diff --git a/tests/test_boundargs.py b/tests/test_boundargs.py index 59d965c..fb6b2cd 100644 --- a/tests/test_boundargs.py +++ b/tests/test_boundargs.py @@ -12,7 +12,8 @@ import unittest from pathlib import Path -from parser.boundargs import extract_wrappers, merge_boundargs, resolve_bound_names +from parser.boundargs import (attach_call_literals, extract_call_literals, extract_wrappers, + merge_boundargs, resolve_bound_names, strip_call_literals) # A synthetic MobilityDB wrapper source (mobilitydb/src/**/*.c shape). SAMPLE = ''' @@ -816,5 +817,49 @@ def test_names_without_one_literal_stay_unresolved(self): self.assertEqual(unresolved, ["GUARDED", "MISSING", "SHADOWED"]) +class CallLiteralTests(unittest.TestCase): + """A parameter name MEOS's own calls pass one literal alone reads that literal.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + (Path(self.tmp.name) / "type_in.c").write_text( + "GSERIALIZED *\n" + "geom_in(const char *str, int32 typmod)\n" + "{\n return pg_geom_in(str, typmod);\n}\n" + "void f(const char *s, int32 tm)\n" + "{\n" + " /* geom_in(s, 7) in a comment is no call */\n" + " geom_in(s, -1);\n" + " geog_in(s, -1);\n" + " geog_in(s, tm);\n" + " geo_set_srid(g, 0);\n" + " geo_set_srid(g, SRID_UNKNOWN);\n" + "}\n") + self.functions = [ + {"name": "geom_in", "params": [{"name": "str"}, {"name": "typmod"}]}, + {"name": "geog_in", "params": [{"name": "str"}, {"name": "typmod"}]}, + {"name": "geo_set_srid", "params": [{"name": "gs"}, {"name": "srid"}]}] + + def tearDown(self): + self.tmp.cleanup() + + def test_one_literal_alone_is_read(self): + # the definition's typmod, the variable tm and the commented call are no literals + lits = extract_call_literals(self.tmp.name, self.functions) + self.assertEqual(lits, {"typmod": "-1"}) + + def test_two_literals_state_none(self): + self.assertNotIn("srid", extract_call_literals(self.tmp.name, self.functions)) + + def test_the_literal_is_attached_then_stripped(self): + idl, n = attach_call_literals({"functions": self.functions}, self.tmp.name) + self.assertEqual(n, 2) + self.assertEqual(idl["functions"][0]["params"][1]["_callLiteral"], "-1") + self.assertNotIn("_callLiteral", idl["functions"][2]["params"][1]) + strip_call_literals(idl) + self.assertFalse(any("_callLiteral" in p for f in idl["functions"] + for p in f["params"])) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_enrich.py b/tests/test_enrich.py index 3d76bb6..0baa21e 100644 --- a/tests/test_enrich.py +++ b/tests/test_enrich.py @@ -7,13 +7,15 @@ parameters), so the assertions double as a specification. """ +import json import sys import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from parser.enrich import enrich_idl, classify_category, build_type_encodings +from parser.enrich import (_aux_specs, build_type_encodings, classify_category, + enrich_idl) def fn(name, ret, *params): @@ -267,6 +269,39 @@ def test_a_standard_integer_result_is_a_json_integer(self): {"kind": "json", "json": "integer"}) +class CallLiteralDefaultTests(unittest.TestCase): + """A trailing input whose name MEOS's calls pass one literal for, as + #attach_call_literals of parser/boundargs.py reads it, defaults to that literal; + a macro name keeps the default #TypeEncodingTests states.""" + + def _aux(self, ctype, name, literal): + p = {"name": name, "cType": ctype, "canonical": ctype, "_callLiteral": literal} + return _aux_specs([p])[0]["default"] + + def test_the_literal_is_the_default(self): + self.assertEqual(self._aux("int32_t", "typmod", "-1"), -1) + self.assertIsNone(self._aux("const char *", "srs", "NULL")) + self.assertIs(self._aux("bool", "with_bbox", "true"), True) + + def test_a_macro_or_a_literal_of_another_kind_keeps_the_default(self): + self.assertEqual(self._aux("int", "maxdd", "OUT_DEFAULT_DECIMAL_DIGITS"), 15) + self.assertEqual(self._aux("int", "option", "NULL"), 0) + + +class CallLiteralCatalogTests(unittest.TestCase): + """Over the generated catalog, the PostgreSQL readers taking a type modifier read + with -1, the modifier MEOS's own calls pass, as #CallLiteralDefaultTests states.""" + + def test_a_type_modifier_reads_minus_one(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"] + for cls in ("Interval", "TimeTzADT", "NumericData"): + self.assertEqual(te[cls]["in_aux"], + [{"name": "typmod", "kind": "integer", "default": -1}], cls) + + class ApiClassificationTests(unittest.TestCase): def setUp(self): self.fns = by_name(make_idl())