diff --git a/parser/sqlfn.py b/parser/sqlfn.py index df58590..b008ffa 100644 --- a/parser/sqlfn.py +++ b/parser/sqlfn.py @@ -244,10 +244,46 @@ def _mdb_to_sql(mdb_src): return out +_DOXY_BLOCK = re.compile(r"/\*\*.*?\*/", re.S) + + +def _meos_direct_sql(meos_src): + """MEOS-C function name -> (sqlfn|None, sqlop|None) from a DIRECT @sqlfn / + @sqlop tag in meos/src — one hop, mirroring @csqlaggfn. This is the tag form + for a surface whose PostgreSQL registration is DEFERRED to a host extension + (the h3index scalar functions defer to h3-pg), so no PG wrapper exists to + carry the tag: the canonical SQL name lives with the MEOS function itself. + A block that also carries @csqlfn keeps the two-hop wrapper chain (skipped + here), and attach_sqlfn_map consults this map ONLY for functions the wrapper + chain did not resolve — fill-only, never an override.""" + out = {} + for cf in Path(meos_src).rglob("*.c"): + text = cf.read_text(errors="ignore") + for bm in _DOXY_BLOCK.finditer(text): + block = bm.group(0) + if "@csqlfn" in block or "@csqlaggfn" in block: + continue + # Anchor on @sqlfn exactly as the wrapper-side scan does (_mdb_to_sql): + # an @sqlop-only block carries no SQL NAME and stays out of the map on + # both sides, so the fallback restores names without inventing new + # operator-only attributions. + sm = _SQLFN.search(block) + if not sm: + continue + om = _SQLOP.search(block) + fm = _FNDEF.search(text, bm.end() - 2) + if not fm: + continue + out.setdefault(fm.group(1), + (sm.group(1), om.group(1) if om else None)) + return out + + def attach_sqlfn_map(idl, meos_src, mdb_src, sql_src=None): m2d = _meos_to_mdb(meos_src) d2s = _mdb_to_sql(mdb_src) w2sig = _wrapper_sql_sigs(sql_src) if sql_src else {} + direct = _meos_direct_sql(meos_src) n = 0 # Transient map: MEOS function name -> every SQL name it resolves to, for the # functions that fan out (a shared wrapper / ever-always pair). This is NOT @@ -256,17 +292,27 @@ def attach_sqlfn_map(idl, meos_src, mdb_src, sql_src=None): multi = {} for f in idl["functions"]: wrappers = m2d.get(f["name"]) - if not wrappers: - continue # A MEOS function can back several wrappers (the ever/always pair), each # carrying its own @sqlfn; collect the (sqlfn, sqlop) pairs across all of # them in order, keeping the primary (first) wrapper for back-compat. pairs = [] - for w in wrappers: + for w in wrappers or []: for entry in d2s.get(w, []): if entry not in pairs: pairs.append(entry) if not pairs: + # Fill-only fallback: a direct @sqlfn / @sqlop on the MEOS function + # itself (no PG wrapper — the PG registration is deferred to a host + # extension). Never reached when the wrapper chain resolves. + dsql = direct.get(f["name"]) + if not dsql: + continue + sqlfn, sqlop = dsql + if sqlfn: + f["sqlfn"] = sqlfn + if sqlop: + f["sqlop"] = sqlop + n += 1 continue f["mdbC"] = wrappers[0] f["sqlfn"] = pairs[0][0] diff --git a/tests/test_sqlfn_direct.py b/tests/test_sqlfn_direct.py new file mode 100644 index 0000000..4b366a5 --- /dev/null +++ b/tests/test_sqlfn_direct.py @@ -0,0 +1,98 @@ +"""Regression tests for the direct one-hop @sqlfn fallback in parser/sqlfn.py. + +A surface whose PostgreSQL registration is deferred to a host extension (the +h3index scalar functions defer to h3-pg) has no PG wrapper to carry @sqlfn, so +the MEOS function carries the tag itself. attach_sqlfn_map consults that direct +map ONLY for functions the @csqlfn wrapper chain does not resolve — fill-only, +never an override. + +Plain unittest, no pytest dependency; synthetic sources via a temp dir. +""" +import tempfile +import unittest +from pathlib import Path + +from parser.sqlfn import _meos_direct_sql, attach_sqlfn_map + +MEOS_C = """ +/** + * @ingroup meos_h3_base_inout + * @brief Parse a string into an H3Index + * @sqlfn h3index_in() + */ +H3Index +h3index_in(const char *str) +{ +} + +/** + * @ingroup meos_h3_base_comp + * @brief Return true if two h3index values are equal + * @sqlop @p = + */ +bool +h3index_eq(H3Index a, H3Index b) +{ +} + +/** + * @ingroup meos_setspan_inout + * @brief Return a set from its string representation + * @sqlfn set_in_direct_should_lose() + * @csqlfn #Set_in() + */ +Set * +set_in(const char *str) +{ +} +""" + +MDB_C = """ +/** + * @brief Return a set from its string representation + * @sqlfn set_in() + */ +Datum +Set_in(PG_FUNCTION_ARGS) +{ +} +""" + + +class DirectSqlfnTests(unittest.TestCase): + def _trees(self, d): + meos = Path(d) / "meos" + mdb = Path(d) / "mdb" + meos.mkdir() + mdb.mkdir() + (meos / "x.c").write_text(MEOS_C) + (mdb / "y.c").write_text(MDB_C) + return str(meos), str(mdb) + + def test_direct_map_needs_sqlfn_and_skips_csqlfn_blocks(self): + with tempfile.TemporaryDirectory() as d: + meos, _ = self._trees(d) + direct = _meos_direct_sql(meos) + # Name-bearing block resolves; @sqlop-only block stays out (same anchor + # the wrapper-side scan uses); a block with @csqlfn keeps the two-hop chain. + self.assertEqual(direct.get("h3index_in"), ("h3index_in", None)) + self.assertNotIn("h3index_eq", direct) + self.assertNotIn("set_in", direct) + + def test_attach_fills_only_unresolved_functions(self): + idl = {"functions": [{"name": "h3index_in"}, {"name": "set_in"}]} + with tempfile.TemporaryDirectory() as d: + meos, mdb = self._trees(d) + idl, n, _ = attach_sqlfn_map(idl, meos, mdb) + by = {f["name"]: f for f in idl["functions"]} + # Deferred surface: filled from the direct tag. + self.assertEqual(by["h3index_in"]["sqlfn"], "h3index_in") + self.assertNotIn("mdbC", by["h3index_in"]) + # Wrapper-resolved surface: the two-hop chain wins over the direct tag. + self.assertEqual(by["set_in"]["sqlfn"], "set_in") + self.assertEqual(by["set_in"]["mdbC"], "Set_in") + self.assertEqual(n, 2) + + +if __name__ == "__main__": + unittest.main()