diff --git a/parser/aggregates.py b/parser/aggregates.py index 46df043..570ed94 100644 --- a/parser/aggregates.py +++ b/parser/aggregates.py @@ -30,6 +30,7 @@ deserialize function over `bytea` and the state.""" import re +from parser.families import built_files from parser.compositions import _type from parser.sqlfn import _split_top_commas, _strip_sql_comments, sql_signature, sql_statements @@ -67,7 +68,7 @@ def _create_agg_stmts(text): def _agg_statements(sql_src): """Every CREATE AGGREGATE under `sql_src`, as #_create_agg_stmts yields it.""" out = [] - for sf in sorted(sql_src.rglob("*.sql")): + for sf in sorted(built_files(sql_src, "*.sql")): out.extend(_create_agg_stmts(_strip_sql_comments(sf.read_text(errors="ignore")))) return out diff --git a/parser/altsqlfn.py b/parser/altsqlfn.py index f1e3e5e..af89126 100644 --- a/parser/altsqlfn.py +++ b/parser/altsqlfn.py @@ -49,6 +49,7 @@ import re from pathlib import Path +from parser.families import built_files from parser.sqlfn import _CSQLFN_END, _meos_to_mdb, _wrapper_sql_sigs from parser.typescope import SQL_ALIASES, TypeFacts @@ -77,7 +78,7 @@ def wrapper_names(mdb_src: str | Path) -> dict[str, tuple[list[str], list[str]]] and no ``@sqlaggfn``: on a wrapper stating ``@sqlaggfn`` the tag names the aggregate (#aggregate_alt_names).""" out = {} - for cf in sorted(Path(mdb_src).rglob("*.c")): + for cf in sorted(built_files(mdb_src, "*.c")): for m in _WRAPPER_BLOCK.finditer(cf.read_text(errors="ignore")): alts = _tag_names(m.group(1), "altsqlfn") if alts and not _tag_names(m.group(1), "sqlaggfn"): @@ -91,7 +92,7 @@ def aggregate_alt_names(mdb_src: str | Path) -> tuple[dict[str, str], list[str]] pairs several ``@sqlfn`` names. A wrapper whose two lists differ in length, or an aggregate two wrappers name differently, is an error.""" out, errors = {}, [] - for cf in sorted(Path(mdb_src).rglob("*.c")): + for cf in sorted(built_files(mdb_src, "*.c")): for m in _WRAPPER_BLOCK.finditer(cf.read_text(errors="ignore")): alts = _tag_names(m.group(1), "altsqlfn") aggs = _tag_names(m.group(1), "sqlaggfn") diff --git a/parser/boundargs.py b/parser/boundargs.py index c77011f..771a47b 100644 --- a/parser/boundargs.py +++ b/parser/boundargs.py @@ -66,6 +66,8 @@ import re from pathlib import Path +from parser.families import built_files + # `Datum (PG_FUNCTION_ARGS) {` opens a PG wrapper. _WRAP = re.compile(r"Datum\s+(?P\w+)\s*\(\s*PG_FUNCTION_ARGS\s*\)\s*\{") # Any local the wrapper ASSIGNS (`var = ...`, excluding `==`): every value the @@ -150,7 +152,7 @@ def _call_args(body: str, fn: str) -> list[str] | None: def extract_wrappers(mdb_src: str | Path) -> dict[str, str]: """Return ``{wrapper_name: body_text}`` for every PG wrapper under ``mdb_src``.""" out: dict[str, str] = {} - for cf in Path(mdb_src).rglob("*.c"): + for cf in built_files(mdb_src, "*.c"): text = cf.read_text(errors="ignore") for m in _WRAP.finditer(text): out[m.group("name")] = _body(text, m.end() - 1) @@ -161,7 +163,7 @@ def extract_helpers(mdb_src: str | Path) -> dict[str, tuple[str, list[str]]]: """``{helper_name: (body_text, [param names after the call info])}`` for every shared wrapper helper under ``mdb_src``.""" out: dict[str, tuple[str, list[str]]] = {} - for cf in Path(mdb_src).rglob("*.c"): + for cf in built_files(mdb_src, "*.c"): text = cf.read_text(errors="ignore") for m in _HELPER.finditer(text): rest = (m.group("rest") or "").strip() diff --git a/parser/compositions.py b/parser/compositions.py index 22831af..0516887 100644 --- a/parser/compositions.py +++ b/parser/compositions.py @@ -43,6 +43,7 @@ import re from itertools import permutations +from parser.families import built_files from parser.sqlfn import _c_base, _fits, _sql_ctypes, sql_signature, sql_statements from parser.typescope import SQL_ALIASES @@ -67,6 +68,11 @@ class _Misfit(Exception): """A body outside the form of a composition.""" +class _Unformed(Exception): + """A call reaching a body that is no composition: one composed over functions + MobilityDB does not declare, or a program outside the form a composition takes.""" + + class _Parser: """The expressions of a composition body: `$k`, a literal, `f(e, ...)`, `e::T` and a row column `r.c`, in a `SELECT e, ... [FROM f(...) AS r]`.""" @@ -166,6 +172,13 @@ def __init__(self, idl, stmts, vocab, composites, text_casts): # whose signatures the catalog carries, read from the same statements. self.declared = {stmt[0].lower() for stmt in stmts} | set(self.by_sig) self.done, self.active = {}, set() + # The signatures whose bodies are no composition, directly (stable-1.3's `rotate`, + # a program over `cos` and `-sin`) or through a call of such a body (`rotateZ`). + self.unformed = set() + # The entry of each plain forward: no composition of its own, but a body calling it + # composes through it (stable-1.3's `translate(t, x, y)` through `translate(t, x, + # y, 0)`, a forward to `affine` binding nine of its arguments). + self.forwards = {} def foreign(self, node): """Whether expression `node` calls a function MobilityDB does not declare (a @@ -264,6 +277,9 @@ def compose(self, sig, body): self.active.add(key) try: entry = self._compose(sig, body) + except _Unformed: + self.unformed.add(key) + entry = None finally: self.active.discard(key) self.done[key] = entry @@ -315,6 +331,12 @@ def call(self, node, sig): return (self.params(func, csig, ops), func["name"], csig["ret"], csig.get("columns"), None) inner = self.composition(name, types) + if inner is None: + fits = [sig for sig, _ in self.bodies.get(name.lower(), ()) if self._fits(sig, types)] + key = (fits[0]["sqlName"].lower(), tuple(fits[0]["args"])) if len(fits) == 1 else None + if key in self.unformed: + raise _Unformed + inner = self.forwards.get(key) if inner is None: raise _Misfit(f"{name}({', '.join(t or '?' for t in types)}) resolves to " f"neither a C-backed signature nor a composition") @@ -341,12 +363,15 @@ def call(self, node, sig): return merged, inner.get("call"), inner["ret"], inner.get("columns"), restore def _compose(self, sig, body): + # A body that is no composition raises #_Unformed, which #compose records, so that + # a body calling it is no composition either. try: items, source, alias = _Parser(body).statement() except _Misfit: - return None # a program, not a form + raise _Unformed # a program, not a form if any(self.foreign(n) for n in items + ([source] if source else [])): - return None # composed over PostGIS + raise _Unformed # composed over PostGIS + key = (sig["sqlName"].lower(), tuple(sig["args"])) restore = None if source is not None: # SELECT r.a, [r.b, ...] at($k, time(r.c)) FROM f(...) AS r @@ -382,9 +407,13 @@ def _compose(self, sig, body): try: if self.function(top[1], [self.operand(a, sig)["type"] for a in top[2]]) is not None: + ops, fn, _, _, restore = self.call(top, sig) + self.forwards[key] = self._entry(sig, ops, fn, restore) return None except _Misfit: - return None + # a forward stating no call of a C function (`affine`, whose + # wrapper packs twelve floats into one AFFINE argument) + raise _Unformed ops, fn, cret, _, restore = self.call(top, sig) elif top[0] in ("arg", "cast"): ops, fn = [self.operand(top, sig)], None @@ -395,12 +424,19 @@ def _compose(self, sig, body): return None # A call reaching a composition takes its casts, so it converts as well. if not (any(o.get("casts") for o in ops) or restore): + self.forwards[key] = self._entry(sig, ops, fn, restore) return None # a plain forward used = {o["arg"] for o in ops if "arg" in o} | ({restore["of"]} if restore else set()) if used != set(range(len(sig["args"]))): raise _Misfit(f"arguments {sorted(set(range(len(sig['args']))) - used)} unused") if restore is None and _type(cret or "") != _type(sig["ret"] or ""): raise _Misfit(f"answers {cret} where the function returns {sig['ret']}") + return self._entry(sig, ops, fn, restore) + + @staticmethod + def _entry(sig, ops, fn, restore): + """The composition entry of `sig` calling `fn` over `ops`, as #_compose returns + it and keeps it for a plain forward.""" entry = {k: sig[k] for k in ("sqlName", "args", "required", "argDefaults", "ret")} if sig.get("retSet"): entry["retSet"] = True @@ -462,10 +498,9 @@ def _text_casts(sql_src): """{(source type, target type): cast function SQL name} of every `CREATE CAST ... WITH FUNCTION f(source)` under `sql_src`; a cast whose function takes more than the source (a typmod) is left out.""" - from pathlib import Path from parser.sqlfn import _strip_sql_comments out = {} - for sf in sorted(Path(sql_src).rglob("*.sql")): + for sf in sorted(built_files(sql_src, "*.sql")): text = _SCHEMA.sub("", _strip_sql_comments(sf.read_text(errors="ignore"))) for src, dst, fn, fargs in _CAST.findall(text): if len([a for a in fargs.split(",") if a.strip()]) == 1: diff --git a/parser/families.py b/parser/families.py index e33155e..0723cf0 100644 --- a/parser/families.py +++ b/parser/families.py @@ -38,16 +38,35 @@ class FamiliesUnavailable(RuntimeError): """ +# A family stated as its own option, as a MobilityDB release line before the +# `ALL` list states each one: `option(CBUFFER` on a line of its own, or +# `CMAKE_DEPENDENT_OPTION(RGEO` for a family that needs another one. +_OPTION_RE = re.compile(r"^(?:option|CMAKE_DEPENDENT_OPTION)\(\s*([A-Z][A-Z0-9_]*)\b", re.M) + + def read_all_families(cmakelists: Path) -> tuple[str, ...]: - """The family tokens named by ``cmakelists``'s ``if(ALL) foreach`` list.""" + """The family tokens of ``cmakelists``: its ``if(ALL) foreach`` list, or, in a + tree that has none, each ``option()`` whose ``meos/src/`` + directory holds the family's sources. + + A MobilityDB release line older than the ``ALL`` list (stable-1.3) states + every family as an option of its own. Its options also name what is no + family -- ``MEOS``, ``GEOS`` -- and the source directory a family lives in, + spelled from the token as #subdir_family spells it, tells the two apart, + read from the tree rather than listed here.""" text = Path(cmakelists).read_text(errors="ignore") m = _ALL_BLOCK_RE.search(text) - if not m: + if m: + return tuple(sorted(re.findall(r"[A-Z][A-Z0-9_]*", m.group(1)))) + src = Path(cmakelists).parent / "meos" / "src" + stated = [t for t in _OPTION_RE.findall(text) if (src / t.lower()).is_dir()] + if not stated: raise FamiliesUnavailable( - f"{cmakelists} carries no `if(ALL) foreach(_family ...)` list — " - "the family source of truth moved; update parser/families.py to " - "read it where it now lives") - return tuple(sorted(re.findall(r"[A-Z][A-Z0-9_]*", m.group(1)))) + f"{cmakelists} carries no `if(ALL) foreach(_family ...)` list and no " + "`option()` with a meos/src/ directory — the family " + "source of truth moved; update parser/families.py to read it where " + "it now lives") + return tuple(sorted(set(stated))) _headers_dir: Path | None = None @@ -117,3 +136,33 @@ def subdir_family() -> dict: def header_family() -> dict: """Top-level public header name -> family token.""" return {f"meos_{f.lower()}.h": f for f in all_families()} + + +def unbuilt_families() -> frozenset: + """The families the header tree being parsed was built without. + + A libmeos built without a family installs no public header of it: the stable-1.3 + default build installs ``meos_npoint.h`` but not ``meos_cbuffer.h``. A family is + unbuilt when the checkout has its header, by #header_family, and the parsed tree + does not, so the source tree itself and a build of every family leave none out, and + a family fronted by no header of its own is never left out. + """ + root = find_mobilitydb_root() + if root is None or _headers_dir is None: + return frozenset() + source = root / "meos" / "include" + if source.resolve() == _headers_dir.resolve(): + return frozenset() + return frozenset(f for h, f in header_family().items() + if (source / h).exists() and not (_headers_dir / h).exists()) + + +def built_files(root, pattern: str): + """The files under ``root`` matching ``pattern``, in ``rglob`` order, less those + inside the directory of an #unbuilt_families family (``mobilitydb/sql/cbuffer``, + ``mobilitydb/src/cbuffer``): the SQL and the wrappers of a family the parsed headers + do not carry state signatures no catalog function has.""" + root = Path(root) + skip = {f.lower() for f in unbuilt_families()} + return [p for p in root.rglob(pattern) + if not skip & set(p.relative_to(root).parts[:-1])] diff --git a/parser/indexsearch.py b/parser/indexsearch.py index 557bc2f..095ed79 100644 --- a/parser/indexsearch.py +++ b/parser/indexsearch.py @@ -30,6 +30,7 @@ import re from pathlib import Path +from parser.families import built_files from parser.sqlfn import _strip_sql_comments # `CREATE OPERATOR (` opens an operator declaration; its clauses run to the `)` the @@ -54,7 +55,7 @@ def operator_decls(sql_src: str | Path) -> dict[tuple[str, str, str], list[tuple sql_src = Path(sql_src) if not sql_src.exists(): return decls - for sf in sorted(sql_src.rglob("*.sql")): + for sf in sorted(built_files(sql_src, "*.sql")): text = _strip_sql_comments(sf.read_text(errors="ignore")) for m in _CREATE_OP.finditer(text): end = text.find(";", m.end()) diff --git a/parser/object_model.py b/parser/object_model.py index 32e49e7..ae604c0 100644 --- a/parser/object_model.py +++ b/parser/object_model.py @@ -27,7 +27,8 @@ class is invented; a function with no prefix match is recorded honestly as import re from pathlib import Path -from parser.typerelations import locate_catalog, temptype_basetypes +from parser.typerelations import (MEOSTYPE_PARAM, locate_catalog, temptype_basetypes, + type_names) def find_mobilitydb_src(headers_dir: Path | None = None) -> Path | None: @@ -373,7 +374,7 @@ class MembershipUnavailable(RuntimeError): def _predicate_body(cat_src: str, name: str) -> str: """The body of the ``name(MeosType ...)`` membership predicate.""" - m = re.search(r"\n" + re.escape(name) + r"\(MeosType \w+\)\s*", cat_src) + m = re.search(r"\n" + re.escape(name) + r"\(" + MEOSTYPE_PARAM + r" \w+\)\s*", cat_src) if not m: raise MembershipUnavailable( f"meos_catalog.c declares no `{name}` predicate — the lattice names " @@ -428,6 +429,22 @@ def predicate_temptypes(cat_src: str, name: str) -> list: return out +def release_nodes(nodes: dict, cat_src: str) -> dict: + """The nodes of the model the release line of ``cat_src`` has. + + The model names every type MEOS has on master; a release line older than a type + (stable-1.3 has no tjsonb or tbigint) does not list it in ``MEOS_TYPE_NAMES``, so the + leaf modelling it is not a class of that line. A leaf whose type the line lists but + does not relate is a disagreement, which #derive_membership raises; a catalog whose + type names do not parse keeps every node, so that the derivation reports it. + """ + named = type_names(cat_src) + if not named: + return nodes + return {k: v for k, v in nodes.items() + if v.get("kind") != "leaf" or all(t in named for t in v.get("temptypes") or ())} + + def derive_membership(nodes: dict, cat_src: str, basetypes: dict) -> None: """Fill each node's membership from the catalog, in place. @@ -531,6 +548,7 @@ def attach_object_model(idl: dict, path: Path, if catalog is not None: cat_src = catalog.read_text(errors="ignore") basetypes = temptype_basetypes(cat_src) + lattice_nodes = release_nodes(lattice_nodes, cat_src) derive_membership(lattice_nodes, cat_src, basetypes) derive_membership(trait_nodes, cat_src, basetypes) membership = {"status": "derived", "source": str(catalog)} diff --git a/parser/portable.py b/parser/portable.py index 1757328..52d92b4 100644 --- a/parser/portable.py +++ b/parser/portable.py @@ -72,9 +72,12 @@ def attach_position_names(idl: dict) -> dict: the name less its stem. Adds ``portableAliases.positionNames``: operator -> class -> SQL name. - A name that does not end with its operator's stem, or an operator that no - function carries, raises: either means the @sqlfn/@sqlop tags and the mapping - disagree, which a binding would otherwise inherit silently. + A release line spells the names one way throughout: master's ```` + (spanLeft), or the ``_`` of a release before the class names + (span_left, temporal_left on stable-1.3). The spelling of the catalog is the one + its names take; a name in neither form, or in the other one, raises, as does an + operator that no function carries: each means the @sqlfn/@sqlop tags and the + mapping disagree, which a binding would otherwise inherit silently. MUST run AFTER ``attach_sqlfn_map`` (sqlfn/sqlop) AND ``attach_portable_aliases`` (byPositionOperator). @@ -84,17 +87,28 @@ def attach_position_names(idl: dict) -> dict: return idl by_position_operator = pa["byPositionOperator"] names = {op: {} for op in by_position_operator} + spelled = {} # spelling -> the first function taking it for f in idl.get("functions", []): op = (f.get("sqlop") or "").replace("\\", "") sqlfn = f.get("sqlfn") or "" if op not in by_position_operator or not sqlfn: continue pos = by_position_operator[op] - suffix = pos[0].upper() + pos[1:] - cls = sqlfn[:-len(suffix)] if sqlfn.endswith(suffix) else "" + cls = "" + for spelling, suffix in (("", pos[0].upper() + pos[1:]), + ("_", "_" + pos)): + if sqlfn.endswith(suffix) and len(sqlfn) > len(suffix): + cls = sqlfn[:-len(suffix)] + break if not cls: raise ValueError(f"portable-aliases: {f['name']} has @sqlop {op} and " - f"@sqlfn {sqlfn}, which is not {suffix}") + f"@sqlfn {sqlfn}, which is not " + f"{pos[0].upper() + pos[1:]} or _{pos}") + spelled.setdefault(spelling, f["name"]) + if len(spelled) > 1: + other = next(s for s in spelled if s != spelling) + raise ValueError(f"portable-aliases: {f['name']} spells @sqlfn {sqlfn} as " + f"{spelling}, {spelled[other]} as {other}") names[op][cls] = sqlfn missing = sorted(op for op, by_class in names.items() if not by_class) if missing: diff --git a/parser/sqlfn.py b/parser/sqlfn.py index c6a3055..2c90830 100644 --- a/parser/sqlfn.py +++ b/parser/sqlfn.py @@ -18,6 +18,7 @@ import re from pathlib import Path +from parser.families import built_files from parser.shapeinfer import _out_count_param from itertools import permutations @@ -304,7 +305,7 @@ def _wrapper_sql_strict(sql_src): sql_src = Path(sql_src) if not sql_src.exists(): return out - for sf in sorted(sql_src.rglob("*.sql")): + for sf in sorted(built_files(sql_src, "*.sql")): text = _strip_sql_comments(sf.read_text(errors="ignore")) for sqlname, argdecls, ret, wrapper, retset, strict in _create_fn_stmts(text, strict=True): @@ -325,7 +326,7 @@ def sql_statements(sql_src): sql_src = Path(sql_src) if not sql_src.exists(): return stmts, vocab, composites - for sf in sorted(sql_src.rglob("*.sql")): + for sf in sorted(built_files(sql_src, "*.sql")): text = _strip_sql_comments(sf.read_text(errors="ignore")) composites.update(_composite_types(text)) for stmt in _create_fn_stmts(text, bodies=True): @@ -426,7 +427,7 @@ def _mdb_to_sql(mdb_src): than the first — otherwise the mapped SQL name is order-dependent. """ out = {} - for cf in Path(mdb_src).rglob("*.c"): + for cf in built_files(mdb_src, "*.c"): text = cf.read_text(errors="ignore") for m in _SQLFN.finditer(text): # @sqlop lives in the SAME doxygen block (before the closing */). @@ -455,7 +456,7 @@ def _mdb_to_agg(mdb_src): wrapper resolves to both without either name displacing the other. A member shared by several aggregates lists them, and duplicates collapse.""" out = {} - for cf in Path(mdb_src).rglob("*.c"): + for cf in built_files(mdb_src, "*.c"): text = cf.read_text(errors="ignore") for m in _SQLAGGFN.finditer(text): close = text.find("*/", m.end()) diff --git a/parser/temporaltypes.py b/parser/temporaltypes.py index 8ebdde7..334812d 100644 --- a/parser/temporaltypes.py +++ b/parser/temporaltypes.py @@ -38,12 +38,14 @@ #: registry covers, so a type absent from it is absent from the registry. UNIVERSE = "temporal_type" -#: The class of each temporal type, one boolean per predicate that decides it. -#: A renamed predicate raises rather than silently emitting every type as false. +#: The class of each temporal type, one boolean per predicate that decides it, under +#: each name a release line gives the predicate, the current one first: stable-1.3 +#: names the linear one `temptype_continuous`. A predicate defined under none of its +#: names raises rather than silently emitting every type as false. CLASSES = { - "number": "tnumber_type", - "spatial": "tspatial_type", - "linear": "temptype_supports_linear", + "number": ("tnumber_type",), + "spatial": ("tspatial_type",), + "linear": ("temptype_supports_linear", "temptype_continuous"), } _ENUM_RE = re.compile(r"\bT_[A-Z0-9_]+\b") @@ -126,6 +128,15 @@ def function_body(text: str, name: str) -> str: raise ValueError(f"{name}() has an unterminated body") +def defined_name(text: str, names: tuple) -> str: + """The first of a predicate's ``names`` that ``text`` defines, as #function_body + anchors a definition, else the current name, so the refusal names that one.""" + for name in names: + if re.search(r"(?m)^" + re.escape(name) + r"\s*\(", text): + return name + return names[0] + + def predicate_types(text: str, name: str) -> set: """The type enumerators the named boolean predicate answers true for.""" found = set(_ENUM_RE.findall(function_body(text, name))) @@ -172,13 +183,17 @@ def attach_temporal_types(idl: dict, src_root: Path | None) -> dict: if catalog_path is None or type_out_path is None: return idl - catalog = strip_comments(catalog_path.read_text(errors="ignore")) + raw_catalog = catalog_path.read_text(errors="ignore") + catalog = strip_comments(raw_catalog) type_out = strip_comments(type_out_path.read_text(errors="ignore")) names = type_names(catalog) - rows = dict(catalog_rows(catalog, "MEOS_RELTYPE_CATALOG")) + # read as #attach_type_relations reads them: a release line's pair table names its + # relation in its header comment + rows = dict(catalog_rows(re.sub(r"//.*", "", raw_catalog), "MEOS_RELTYPE_CATALOG")) universe = predicate_types(catalog, UNIVERSE) - classes = {role: predicate_types(catalog, fn) for role, fn in CLASSES.items()} + classes = {role: predicate_types(catalog, defined_name(catalog, fns)) + for role, fns in CLASSES.items()} tokens = mfjson_tokens(type_out) registry = {} diff --git a/parser/typerelations.py b/parser/typerelations.py index d0976e8..2df8fcf 100644 --- a/parser/typerelations.py +++ b/parser/typerelations.py @@ -28,6 +28,16 @@ _NAME_RE = re.compile(r'\[\s*(T_\w+)\s*\]\s*=\s*"([^"]+)"') _ROW_RE = re.compile(r'\[\s*(T_\w+)\s*\]\s*=\s*\{(.*?)\}', re.S) _FIELD_RE = re.compile(r'\.\s*(\w+)\s*=\s*(T_\w+)') +# A release line older than the type-indexed catalog (stable-1.3) states each relation as +# its own table of {outer, inner} pairs headed by the relation's two names, +# `/* settype basetype */`, which together are the field the type-indexed entry names. +_PAIR_TABLE_RE = re.compile(r'_CATALOG\s*\[\]\s*=\s*\{\s*/\*\s*(\w+)\s+(\w+)\s*\*/(.*?)\n\};', + re.S) +_PAIR_RE = re.compile(r'\{\s*(T_\w+)\s*,\s*(T_\w+)\s*\}') + +#: The spelling of the parameter type of a membership predicate, `temporal_type(MeosType +#: type)`: `meosType` on a release line older than the rename (stable-1.3). +MEOSTYPE_PARAM = r'(?:MeosType|meosType)' #: The relation field of ``reltype_catalog_struct`` naming each role, and the field naming #: its inverse. The catalog records both directions — a relation and its inverse are read @@ -50,12 +60,23 @@ def catalog_rows(text: str, array: str) -> list: """The ``[T_X] = { .field = T_Y, ... }`` entries of the type-indexed catalog array. Returned in file order, which is ``MeosType`` order, the ordering the catalog enforces. + A release line without the type-indexed ``MEOS_RELTYPE_CATALOG`` states the same + relations as pair tables, which yield the same entries, ordered by ``MEOS_TYPE_NAMES``, + the array that lists every type in ``MeosType`` order there. A pair table names its + relation in its header comment, so ``text`` keeps its block comments. """ m = re.search(re.escape(array) + r'\s*\[\]\s*=\s*\{(.*?)\};', text, re.S) - if not m: + if m: + return [(t, dict((f, v) for f, v in _FIELD_RE.findall(body))) + for t, body in _ROW_RE.findall(m.group(1))] + if array != "MEOS_RELTYPE_CATALOG": return [] - return [(t, dict((f, v) for f, v in _FIELD_RE.findall(body))) - for t, body in _ROW_RE.findall(m.group(1))] + rows = {} + for outer_role, inner_role, pairs in _PAIR_TABLE_RE.findall(text): + for outer, inner in _PAIR_RE.findall(pairs): + rows.setdefault(outer, {})["%s_%s" % (outer_role, inner_role)] = inner + order = {t: i for i, t in enumerate(type_names(text))} + return sorted(rows.items(), key=lambda row: order.get(row[0], len(order))) def temptype_basetypes(cat_src: str) -> dict: diff --git a/parser/typescope.py b/parser/typescope.py index 16107e2..6f7f645 100644 --- a/parser/typescope.py +++ b/parser/typescope.py @@ -33,6 +33,8 @@ import re from pathlib import Path +from parser.typerelations import MEOSTYPE_PARAM, catalog_rows + # A declared scope of "*" means the function serves every overload its wrapper # declares — the legitimate generic case, stated rather than guessed. EVERY_OVERLOAD = '*' @@ -40,7 +42,7 @@ _META = Path(__file__).resolve().parent.parent / 'meta' / 'type-scope.json' _TYPE_NAME = re.compile(r'\[(T_[A-Z0-9_]+)\]\s*=\s*"([a-z0-9_]+)"') -_PREDICATE = re.compile(r'^(\w+)\(MeosType type\)\n\{\n(.*?)\n\}', re.S | re.M) +_PREDICATE = re.compile(r'^(\w+)\(' + MEOSTYPE_PARAM + r' type\)\n\{\n(.*?)\n\}', re.S | re.M) _MEOS_TYPE = re.compile(r'\bT_[A-Z0-9_]+\b') _VALIDATE = re.compile(r'\bVALIDATE_[A-Z0-9_]+\b') _C_PARAM = re.compile(r'\b(?:const\s+)?(\w+)\s*\*?\s*\w+\s*[,)]') @@ -91,9 +93,9 @@ def __init__(self, meos_src: str | Path): if members: self.klass[m.group(1)] = members self.container = {} - for field in _RELATIONS: - pat = rf'\[(T_[A-Z0-9_]+)\]\s*=\s*\{{[^}}]*\.{field}\s*=\s*(T_[A-Z0-9_]+)' - for outer, inner in re.findall(pat, src): + for outer, fields in catalog_rows(src, 'MEOS_RELTYPE_CATALOG'): + for field in _RELATIONS: + inner = fields.get(field) if outer in self.name and inner in self.name: self.container.setdefault(self.name[inner], set()).add(self.name[outer]) self.validate = self._read_validate_macros(Path(meos_src) / 'include') diff --git a/tests/test_compositions.py b/tests/test_compositions.py index 7eb79b0..a9640fd 100644 --- a/tests/test_compositions.py +++ b/tests/test_compositions.py @@ -292,6 +292,68 @@ def test_bodies_that_convert_nothing_are_no_composition(self): self.assertNotIn(key, entries) self.assertEqual(len(entries), 10) + def test_a_call_of_a_body_that_is_no_composition_is_none(self): + # #test_bodies_that_convert_nothing_are_no_composition, one call away: stable-1.3's + # rotate is a program over cos and -sin, so rotateZ calling it composes nothing, + # as a call of a PostGIS forward composes nothing. + idl, _ = self._attach(CASTS + """ +CREATE FUNCTION rotate(tgeompoint, float) + RETURNS tgeompoint + AS 'SELECT @extschema@.affine($1, cos($2), -sin($2))' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION rotateZ(tpose, float) + RETURNS tgeompoint + AS 'SELECT @extschema@.rotate($1::@extschema@.tgeompoint, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION asText(tpose, integer) + RETURNS text + AS 'SELECT @extschema@.asEWKT($1::@extschema@.tgeompoint, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION asEWKT(tgeompoint, integer) + RETURNS text + AS 'SELECT @extschema@.ST_AsEWKT($1, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +""") + self.assertEqual(idl["compositions"], []) + + def test_a_call_of_a_plain_forward_composes_through_it(self): + # #test_a_forward_to_a_composition_takes_its_steps, for a forward to a C-backed + # signature: stops(tgeompoint, interval) binds maxdist, and a body calling it + # reaches temporal_stops with that literal. + idl, _ = self._attach(COMPOSED + """ +CREATE FUNCTION stops(tpose, interval) + RETURNS tgeompoint + AS 'SELECT @extschema@.stops($1::@extschema@.tgeompoint, $2)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +""") + e = {(e["sqlName"], tuple(e["args"])): e + for e in idl["compositions"]}[("stops", ("tpose", "interval"))] + self.assertEqual(e["call"], "temporal_stops") + self.assertEqual(e["operands"], [ + {"arg": 0, "casts": ["tpose_to_tpoint"], "param": "temp"}, + {"value": "0.0", "param": "maxdist"}, {"arg": 1, "param": "minduration"}]) + + def test_a_call_of_a_forward_stating_no_c_call_is_none(self): + # stable-1.3's translate(t, x, y, z) forwards to affine, whose wrapper packs + # twelve floats into the one AFFINE argument tgeo_affine takes: no call of the C + # function, so translate(t, x, y) calling it composes nothing either. + idl = _idl() + idl["functions"].append(_fn("tgeo_affine", "affine", [("temp", T), ("a", "const AFFINE *")], + [{"args": ["tgeompoint", "float", "float", "float"], + "ret": "tgeompoint"}])) + (self.sql / "composed.in.sql").write_text(CASTS + """ +CREATE FUNCTION translate(tgeompoint, float, float, float) + RETURNS tgeompoint + AS 'SELECT @extschema@.affine($1, $2, $3, $4)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +CREATE FUNCTION translate(tpose, float, float) + RETURNS tgeompoint + AS 'SELECT @extschema@.translate($1::@extschema@.tgeompoint, $2, $3, 0)' + LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE; +""") + idl, _ = attach_compositions(idl, self.sql) + self.assertEqual(idl["compositions"], []) + def test_a_step_no_public_function_takes_stops_the_catalog(self): with self.assertRaises(ValueError) as cm: self._attach(CASTS + """ diff --git a/tests/test_families.py b/tests/test_families.py index dd415c0..caa9edf 100644 --- a/tests/test_families.py +++ b/tests/test_families.py @@ -65,6 +65,28 @@ def test_a_file_without_the_block_raises_rather_than_defaulting(self): with self.assertRaises(FamiliesUnavailable): read_all_families(_write("project(mobilitydb)\n")) + def test_a_release_line_without_the_block_reads_its_family_options(self): + # As #test_reads_the_foreach_of_the_if_all_block, for a stable-1.3 shape: + # each family is an option of its own, and the options with a + # meos/src/ directory are the families, MEOS and GEOS not. A + # family needing another one is a CMAKE_DEPENDENT_OPTION, as RGEO is. + cmake = _write("option(MEOS\n \"the MEOS library\"\n OFF\n)\n" + "option(CBUFFER\n \"circular buffers\"\n OFF\n)\n" + "option(NPOINT\n \"network points\"\n ON\n)\n" + "option(POSE\n \"geoposes\"\n OFF\n)\n" + "CMAKE_DEPENDENT_OPTION(RGEO\n \"rigid geometries\"\n ON \"POSE\"\n" + " OFF\n )\n" + "option(GEOS \"GEOS\" ON)\n") + for family in ("cbuffer", "npoint", "pose", "rgeo"): + (cmake.parent / "meos" / "src" / family).mkdir(parents=True) + self.assertEqual(read_all_families(cmake), ("CBUFFER", "NPOINT", "POSE", "RGEO")) + + def test_options_without_their_source_directories_raise(self): + # As #test_a_file_without_the_block_raises_rather_than_defaulting: an + # option naming no family source is no family, so nothing is read. + with self.assertRaises(FamiliesUnavailable): + read_all_families(_write("option(MEOS\n \"MEOS\"\n OFF\n)\n")) + class RootResolutionTests(unittest.TestCase): """A caller that names only a header tree still locates the checkout. @@ -107,6 +129,52 @@ def test_headers_staged_away_from_a_checkout_do_not_answer_for_one(self): self.assertNotEqual(find_mobilitydb_root(), staged.parent.parent) +class BuiltFamiliesTests(RootResolutionTests): + """#unbuilt_families and #built_files over a checkout whose families front a header + each, as #test_the_root_is_found_two_levels_above_the_headers builds one.""" + + def _checkout(self): + root = Path(tempfile.mkdtemp()) + (root / "CMakeLists.txt").write_text(_CMAKE) + (root / "meos" / "include").mkdir(parents=True) + for family in ("cbuffer", "pose"): + (root / "meos" / "include" / f"meos_{family}.h").write_text("") + for d in ("sql/temporal", "sql/cbuffer", "sql/pose"): + (root / "mobilitydb" / d).mkdir(parents=True) + (root / "mobilitydb" / d / "x.sql").write_text("") + os.environ["MDB_SRC_ROOT"] = str(root) + return root + + def _built(self, root): + sql = root / "mobilitydb" / "sql" + return sorted(str(p.relative_to(sql)) for p in families.built_files(sql, "*.sql")) + + def test_a_build_without_a_family_leaves_out_its_sql(self): + # The installed headers of a build with pose and without cbuffer. + root = self._checkout() + prefix = Path(tempfile.mkdtemp()) / "include" + prefix.mkdir() + (prefix / "meos_pose.h").write_text("") + use_headers_dir(prefix) + self.assertEqual(families.unbuilt_families(), {"CBUFFER"}) + self.assertEqual(self._built(root), ["pose/x.sql", "temporal/x.sql"]) + + def test_the_source_headers_leave_out_no_family(self): + root = self._checkout() + use_headers_dir(root / "meos" / "include") + self.assertEqual(families.unbuilt_families(), frozenset()) + self.assertEqual(self._built(root), ["cbuffer/x.sql", "pose/x.sql", "temporal/x.sql"]) + + def test_a_family_with_no_header_of_its_own_is_never_left_out(self): + # RGEO, RASTER and S2CELL front no header in this checkout, so a prefix lacking + # theirs says nothing about whether they were built. + self._checkout() + prefix = Path(tempfile.mkdtemp()) / "include" + prefix.mkdir() + use_headers_dir(prefix) + self.assertEqual(families.unbuilt_families(), {"CBUFFER", "POSE"}) + + class DerivedMapTests(unittest.TestCase): @unittest.skipUnless(find_mobilitydb_root(), "MobilityDB checkout not reachable") def test_subdir_and_header_are_spelled_from_the_token(self): diff --git a/tests/test_object_model.py b/tests/test_object_model.py index db2d9ce..f373aad 100644 --- a/tests/test_object_model.py +++ b/tests/test_object_model.py @@ -21,7 +21,8 @@ from parser.typerelations import bbox_types, temptype_basetypes from parser.object_model import (MembershipUnavailable, byreference_basetypes, - derive_membership, predicate_temptypes) + derive_membership, predicate_temptypes, + release_nodes) from parser.object_model import ( _scan_errors, attach_object_model, find_mobilitydb_src) @@ -512,6 +513,30 @@ def test_a_leaf_whose_type_the_catalog_does_not_relate_raises(self): derive_membership(nodes, _SYNTHETIC_CATALOG, temptype_basetypes(_SYNTHETIC_CATALOG)) + def test_a_leaf_of_a_type_the_release_line_lacks_is_not_a_class_of_it(self): + # #test_a_leaf_whose_type_the_catalog_does_not_relate_raises on a release line + # whose MEOS_TYPE_NAMES does not list the type at all (stable-1.3 has no tjsonb): + # the leaf is dropped, while one the line lists but does not relate still raises. + names = ('static const char *MEOS_TYPE_NAMES[] =\n{\n' + ' [T_TBOOL] = "tbool",\n [T_TGHOST] = "tghost",\n};\n') + nodes = { + "Demo": {"kind": "root", "predicate": "demo_type"}, + "TBool": {"kind": "leaf", "predicate": None, "temptypes": ["T_TBOOL"]}, + "TJsonb": {"kind": "leaf", "predicate": None, "temptypes": ["T_TJSONB"]}, + } + kept = release_nodes(nodes, names + _SYNTHETIC_CATALOG) + self.assertEqual(sorted(kept), ["Demo", "TBool"]) + ghost = {"TGhost": {"kind": "leaf", "predicate": None, "temptypes": ["T_TGHOST"]}} + kept = release_nodes(ghost, names + _SYNTHETIC_CATALOG) + with self.assertRaises(MembershipUnavailable): + derive_membership(kept, _SYNTHETIC_CATALOG, temptype_basetypes(_SYNTHETIC_CATALOG)) + + def test_a_catalog_whose_type_names_do_not_parse_keeps_every_node(self): + # No MEOS_TYPE_NAMES read is a lost parse, not a release line without types: + # every node stays, and the derivation reports the leaf it cannot relate. + nodes = {"TJsonb": {"kind": "leaf", "predicate": None, "temptypes": ["T_TJSONB"]}} + self.assertEqual(release_nodes(nodes, _SYNTHETIC_CATALOG), nodes) + def test_attaching_without_the_source_says_so_rather_than_emptying(self): # A class naming no type would read the same as one MEOS has no type # for, so the status says which it is — as the error contract does. diff --git a/tests/test_portable.py b/tests/test_portable.py index 74fe778..b6b692b 100644 --- a/tests/test_portable.py +++ b/tests/test_portable.py @@ -239,6 +239,34 @@ def test_position_name_without_its_class_raises(self): with self.assertRaises(ValueError): attach_position_names(idl) + def test_position_names_of_a_release_before_the_class_names(self): + # #test_position_names_by_class on stable-1.3, which spells every name + # _, a temporal operand taking the class temporal; the class + # is the name less _. + fns = [dict(f, sqlfn=f["sqlfn"].replace("stbox", "stbox_", 1).lower()) + for f in _stbox_position_functions()] + [ + {"name": "left_temporal_temporal", "sqlfn": "temporal_left", "sqlop": "<<"}, + {"name": "left_span_span", "sqlfn": "span_left", "sqlop": "<<"}, + ] + idl = attach_portable_aliases({"functions": fns}, MAP) + pn = attach_position_names(idl)["portableAliases"]["positionNames"] + self.assertEqual(pn["<<"], {"span": "span_left", "stbox": "stbox_left", + "temporal": "temporal_left"}) + self.assertEqual(pn["&_ name among + # ones, or the reverse, is a tag the release line does not state. + for fns in (_stbox_position_functions() + [ + {"name": "left_span_span", "sqlfn": "span_left", "sqlop": "<<"}], + [dict(f, sqlfn=f["sqlfn"].replace("stbox", "stbox_", 1).lower()) + for f in _stbox_position_functions()] + [ + {"name": "left_span_span", "sqlfn": "spanLeft", "sqlop": "<<"}]): + idl = attach_portable_aliases({"functions": fns}, MAP) + with self.assertRaisesRegex(ValueError, "spells"): + attach_position_names(idl) + def test_position_operator_without_function_raises(self): idl = attach_portable_aliases( {"functions": _stbox_position_functions(skip=("/&>",))}, MAP) diff --git a/tests/test_temporaltypes.py b/tests/test_temporaltypes.py index 7941c69..a1288f5 100644 --- a/tests/test_temporaltypes.py +++ b/tests/test_temporaltypes.py @@ -17,8 +17,7 @@ sys.path.insert(0, str(ROOT)) from parser.object_model import find_mobilitydb_src -from parser.temporaltypes import (attach_temporal_types, function_body, - mfjson_tokens, strip_comments) +from parser.temporaltypes import attach_temporal_types, function_body, strip_comments from parser.typerelations import locate_temporal_source _CATALOG = """ @@ -147,6 +146,34 @@ def test_a_renamed_predicate_raises(self): with self.assertRaises(ValueError): self._attach(catalog=without) + def test_the_linear_predicate_under_its_release_line_name_decides_the_class(self): + # #test_a_renamed_predicate_raises, for a name a release line gives the predicate: + # stable-1.3 names the linear one temptype_continuous, which decides the same class, + # while a name no release line gives it still raises. + older = _CATALOG.replace("temptype_supports_linear(MeosType type)", + "temptype_continuous(MeosType type)") + self.assertEqual(self._attach(catalog=older), self._attach()) + with self.assertRaises(ValueError): + self._attach(catalog=_CATALOG.replace("temptype_supports_linear(MeosType type)", + "temptype_linear_kind(MeosType type)")) + + def test_pair_tables_give_each_type_its_base(self): + # #test_every_temporal_type_carries_its_base_box_and_classes on a release line that + # states the base of each temporal type in a pair table headed by a comment + # (stable-1.3), and no bounding box: the same bases, and no bbox key. + start = _CATALOG.index("static const reltype_catalog_struct") + end = _CATALOG.index("/* A comment naming") + pairs = _CATALOG[:start] + ( + "static const temptype_catalog_struct MEOS_TEMPTYPE_CATALOG[] =\n{\n" + " /* temptype basetype */\n" + " {T_TDOUBLE2, T_DOUBLE2},\n {T_TFLOAT, T_FLOAT8},\n" + " {T_TGEOMPOINT, T_GEOMETRY},\n {T_TGEOMETRY, T_GEOMETRY},\n};\n" + ) + _CATALOG[end:] + types = self._attach(catalog=pairs) + expected = {name: {k: v for k, v in record.items() if k != "bbox"} + for name, record in self._attach().items()} + self.assertEqual(types, expected) + def test_a_lost_mfjson_switch_raises(self): with self.assertRaises(ValueError): self._attach(type_out="/* nothing here */\n") diff --git a/tests/test_typerelations.py b/tests/test_typerelations.py index 6e81cf2..5a05e5b 100644 --- a/tests/test_typerelations.py +++ b/tests/test_typerelations.py @@ -50,6 +50,36 @@ }; """ +# _FIXTURE as a release line older than MEOS_RELTYPE_CATALOG states it (stable-1.3): a table +# of {outer, inner} pairs per relation, headed by the relation's two names. The temporal +# table lists tgeometry before tgeompoint, against MeosType order. +_PAIR_FIXTURE = _FIXTURE[:_FIXTURE.index("static const reltype_catalog_struct")] + """ +static const settype_catalog_struct MEOS_SETTYPE_CATALOG[] = +{ + /* settype basetype */ + {T_TEXTSET, T_TEXT}, + {T_FLOATSET, T_FLOAT8}, +}; +static const spantype_catalog_struct MEOS_SPANTYPE_CATALOG[] = +{ + /* spantype basetype */ + {T_FLOATSPAN, T_FLOAT8}, +}; +static const spansettype_catalog_struct MEOS_SPANSETTYPE_CATALOG[] = +{ + /* spansettype spantype */ + {T_FLOATSPANSET, T_FLOATSPAN}, +}; +static const temptype_catalog_struct MEOS_TEMPTYPE_CATALOG[] = +{ + /* temptype basetype */ + {T_TTEXT, T_TEXT}, + {T_TGEOMETRY, T_GEOMETRY}, + {T_TGEOMPOINT, T_GEOMETRY}, + {T_TFLOAT, T_FLOAT8}, +}; +""" + class TypeRelationsParseTest(unittest.TestCase): @@ -80,6 +110,14 @@ def test_base_shared_by_several_temporal_types_names_all_of_them(self): by_base = self._attach(_FIXTURE) self.assertEqual(by_base["geometry"]["temporal"], ["tgeompoint", "tgeometry"]) + def test_pair_tables_resolve_as_the_relation_array_does(self): + # #test_full_numeric_base_resolves_all_four_templates over the pair tables of a + # release line older than the relation array: the same registry, the temporal role + # still in MeosType order although the table lists tgeometry first. + self.assertEqual(self._attach(_PAIR_FIXTURE), self._attach(_FIXTURE)) + self.assertEqual(self._attach(_PAIR_FIXTURE)["geometry"]["temporal"], + ["tgeompoint", "tgeometry"]) + def test_catalog_without_the_relation_array_raises(self): # A located catalog the parse reads no relation out of is a lost array, not a # catalog without types: it must raise rather than attach an empty registry that diff --git a/tests/test_typescope.py b/tests/test_typescope.py index c7596c0..ecb6e5d 100644 --- a/tests/test_typescope.py +++ b/tests/test_typescope.py @@ -1,5 +1,6 @@ """A function keeps only the signatures of the types it serves.""" import json +import tempfile import unittest from pathlib import Path @@ -102,6 +103,59 @@ def test_every_cell_grid_names_its_base_type_through_its_c_typedef(self): ({'s2cell', 's2cellset'}, 'cparam')) +_TYPE_NAMES = (' [T_INT4] = "int4",\n [T_INTSET] = "intset",\n' + ' [T_INTSPAN] = "intspan",\n [T_INTSPANSET] = "intspanset",\n') + +# The catalog as master states it: a designated initializer per type, predicates +# over `MeosType`. +_CATALOG_DESIGNATED = (_TYPE_NAMES + + 'static const reltype_catalog_struct MEOS_RELTYPE_CATALOG[] =\n{\n' + ' [T_INTSET] = { .settype_basetype = T_INT4 },\n' + ' [T_INTSPAN] = { .spantype_basetype = T_INT4 },\n' + ' [T_INTSPANSET] = { .spansettype_spantype = T_INTSPAN },\n' + '};\n' + 'bool\nset_type(MeosType type)\n{\n if (type == T_INTSET)\n return true;\n}\n') + +# The catalog as a release line older than the rename states it (stable-1.3): a table +# of {outer, inner} pairs headed by the relation's names, predicates over `meosType`. +_CATALOG_PAIRS = (_TYPE_NAMES + + 'static const settype_catalog_struct MEOS_SETTYPE_CATALOG[] =\n{\n' + ' /* settype basetype */\n {T_INTSET, T_INT4},\n};\n' + 'static const spantype_catalog_struct MEOS_SPANTYPE_CATALOG[] =\n{\n' + ' /* spantype basetype */\n {T_INTSPAN, T_INT4},\n};\n' + 'static const spansettype_catalog_struct MEOS_SPANSETTYPE_CATALOG[] =\n{\n' + ' /* spansettype spantype */\n {T_INTSPANSET, T_INTSPAN},\n};\n' + 'bool\nset_type(meosType type)\n{\n if (type == T_INTSET)\n return true;\n}\n') + + +def facts_from(catalog_text): + """#TypeFacts read from a meos/ tree holding only the given meos_catalog.c.""" + root = Path(tempfile.mkdtemp()) + (root / 'src' / 'temporal').mkdir(parents=True) + (root / 'include').mkdir() + (root / 'src' / 'temporal' / 'meos_catalog.c').write_text(catalog_text) + return TypeFacts(root) + + +class CatalogShapeTests(unittest.TestCase): + """#TypeFacts reads the container relations and the class predicates of either shape of + meos_catalog.c, over synthetic inputs as #CParamScopeTests is built.""" + + def test_both_shapes_read_the_same_containers(self): + expected = {'int4': {'intset', 'intspan'}, 'intspan': {'intspanset'}} + self.assertEqual(facts_from(_CATALOG_DESIGNATED).container, expected) + self.assertEqual(facts_from(_CATALOG_PAIRS).container, expected) + + def test_both_shapes_read_the_class_predicates(self): + self.assertEqual(facts_from(_CATALOG_DESIGNATED).klass, {'set_type': {'intset'}}) + self.assertEqual(facts_from(_CATALOG_PAIRS).klass, {'set_type': {'intset'}}) + + def test_a_pair_table_of_no_known_relation_is_not_read(self): + other = _TYPE_NAMES + ('static const x MEOS_OTHER_CATALOG[] =\n{\n' + ' /* outer unrelated */\n {T_INTSET, T_INT4},\n};\n') + self.assertEqual(facts_from(other).container, {}) + + # `Tsequence_from_base_tstzset` is the body behind tint(integer, tstzset), # tjsonb(jsonb, tstzset) and seventeen more, every one taking the tstzset. FROM_BASE_TSTZSET = [