diff --git a/meta/type-scope.json b/meta/type-scope.json new file mode 100644 index 0000000..77195df --- /dev/null +++ b/meta/type-scope.json @@ -0,0 +1,126 @@ +{ + "$schema": "./type-scope.schema.json", + "description": "The SQL type scope of MEOS functions whose scope MEOS itself does not state. A function sharing a PG wrapper with others receives only the wrapper's signatures that its scope covers; parser/typescope.py derives that scope from a VALIDATE_* macro, MeosType literals, a class predicate or the C parameter types, and every claimant it cannot derive must be declared here. A scope of \"*\" means the function genuinely serves every overload its wrapper declares.", + "scopes": { + "temporal_append_tinstant": { + "types": "*", + "note": "Generic over Temporal *: appends to any temporal type." + }, + "temporal_at_values": { + "types": "*", + "note": "Generic over Temporal *: restricts any temporal type to a value set." + }, + "temporal_minus_values": { + "types": "*", + "note": "Generic over Temporal *: the complement of temporal_at_values." + }, + "temporal_end_instant": { + "types": "*", + "note": "Generic over Temporal *: accessor on any temporal type." + }, + "temporal_start_instant": { + "types": "*", + "note": "Generic over Temporal *: accessor on any temporal type." + }, + "temporal_end_sequence": { + "types": "*", + "note": "Generic over Temporal *: accessor on any temporal type." + }, + "temporal_start_sequence": { + "types": "*", + "note": "Generic over Temporal *: accessor on any temporal type." + }, + "temporal_set_interp": { + "types": "*", + "note": "Generic over Temporal *: sets the interpolation of any temporal type." + }, + "temporal_shift_time": { + "types": "*", + "note": "Generic over Temporal *: shifts the time of any temporal type." + }, + "temporal_shift_scale_time": { + "types": "*", + "note": "Generic over Temporal *: shifts and scales the time of any temporal type." + }, + "spanset_span": { + "types": "*", + "note": "Generic over SpanSet *: returns the bounding span of any spanset type." + }, + "span_lower_inc": { + "types": "*", + "note": "Generic over Span *: reports whether the lower bound of any span type is inclusive." + }, + "span_upper_inc": { + "types": "*", + "note": "Generic over Span *: reports whether the upper bound of any span type is inclusive." + }, + + "trgeometry_at_values": { + "types": ["trgeometry"], + "note": "Serves trgeometry alone. It states no validity check of its own, so the scope cannot be read from the body — see the rgeo restrict functions, which delegate without validating." + }, + "trgeometry_minus_values": { + "types": ["trgeometry"], + "note": "Serves trgeometry alone; states no validity check." + }, + "trgeometry_at_tstzspan": { + "types": ["trgeometry"], + "note": "Serves trgeometry alone; states no validity check." + }, + "trgeometry_at_tstzset": { + "types": ["trgeometry"], + "note": "Serves trgeometry alone; states no validity check." + }, + "trgeometry_at_tstzspanset": { + "types": ["trgeometry"], + "note": "Serves trgeometry alone; states no validity check." + }, + "trgeometry_minus_tstzspan": { + "types": ["trgeometry"], + "note": "Serves trgeometry alone; states no validity check." + }, + "trgeometry_minus_tstzset": { + "types": ["trgeometry"], + "note": "Serves trgeometry alone; states no validity check." + }, + "trgeometry_minus_tstzspanset": { + "types": ["trgeometry"], + "note": "Serves trgeometry alone; states no validity check." + }, + + "tintbox_shift_scale": { + "types": ["tbox"], + "note": "Shifts and scales a tbox. Its body names the value span it moves (T_INTSPAN), so the span is what a body-read derives — the type it serves is the box." + }, + "tbigintbox_shift_scale": { + "types": ["tbox"], + "note": "Shifts and scales a tbox; the body names its value span, not the box." + }, + "tfloatbox_shift_scale": { + "types": ["tbox"], + "note": "Shifts and scales a tbox; the body names its value span, not the box." + }, + + "nad_tint_tbox": { + "types": ["tint"], + "note": "The tint arm of the tnumber/tbox nearest-approach pair." + }, + "nad_tfloat_tbox": { + "types": ["tfloat"], + "note": "The tfloat arm of the tnumber/tbox nearest-approach pair." + }, + "nad_tcbuffer_stbox": { + "types": ["tcbuffer"], + "note": "Serves tcbuffer. Its @csqlfn names NAD_tcbuffer_geo, the wrapper of nad_tcbuffer_geo; the stbox form has its own wrapper NAD_tcbuffer_stbox. Upstream tag fix, recorded here so generation stays deterministic meanwhile." + }, + + "span_to_spanset": { + "types": [], + "note": "Converts span to spanset, while its @csqlfn names Spanset_to_span — the wrapper of the opposite conversion, whose overloads are all spanset-argument. No overload of that wrapper is served, hence the empty scope. Upstream tag fix." + }, + "contained_span_span": { + "types": [], + "note": "Compares span with span, while its @csqlfn names Contained_value_span, whose overloads all take a base value. No overload of that wrapper is served. Upstream tag fix." + } + } +} diff --git a/meta/type-scope.schema.json b/meta/type-scope.schema.json new file mode 100644 index 0000000..b483fdf --- /dev/null +++ b/meta/type-scope.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "type-scope.schema.json", + "title": "MEOS function type scopes", + "description": "Declared SQL type scopes for MEOS functions whose scope MEOS itself does not state. Consumed by parser/typescope.py, which fails generation on any underivable claimant absent from this file.", + "type": "object", + "required": ["scopes"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "description": { "type": "string" }, + "scopes": { + "type": "object", + "description": "Keyed by MEOS function name.", + "additionalProperties": { + "type": "object", + "required": ["types", "note"], + "additionalProperties": false, + "properties": { + "types": { + "description": "The SQL types the function serves: a list of type names, or \"*\" when it serves every overload its wrapper declares. An empty list means it serves none of them, which is what a mistagged @csqlfn looks like.", + "oneOf": [ + { "type": "string", "const": "*" }, + { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "uniqueItems": true + } + ] + }, + "note": { + "type": "string", + "description": "Why the scope is what it is, and why MEOS does not state it.", + "minLength": 1 + } + } + } + } + } +} diff --git a/parser/sqlfn.py b/parser/sqlfn.py index b008ffa..d99521c 100644 --- a/parser/sqlfn.py +++ b/parser/sqlfn.py @@ -17,6 +17,9 @@ import re from pathlib import Path +from parser.typescope import (TypeFacts, declared_scopes, read_bodies, + require_scopes, resolve_scope, signatures_for) + # A @csqlfn tag carries one OR MORE #Wrapper() references — comma- or # space-separated, and possibly continued across doxygen lines — because a single # MEOS function can back several wrappers (the ever/always pair eDisjoint/aDisjoint @@ -285,6 +288,32 @@ def attach_sqlfn_map(idl, meos_src, mdb_src, sql_src=None): w2sig = _wrapper_sql_sigs(sql_src) if sql_src else {} direct = _meos_direct_sql(meos_src) n = 0 + # One wrapper commonly backs a whole per-type family — `Set_values` is the body + # behind getValues(intset), getValues(cbufferset) and fourteen more — so a + # wrapper's signature list is the union over its claimants, not the surface of + # any one of them. Each function therefore keeps only the signatures its own + # TYPE SCOPE covers; scopes MEOS does not state are declared in + # meta/type-scope.json, and an underivable claimant fails generation rather + # than silently taking the union or nothing. + scope_facts = scope_bodies = scope_params = None + declared = {} + shared_wrappers = set() + if w2sig: + meos_root = Path(meos_src).parent + scope_facts = TypeFacts(meos_root) + scope_bodies, scope_params = read_bodies(meos_root) + declared = declared_scopes() + claimed = {} + for f in idl["functions"]: + if f.get("api") != "public": + continue + for w in m2d.get(f["name"]) or (): + claimed.setdefault(w, []).append(f["name"]) + break + shared_wrappers = {w for w, names in claimed.items() + if len(names) > 1 and len(w2sig.get(w) or ()) > 1} + require_scopes([n for w in shared_wrappers for n in claimed[w]], + scope_facts, scope_bodies, scope_params, declared) # 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 # catalog output — every binding reads only the primary `sqlfn` — it is working @@ -320,6 +349,16 @@ def attach_sqlfn_map(idl, meos_src, mdb_src, sql_src=None): # signature instead of the wider C one: args beyond sqlArity are SQL-optional # (DEFAULT), and C params beyond sqlArityMax are C-only out-params. sigs = w2sig.get(wrappers[0]) + # Only a public claimant of a shared wrapper is filtered: those are the + # functions a binding projects, and the ones require_scopes has proven a + # scope for. An internal function is not part of any binding surface. + scoped = False + if sigs and wrappers[0] in shared_wrappers and f.get("api") == "public": + scope, _ = resolve_scope(f["name"], scope_facts, scope_bodies, + scope_params, declared) + if scope is not None: + sigs = signatures_for(f["name"], sigs, scope) + scoped = True if sigs: f["sqlArity"] = min(s["required"] for s in sigs) f["sqlArityMax"] = max(len(s["args"]) for s in sigs) @@ -349,8 +388,14 @@ def attach_sqlfn_map(idl, meos_src, mdb_src, sql_src=None): # attached ONLY for a signature that actually has an optional arg, so a binding # can render the shorter overload of a SQL-optional argument with its omitted # value; default-free signatures stay {args, ret} unchanged. + # Scope filtering already reduced `sigs` to the overloads this function + # serves, and a per-type function's own overload carries its OWN SQL name + # (`bigintset_in`), not the representative the @sqlfn tag names + # (`intset_in`). Dropping a name that differs from `sqlfn` would discard + # exactly the signature the filter just proved belongs here, so a filtered + # function keeps all of them and stamps the name whenever it differs. fam_names = {s["sqlName"] for s in sigs} - multiname = len(fam_names) > 1 + multiname = len(fam_names) > 1 or (scoped and fam_names != {f["sqlfn"]}) own = [] for s in sigs: if not multiname and s["sqlName"] != f["sqlfn"]: diff --git a/parser/typescope.py b/parser/typescope.py new file mode 100644 index 0000000..134f7ab --- /dev/null +++ b/parser/typescope.py @@ -0,0 +1,233 @@ +"""Derive the SQL type scope of a MEOS function. + +One PG wrapper commonly backs a whole per-type family: `Set_values` is the body +behind `getValues(intset)`, `getValues(cbufferset)` and fourteen more, while the +MEOS side spells one typed function per type (`intset_values`, +`cbufferset_values`, ...). Attaching a wrapper's whole signature list to every +MEOS function that names it would tell a binding that `intset_values` serves +cbufferset — so each function's signatures are filtered to the types it actually +serves, and that set is its TYPE SCOPE. + +The scope is read from what MEOS itself states, never from the function's name: + + * its own `VALIDATE_` macro, + * the `MeosType` literals in its body, resolved through `meostype_name`, + * a class predicate it calls (`tnumber_type`, `tspatial_type`, ...), whose + members the catalog lists, + * its C parameter types, resolved through the catalog's base-type relations. + +A function whose scope none of those state is UNDERIVABLE: it is either generic +over every overload or simply unclassified, and the two are indistinguishable +from outside. Rather than guess — one guess keeps a wrong signature list, the +other drops a real registration — such a function must be listed in +`meta/type-scope.json`, and `require_scopes` fails on any that is not. +""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +# A declared scope of "*" means the function serves every overload its wrapper +# declares — the legitimate generic case, stated rather than guessed. +EVERY_OVERLOAD = '*' + +_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) +_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*[,)]') +_FN_OPEN = re.compile(r'^(\w+)\((.*)$') + +# The catalog fields relating a container type to the type it is built over. +_RELATIONS = ('settype_basetype', 'spantype_basetype', 'temptype_basetype', + 'spansettype_spantype') + +# `T_UNKNOWN` is the catalog's placeholder, not a type a function can serve. +_NOT_A_TYPE = {'unknown'} + +# The C spelling of each MEOS base type. `GSERIALIZED` covers both geometry and +# geography, which is why a C parameter of that type widens to the pair. +C_BASE_TYPES = { + 'int': 'int4', 'int32': 'int4', 'int64': 'int8', 'double': 'float8', + 'bool': 'bool', 'text': 'text', 'DateADT': 'date', + 'TimestampTz': 'timestamptz', + 'GSERIALIZED': ('geometry', 'geography'), +} + +# PostgreSQL's spelling of the MEOS base types: a SQL signature says `integer` +# where `meostype_name` says `int4`, so a scope must be compared in both. +SQL_ALIASES = {'int4': 'integer', 'int8': 'bigint', 'float8': 'float', + 'bool': 'boolean'} + + +def sql_spellings(types): + """Every SQL spelling of a scope, so it can be matched against a signature.""" + return set(types) | {SQL_ALIASES[t] for t in types if t in SQL_ALIASES} + + +class TypeFacts: + """The type vocabulary, class predicates and container relations, read from + MEOS's own catalog rather than restated here.""" + + def __init__(self, meos_src: str | Path): + src = (Path(meos_src) / 'src/temporal/meos_catalog.c').read_text(errors='ignore') + self.name = {e: n for e, n in _TYPE_NAME.findall(src) if n not in _NOT_A_TYPE} + self.names = set(self.name.values()) + self.klass = {} + for m in _PREDICATE.finditer(src): + members = {self.name[t] for t in _MEOS_TYPE.findall(m.group(2)) + if t in self.name} + 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): + 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') + + def _read_validate_macros(self, include_dir: Path) -> dict: + """What each `VALIDATE_*` macro constrains its argument to, read from the + macro's own definition. `VALIDATE_INTSET` names a single type through + `ensure_set_isof_type(s, T_INTSET)`; `VALIDATE_TGEO` names a whole class + through `ensure_tgeo_type_all` — a name-shaped guess would see only the + first kind and miss every class macro.""" + out = {} + for hf in include_dir.rglob('*.h'): + text = hf.read_text(errors='ignore') + for m in re.finditer(r'#\s*define\s+(VALIDATE_[A-Z0-9_]+)\((.*?)\n(?=\s*#|\s*extern|\n)', + text, re.S): + name, body = m.group(1), m.group(2) + types = {self.name[t] for t in _MEOS_TYPE.findall(body) + if t in self.name} + for pred, members in self.klass.items(): + if re.search(rf'\b(?:ensure_)?{re.escape(pred)}\s*\(', body): + types |= members + if types: + out.setdefault(name, set()).update(types) + return out + + def widen(self, types: set[str]) -> set[str]: + """A type plus every container built over it: a function stating + `T_INT8` serves bigint, and the bigintset/bigintspan/tbigint built on + it, transitively.""" + out, queue = set(types), list(types) + while queue: + for c in self.container.get(queue.pop(), ()): + if c not in out: + out.add(c) + queue.append(c) + return out + + +def read_bodies(meos_src: str | Path) -> tuple[dict, dict]: + """Every MEOS function's body text and parameter list, keyed by name.""" + bodies, params = {}, {} + for cf in Path(meos_src, 'src').rglob('*.c'): + cur, buf = None, [] + for line in cf.read_text(errors='ignore').split('\n'): + m = _FN_OPEN.match(line) + if m: + cur, buf = m.group(1), [] + params.setdefault(cur, m.group(2)) + if cur is not None: + buf.append(line) + if line == '}': + bodies.setdefault(cur, '\n'.join(buf)) + cur = None + return bodies, params + + +def scope_of(name: str, facts: TypeFacts, bodies: dict, params: dict, + c_types: dict = C_BASE_TYPES) -> tuple[set | None, str]: + """This function's type scope and the signal that states it, or + ``(None, 'none')`` when MEOS states nothing.""" + body = bodies.get(name) + if body is None: + return None, 'none' + + stated = {t for macro in _VALIDATE.findall(body) + for t in facts.validate.get(macro, ())} + if stated: + return facts.widen(stated), 'validate' + + literals = {facts.name[t] for t in _MEOS_TYPE.findall(body) if t in facts.name} + if literals: + return facts.widen(literals), 'meostype' + + members = {t for pred, types in facts.klass.items() + if re.search(rf'\b(?:ensure_)?{re.escape(pred)}\s*\(', body) + for t in types} + if members: + return members, 'class' + + # A C parameter names a base type (`int64 i` -> int8, `const Cbuffer *cb` -> + # cbuffer), and the catalog says which containers are built over it. + from_params = set() + for token in _C_PARAM.findall(params.get(name, '')): + base = c_types.get(token) + if base is not None: + from_params.update((base,) if isinstance(base, str) else base) + elif token.lower() in facts.names: + from_params.add(token.lower()) + if from_params: + widened = facts.widen(from_params) + if widened - from_params: + return widened, 'cparam' + + return None, 'none' + + +def declared_scopes(path: str | Path = _META) -> dict: + """The scopes stated in `meta/type-scope.json`, keyed by function name.""" + doc = json.loads(Path(path).read_text()) + return {name: entry['types'] for name, entry in doc['scopes'].items()} + + +def resolve_scope(name, facts, bodies, params, declared): + """This function's scope, from MEOS's own signals or from the declared file. + + Returns ``(types, signal)`` where `types` is a set, or ``EVERY_OVERLOAD`` for + a function declared generic, or ``None`` when nothing states it.""" + stated = declared.get(name) + if stated is not None: + return (EVERY_OVERLOAD if stated == EVERY_OVERLOAD else set(stated)), 'declared' + return scope_of(name, facts, bodies, params) + + +def require_scopes(claimants, facts, bodies, params, declared): + """Fail on any claimant of a shared wrapper whose scope nothing states. + + Guessing here is what the whole mechanism exists to avoid: assuming "all" + reinstates the wrong-signature bug, assuming "none" silently drops real + registrations. Both are invisible downstream, so an underivable claimant is + an error the catalog refuses to emit until someone states the answer in + `meta/type-scope.json`.""" + missing = sorted(n for n in claimants + if resolve_scope(n, facts, bodies, params, declared)[0] is None) + if missing: + raise ValueError( + 'type scope underivable for %d function(s); state each in ' + 'meta/type-scope.json: %s' % (len(missing), ', '.join(missing))) + + +def signatures_for(name, sigs, scope): + """The subset of a wrapper's signatures this function serves. + + A signature belongs to the function when the scope covers any type it names — + its arguments or its return — compared in both MEOS and SQL spellings.""" + if scope == EVERY_OVERLOAD: + return list(sigs) + covered = sql_spellings(scope) + kept = [] + for sig in sigs: + named = set(sig.get('args') or ()) + if sig.get('ret'): + named.add(sig['ret']) + if named & covered: + kept.append(sig) + return kept diff --git a/tests/test_typescope.py b/tests/test_typescope.py new file mode 100644 index 0000000..9d6dddc --- /dev/null +++ b/tests/test_typescope.py @@ -0,0 +1,74 @@ +"""A function keeps only the signatures of the types it serves.""" +import json +import unittest +from pathlib import Path + +from parser.typescope import (EVERY_OVERLOAD, SQL_ALIASES, declared_scopes, + signatures_for, sql_spellings) + +META = Path(__file__).resolve().parent.parent / 'meta' / 'type-scope.json' + +# `Set_values` is the body behind getValues(intset), getValues(cbufferset) and +# fourteen more, so its signature list is the union over all of them. +SET_VALUES = [ + {'args': ['intset'], 'ret': 'integer[]', 'sqlName': 'getValues'}, + {'args': ['cbufferset'], 'ret': 'cbuffer[]', 'sqlName': 'getValues'}, + {'args': ['npointset'], 'ret': 'npoint[]', 'sqlName': 'getValues'}, +] + + +class SignatureFilterTests(unittest.TestCase): + + def test_a_typed_function_keeps_only_its_own_overload(self): + kept = signatures_for('intset_values', SET_VALUES, {'intset'}) + self.assertEqual([s['args'] for s in kept], [['intset']]) + + def test_each_sibling_keeps_a_different_overload(self): + for scope, arg in (({'cbufferset'}, 'cbufferset'), ({'npointset'}, 'npointset')): + kept = signatures_for('x', SET_VALUES, scope) + self.assertEqual([s['args'] for s in kept], [[arg]]) + + def test_a_generic_function_keeps_every_overload(self): + kept = signatures_for('temporal_shift_time', SET_VALUES, EVERY_OVERLOAD) + self.assertEqual(len(kept), len(SET_VALUES)) + + def test_a_scope_serving_none_of_them_keeps_none(self): + """What a mistagged @csqlfn looks like: the function names a wrapper + whose overloads it does not serve.""" + self.assertEqual(signatures_for('span_to_spanset', SET_VALUES, {'intspan'}), []) + + def test_the_return_type_also_places_a_signature(self): + """An I/O function is named by what it returns, not by its cstring argument.""" + sigs = [{'args': ['cstring'], 'ret': 'bigintset', 'sqlName': 'bigintset_in'}, + {'args': ['cstring'], 'ret': 'intset', 'sqlName': 'intset_in'}] + kept = signatures_for('bigintset_in', sigs, {'bigintset'}) + self.assertEqual([s['ret'] for s in kept], ['bigintset']) + + def test_a_scope_matches_the_sql_spelling_of_its_types(self): + """SQL says `integer` where meostype_name says `int4`.""" + self.assertEqual(sql_spellings({'int4'}), {'int4', 'integer'}) + sigs = [{'args': ['integer'], 'ret': 'intspan', 'sqlName': 'span'}] + self.assertEqual(len(signatures_for('int_to_span', sigs, {'int4'})), 1) + + def test_every_sql_alias_target_differs_from_its_meos_spelling(self): + for meos, sql in SQL_ALIASES.items(): + self.assertNotEqual(meos, sql) + + +class DeclaredScopeTests(unittest.TestCase): + + def test_the_declared_file_matches_its_schema_shape(self): + doc = json.loads(META.read_text()) + for name, entry in doc['scopes'].items(): + self.assertTrue(entry['note'], f'{name} states no reason') + types = entry['types'] + self.assertTrue(types == EVERY_OVERLOAD or isinstance(types, list), + f'{name} has an unusable scope') + + def test_declared_scopes_reads_every_entry(self): + doc = json.loads(META.read_text()) + self.assertEqual(set(declared_scopes()), set(doc['scopes'])) + + +if __name__ == '__main__': + unittest.main()