Skip to content

Commit a964a6f

Browse files
State in the catalog the size of every string MEOS returns
Every public MEOS function returning a char * takes a size_t * through which it writes the size of the string, its terminating null character included, and the catalog lists that parameter in shape.outParams; the new tests/test_string_size.py refuses a public char * return whose shape.outParams lists no size_t *. The catalog's own readers of a string writer set that size aside as an out-parameter: build_type_encodings leaves it out of the inputs an encoder takes, as _trailing of parser/codecs.py does, and a function returning a string is exposable, its size no wire param and named by size_outparam on its result, which the service reads through the new invoke_string, copying the string for the size MEOS writes rather than scanning it for its terminator. Witness: against MobilityDB master 8b0886986d, which writes the size, the suite parsing its installed headers crashes in test_each_hex_writer_round_trips_through_encode, which calls the text writers without their size, and fails two tests reading typeEncodings, where TimestampTz, Timestamp, TimeADT, DateADT and TimeTzADT write through no function and TSequence is missing; master's parser refuses each of the 140 public functions returning a string with array-or-out-param:size_out. Why: the owner's decision of 2026-11-02 (IO-S2 of the input and output plan, MobilityDB #3353), that no binding scan a string MEOS returns for its terminator. The catalog is where every binding reads which parameter holds that size, so it states it for every string and a test keeps it stated. Measured: over the installed headers of MobilityDB master 3dd98cc90e built with -DALL=ON, the 144 public char * returns all list a size_t * in shape.outParams, and 3641 functions are stateless-exposable, where master's parser states 3360, 140 of them returning a string. Against the catalog of MobilityDB a76d98f21a, before the size was written, the contract test of tests/test_string_size.py fails on 118 functions; the new test of tests/test_enrich.py on build_type_encodings fails without its change. The suite passes 665 tests, the two of tests/test_object_model_parity.py needing PyMEOS skipped.
1 parent ba53955 commit a964a6f

7 files changed

Lines changed: 269 additions & 19 deletions

File tree

‎docs/enrichment.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ literals are in the catalog.
8282
**encoder** — a public `T (+ aux) → char *` (`*_out`, `*_as_text`, `*_as_ewkt`,
8383
`*_as_mfjson`, `*_as_hexwkb`, `*_as_hexewkb`), or `T (+ aux) → uint8_t *` in `wkb` and
8484
`ewkb`. A size the function writes back is an out-parameter, stated in its
85-
`shape.outParams` as for any other function.
85+
`shape.outParams` as for any other function. Every public function returning a
86+
`char *` writes the size of its string, the terminating null character included,
87+
through a `size_t *` its `shape.outParams` lists, so a binding copies the string
88+
with that size rather than scanning it for its terminator
89+
(`tests/test_string_size.py`).
8690
- **A PostgreSQL type passed by value** (`TimestampTz`, `Timestamp`, `TimeADT`,
8791
`DateADT`) is a class like a pointer type, its decoder returning it and its
8892
encoder taking it without a pointer (`timestamptz_in` / `timestamptz_out`).
@@ -229,6 +233,13 @@ Otherwise `exposable` is `false` and `reason` lists the blockers
229233
> maps to *no value* (HTTP 204). This recovers the public `*_value_n`,
230234
> box-bound, and `*_value_at`/geo-accessor families.
231235
236+
> **String results.** A function `char *f(.., size_t *size_out)` whose
237+
> `shape.outParams` lists its trailing `size_t *` writes through it the size
238+
> of the string it returns, the terminating null character included. The
239+
> size is no wire param: the result is the JSON string, with
240+
> `size_outparam` naming the size the string is read for (`tfloat_out`,
241+
> `geo_as_text`, `geom_relate`).
242+
232243
> **Input-array builders.** A builder taking `(Elem **arr, int count)`
233244
> becomes one wire param of `kind: "array"` (element = the serialized
234245
> `Elem`); the `count` is implicit (the JSON array length). This recovers

‎parser/enrich.py‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -263,13 +263,15 @@ def build_type_encodings(functions: list, structs: set,
263263
first arg a char-like string). An *encoder* turns an object into a wire
264264
string (returns ``char *``, first arg ``const struct T *``). Trailing
265265
*formatting* scalars are allowed and defaulted (see ``_aux_specs``); a
266-
non-defaultable trailing arg disqualifies the helper. Only declared
267-
structs qualify, so primitives never register by accident.
266+
non-defaultable trailing arg disqualifies the helper, an out-parameter the
267+
function's ``shape.outParams`` lists, as the ``size_t *size_out`` of a string
268+
writer, being no input. Only declared structs qualify, so primitives never
269+
register by accident.
268270
269271
A name in ``values`` is a type passed by value, as PostgreSQL passes its
270272
``TimestampTz`` and ``DateADT``: its decoder returns it and its encoder takes it
271273
without a pointer (``TimestampTz timestamptz_in(const char *, int32_t)``,
272-
``char *timestamptz_out(TimestampTz)``).
274+
``char *timestamptz_out(TimestampTz, size_t *)``).
273275
"""
274276
enc: dict[str, dict] = {}
275277

@@ -286,7 +288,11 @@ def slot(b: str) -> dict:
286288
p0 = params[0]["canonical"]
287289
rb, rd = _base(ret), _ptr_depth(ret)
288290
pb, pd = _base(p0), _ptr_depth(p0)
289-
aux = _aux_specs(params[1:]) # None => non-defaultable trailing arg
291+
# None => non-defaultable trailing arg; the out-parameters, a string
292+
# writer's size_out among them, are set aside as #_trailing of
293+
# parser/codecs.py sets them aside
294+
out = set((fn.get("shape") or {}).get("outParams") or ())
295+
aux = _aux_specs([p for p in params[1:] if p["name"] not in out])
290296

291297
# Decoder: const char* (+ defaultable scalar aux) -> opaque struct
292298
if (aux is not None and (rd >= 1 or rb in values) and rb in structs
@@ -415,6 +421,24 @@ def classify_category(fn: dict) -> str:
415421
# human-authored tag and cannot drift.
416422

417423

424+
def _string_size(fn: dict):
425+
"""The name of the ``size_t *`` through which ``char *f(.., size_t *size_out)``
426+
writes the size of the string it returns, its terminating null character
427+
included, when its ``shape.outParams`` lists it as its last parameter; else None.
428+
The size is set aside as the presence flag of #_outparam is: no input, it is read
429+
back with the result."""
430+
ps = fn.get("params", [])
431+
ret = fn["returnType"]["canonical"]
432+
if not ps or _base(ret) != "char" or _ptr_depth(ret) != 1 or "const" in ret:
433+
return None
434+
last = ps[-1]
435+
out = set((fn.get("shape") or {}).get("outParams") or ())
436+
if (last["name"] in out and _base(last["canonical"]) == "size_t"
437+
and _ptr_depth(last["canonical"]) == 1):
438+
return last["name"]
439+
return None
440+
441+
418442
def _outparam(fn: dict, enums: set, type_encodings: dict):
419443
"""A MEOS accessor of the form ``bool f(.., T *result)`` returns its
420444
value through a trailing out-parameter, with the ``bool``/``int`` return
@@ -534,6 +558,9 @@ def assess(fn: dict, type_encodings: dict, enums: set) -> tuple:
534558

535559
out_lead, out_p, out_mode = _outparam(fn, enums, type_encodings)
536560
eff = out_lead if out_p is not None else fn.get("params", [])
561+
size_out = _string_size(fn)
562+
if size_out is not None:
563+
eff = eff[:-1]
537564
arr_i, count_i = _array_param(eff, type_encodings)
538565
ret_count_name, ret_elem = _array_return(fn, type_encodings)
539566
ret_count_i = (next((i for i, p in enumerate(eff)
@@ -641,7 +668,8 @@ def assess(fn: dict, type_encodings: dict, enums: set) -> tuple:
641668
if scalar == "handled":
642669
pass
643670
elif scalar is not None:
644-
wire_result = scalar
671+
wire_result = (scalar if size_out is None
672+
else {**scalar, "size_outparam": size_out})
645673
elif _is_scalar_pointer(ret, enums):
646674
reasons.append(f"unsupported-return:{ret}")
647675
wire_result = {"kind": "unsupported"}

‎server/app.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ def build_routes(catalog: dict) -> dict:
9898
"presence_return": r.get("presence_return", False),
9999
"element": _with_outparams(r.get("element"), outs),
100100
"count_outparam": r.get("count_outparam"),
101-
"count_param": r.get("count_param")},
101+
"count_param": r.get("count_param"),
102+
"size_outparam": r.get("size_outparam")},
102103
}
103104
return routes
104105

@@ -209,6 +210,10 @@ def _dispatch(body: dict, route: dict, engine: Engine, enums: dict):
209210
el.get("encode_outparams", []))
210211
for p in ptrs]}
211212

213+
if res.get("size_outparam"):
214+
# char *f(.., size_t *size_out): the string read for the size MEOS writes.
215+
return {"result": engine.invoke_string(route["name"], args)}
216+
212217
rtag = "void" if res["kind"] == "void" else (
213218
"ptr" if res["kind"] == "serialized"
214219
else _RESULT_TAG.get(res["json"], "str"))

‎server/engine.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ def invoke_array(self, fn_name: str, args: list):
6060
"""``Elem **f(.., int *count)`` — return a list of element handles."""
6161
raise NotImplementedError
6262

63+
def invoke_string(self, fn_name: str, args: list):
64+
"""``char *f(.., size_t *size_out)`` — return the string, read for the
65+
size the function writes, as #invoke_array reads its trailing count."""
66+
raise NotImplementedError
67+
6368
def invoke_counted_array(self, fn_name: str, args: list, count: int):
6469
"""``Elem **f(Elem **arr, int count, ..)`` — return the ``count`` element
6570
handles of an array as long as an array argument."""
@@ -88,6 +93,10 @@ def invoke(self, fn_name, args, result_tag):
8893
def encode(self, fn_name, handle, aux=(), outparams=()):
8994
return f"<stub:{fn_name}>"
9095

96+
def invoke_string(self, fn_name, args):
97+
# the placeholder string #invoke answers for a "str" result
98+
return ""
99+
91100
def invoke_outparam(self, fn_name, args, out_ctype, presence):
92101
return True, 0
93102

@@ -292,6 +301,26 @@ def invoke_array(self, fn_name, args):
292301
arr = ct.cast(ret, ct.POINTER(ct.c_void_p))
293302
return [arr[i] for i in range(count)]
294303

304+
def invoke_string(self, fn_name, args):
305+
# char *f(leading..., size_t *size_out): MEOS writes the size of the
306+
# string, its terminating NUL included, through the trailing byref
307+
# size, as #invoke_array reads its count; the string is read for
308+
# that many bytes rather than scanned for its terminator.
309+
self._last_error = None
310+
f = self._sym(fn_name)
311+
ct = self._ct
312+
n = ct.c_size_t()
313+
f.argtypes = [self._argmap[t] for t, _ in args] + [
314+
ct.POINTER(ct.c_size_t)]
315+
f.restype = ct.c_void_p
316+
cargs = [v.encode() if t == "str" and isinstance(v, str) else v
317+
for t, v in args]
318+
ret = f(*cargs, ct.byref(n))
319+
self._raise_if_error()
320+
if not ret:
321+
return None
322+
return ct.string_at(ret, n.value - 1).decode()
323+
295324
def invoke_counted_array(self, fn_name, args, count):
296325
# Elem **f(Elem **arr, int count, ...): MEOS allocates an array as long
297326
# as the argument array, so its length is the one the caller passed.

‎tests/test_engine_integration.py‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,31 @@ def _aux(specs):
4949
class CtypesIntegrationTests(unittest.TestCase):
5050
@classmethod
5151
def setUpClass(cls):
52+
from server.app import writer_outparams
5253
cls.eng = CtypesEngine(_LIB)
53-
te = (json.loads(_CATALOG.read_text()).get("typeEncodings", {})
54-
if _CATALOG.exists() else {})
54+
catalog = json.loads(_CATALOG.read_text()) if _CATALOG.exists() else {}
55+
cls.outs = writer_outparams(catalog)
56+
cls.catalog = catalog
57+
te = catalog.get("typeEncodings", {})
5558
t = te.get("Temporal", {})
5659
cls.readers = (t.get("readers") or {}).get("text", {})
5760
cls.reader_aux = (t.get("readerAux") or {}).get("text", {})
5861
cls.writers = (t.get("writers") or {}).get("text", {})
5962
cls.writer_aux = (t.get("writerAux") or {}).get("text", {})
6063

64+
def encode(self, writer, handle, aux=()):
65+
"""``writer`` called through #CtypesEngine.encode with the out-parameters the
66+
catalog states for it, as the routes of server/app.py call it
67+
(#writer_outparams): a string writer writes its size through one."""
68+
return self.eng.encode(writer, handle, aux, self.outs.get(writer, []))
69+
6170
def read(self, sqltype, literal):
6271
return self.eng.decode(self.readers[sqltype], literal,
6372
_aux(self.reader_aux[sqltype]))
6473

6574
def write(self, sqltype, handle):
66-
return self.eng.encode(self.writers[sqltype], handle,
67-
_aux(self.writer_aux[sqltype]))
75+
return self.encode(self.writers[sqltype], handle,
76+
_aux(self.writer_aux[sqltype]))
6877

6978
def test_each_type_states_its_own_reader_and_writer(self):
7079
for sqltype in _LITERAL_BY_TYPE:
@@ -116,7 +125,7 @@ def test_opaque_outparam_round_trip(self):
116125
# that junk is negative — measured failing 4 runs in 6, with a
117126
# different value each time, and passing 6 in 6 once maxdd is supplied.
118127
self.assertIn("POINT",
119-
self.eng.encode("geo_as_ewkt", ptr, [("int", 15)]).upper())
128+
self.encode("geo_as_ewkt", ptr, [("int", 15)]).upper())
120129

121130
def test_input_array_builder_round_trip(self):
122131
# Temporal *temporal_merge_array(Temporal **temparr, int count):
@@ -138,7 +147,7 @@ def test_array_return_round_trip(self):
138147
"tbool_in", "{[t@2000-01-01, f@2000-01-03], [t@2000-01-05]}")
139148
ptrs = self.eng.invoke_array("temporal_sequences", [("ptr", h)])
140149
self.assertEqual(len(ptrs), 2) # two composing sequences
141-
outs = [self.eng.encode("tsequence_out", p, [("int", 15)])
150+
outs = [self.encode("tsequence_out", p, [("int", 15)])
142151
for p in ptrs]
143152
self.assertTrue(all("@" in o for o in outs))
144153

@@ -150,7 +159,7 @@ def test_counted_array_return_round_trip(self):
150159
ptrs = self.eng.invoke_counted_array(
151160
"temparr_round", [("ptrarray", [h1, h2]), ("int", 2), ("int", 2)], 2)
152161
self.assertEqual(len(ptrs), 2)
153-
outs = [self.eng.encode("tfloat_out", p, [("int", 15)]) for p in ptrs]
162+
outs = [self.encode("tfloat_out", p, [("int", 15)]) for p in ptrs]
154163
self.assertIn("1.23@", outs[0])
155164
self.assertIn("7.65@", outs[1])
156165

@@ -184,8 +193,7 @@ def test_each_hex_writer_round_trips_through_encode(self):
184193
#CtypesEngine.encode, with the out-parameters the catalog states
185194
(#writer_outparams of server/app.py), read back to the value, as
186195
#test_each_type_round_trips_through_its_own_writer round-trips the text."""
187-
from server.app import writer_outparams
188-
outs = writer_outparams(json.loads(_CATALOG.read_text()))
196+
outs = self.outs
189197
for reader, literal, writer, back, out in (
190198
("intset_in", "{1, 2, 3}", "set_as_hexwkb", "set_from_hexwkb",
191199
"intset_out"),
@@ -195,11 +203,27 @@ def test_each_hex_writer_round_trips_through_encode(self):
195203
"temporal_from_hexwkb", "tint_out")):
196204
self.assertEqual(outs[writer], [(2, "size_t *")], writer)
197205
h = self.eng.decode(reader, literal)
198-
hexwkb = self.eng.encode(writer, h, [("int", 0)], outs[writer])
206+
hexwkb = self.encode(writer, h, [("int", 0)])
199207
self.assertRegex(hexwkb, r"^(?:[0-9A-F]{2})+$", writer)
200208
aux = [("int", 15)] if out == "tint_out" else []
201-
again = self.eng.encode(out, self.eng.decode(back, hexwkb), aux)
202-
self.assertEqual(again, self.eng.encode(out, h, aux), writer)
209+
again = self.encode(out, self.eng.decode(back, hexwkb), aux)
210+
self.assertEqual(again, self.encode(out, h, aux), writer)
211+
212+
def test_a_string_route_reads_the_size_meos_writes(self):
213+
"""``tfloat_out(temp, maxdd, size_out)`` served as its route, the size an
214+
out-parameter of its result (#_string_size of parser/enrich.py), answers the
215+
string #test_each_type_round_trips_through_its_own_writer writes."""
216+
from server.app import _dispatch, build_routes
217+
route = build_routes(self.catalog)["/tfloat_out"]
218+
self.assertEqual([p["name"] for p in route["params"]], ["temp", "maxdd"])
219+
self.assertEqual(route["result"]["size_outparam"], "size_out")
220+
# the value crosses the wire as a client sends it: written by the out of its
221+
# class, which the route's decode reads back
222+
t = self.catalog["typeEncodings"]["Temporal"]
223+
h = self.read("tfloat", _TFLOAT)
224+
wire = self.encode(t["out"], h, _aux(t["out_aux"]))
225+
got = _dispatch({"temp": wire, "maxdd": 15}, route, self.eng, {})
226+
self.assertEqual(got["result"], self.write("tfloat", h))
203227

204228
def test_bad_input_raises_not_exits(self):
205229
with self.assertRaises(MeosError):

‎tests/test_enrich.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ def test_defaultable_aux_accepted_type_tag_rejected(self):
179179
# Weird gets no decoder at all.
180180
self.assertNotIn("Weird", self.te)
181181

182+
def test_a_size_out_parameter_is_no_input(self):
183+
"""A string writer stating its size through a ``size_t *`` its
184+
``shape.outParams`` lists is an encoder as #test_struct_prefix_stripped_and_round_trip
185+
reads one; the same parameter left unlisted is a pointer input and disqualifies it."""
186+
listed = dict(fn("temporal_out", "char *", (T, "temp"), ("int", "maxdd"),
187+
("size_t *", "size_out")), shape={"outParams": ["size_out"]})
188+
te = build_type_encodings([FUNCTIONS[0], listed], {"Temporal"})
189+
self.assertEqual(te["Temporal"]["out"], "temporal_out")
190+
self.assertEqual(te["Temporal"]["out_aux"],
191+
[{"name": "maxdd", "kind": "integer", "default": 15}])
192+
unlisted = fn("temporal_out", "char *", (T, "temp"), ("size_t *", "size_out"))
193+
te = build_type_encodings([FUNCTIONS[0], unlisted], {"Temporal"})
194+
self.assertIsNone(te["Temporal"]["out"])
195+
182196
def test_byte_codec_beside_the_wire_encodings(self):
183197
# the byte-codec twin of #test_struct_prefix_stripped_and_round_trip
184198
self.assertEqual(self.te["Temporal"]["bytes"],
@@ -241,6 +255,28 @@ def test_out_param_not_exposable(self):
241255
r2 = self.n("tjsonb_value_at_timestamptz")["reason"]
242256
self.assertIn("array-or-out-param:value", r2)
243257

258+
def test_a_string_result_reads_its_size(self):
259+
"""``char *f(.., size_t *size_out)`` whose ``shape.outParams`` lists the size is
260+
exposable, the size no input and stated on the result, as
261+
#test_array_return_as_long_as_an_array_argument states its count; the same
262+
size left unlisted is refused as #test_out_param_not_exposable refuses one."""
263+
listed = dict(fn("geom_relate", "char *", ("const struct Jsonb *", "js"),
264+
("int", "maxdd"), ("size_t *", "size_out")),
265+
shape={"outParams": ["size_out"]})
266+
unlisted = fn("geom_relate2", "char *", ("const struct Jsonb *", "js"),
267+
("size_t *", "size_out"))
268+
fns = by_name(enrich_idl({
269+
"functions": [listed, unlisted,
270+
fn("jsonb_in", "struct Jsonb *", ("const char *", "str"))],
271+
"structs": [{"name": "Jsonb", "fields": []}], "enums": []}))
272+
f = fns["geom_relate"]
273+
self.assertTrue(f["network"]["exposable"])
274+
self.assertEqual([p["name"] for p in f["wire"]["params"]], ["js", "maxdd"])
275+
self.assertEqual(f["wire"]["result"],
276+
{"kind": "json", "json": "string", "size_outparam": "size_out"})
277+
self.assertIn("array-or-out-param:size_out",
278+
fns["geom_relate2"]["network"]["reason"])
279+
244280
def test_array_param_and_missing_encoder(self):
245281
r = self.n("tsequence_make")["reason"]
246282
self.assertFalse(self.n("tsequence_make")["exposable"])

0 commit comments

Comments
 (0)