From 0c0ea2c00cec70a3f09670a836b87e3f0597591e Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Thu, 15 Oct 2026 09:20:55 +0200 Subject: [PATCH] Read the fields of a returned struct a row names, and no other A column statement of meta/sql-columns.json may name the field of the returned struct feeding its column, and a row naming fields reads those only. valueCount and valuePercent, which MobilityDB declares over raster_value_count, read one ValueCount per row: valueCount its value and its count, valuePercent its value and its percent. Both state their two fields. Witness: tests/test_sqlfn_rows.py builds the rows of both functions over a ValueCount; without the statement the field no column reads stops the catalog, with it each column reads its field, and a statement naming a field the struct lacks stops the catalog. Measured: over MobilityDB 25fe9147ab the catalog run fails on master ("SQL rows with a column MEOS states no source for", the six record forms of the two functions) and completes here, the six forms reading value and count or value and percent. Over MobilityDB f935759e00 the catalog is byte-identical to master's. The suite fails three tests it also fails on master at the same commit (test_representative_symbols_land_in_their_family and two struct layout tests), and no other. Why: a ValueCount holds three fields and each row reads two, so the field a row leaves unread fed no column and stopped every catalog derived from MobilityDB master. --- meta/sql-columns.json | 8 ++++++++ meta/sql-columns.schema.json | 5 +++++ parser/sqlfn.py | 14 ++++++++++--- tests/test_sqlfn_rows.py | 38 ++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/meta/sql-columns.json b/meta/sql-columns.json index 6d1dbf6..8f2afaa 100644 --- a/meta/sql-columns.json +++ b/meta/sql-columns.json @@ -61,6 +61,14 @@ "jsonbEachText": { "note": "The key and the value are both text: the key is the array jsonb_each_text returns, the value its out-parameter values.", "columns": {"key": {"from": "return"}, "value": {"from": "values"}} + }, + "valueCount": { + "note": "A ValueCount holds the count and the percent of a value; the rows of valueCount read its value and its count, as PostGIS's ST_ValueCount does.", + "columns": {"value": {"field": "value"}, "count": {"field": "count"}} + }, + "valuePercent": { + "note": "A ValueCount holds the count and the percent of a value; the rows of valuePercent read its value and its percent, as PostGIS's ST_ValuePercent does.", + "columns": {"value": {"field": "value"}, "percent": {"field": "percent"}} } } } diff --git a/meta/sql-columns.schema.json b/meta/sql-columns.schema.json index 233e417..f9a91d5 100644 --- a/meta/sql-columns.schema.json +++ b/meta/sql-columns.schema.json @@ -36,6 +36,11 @@ "type": "string", "pattern": "^(ordinal|return|[a-z_][a-z0-9_]*)$" }, + "field": { + "description": "The field of the struct an array return holds feeding the column. A row naming fields reads those only.", + "type": "string", + "pattern": "^[a-z_][a-z0-9_]*$" + }, "offset": { "description": "A constant the wrapper adds to the C value feeding the column.", "type": "integer" diff --git a/parser/sqlfn.py b/parser/sqlfn.py index 3489f7e..b1b7e84 100644 --- a/parser/sqlfn.py +++ b/parser/sqlfn.py @@ -621,21 +621,29 @@ def _column_sources(func, sig, sqlc, declared, struct): where two C values of the column's type fit it (`"from": "return"` or the name of an out-parameter, the key and the value of `jsonbEachText`); the column takes that value, and a statement naming no C value of the function stops the - catalog as a column nothing feeds does.""" + catalog as a column nothing feeds does. A statement naming the `field` of a + returned struct feeds its column from that field, and a row naming fields reads + those only: the rows of `valueCount` read the `value` and `count` of a + `ValueCount`, the rows of `valuePercent` its `value` and `percent`.""" stated = declared.get(sig["ret"]) or declared.get(sig.get("sqlName") or func.get("sqlfn")) or {} cols = sig["columns"] fed = [c for c in cols if stated.get(c["name"], {}).get("from") != "ordinal"] classes = {c for cs in sqlc.values() for c in cs} slots = _row_slots(func, sig.get("retSet", False), len(fed), struct, classes) + fields = {s["field"] for s in stated.values() if "field" in s} + if fields: + slots = [x for x in slots if x[0].get("field") in fields | {None}] used, out = set(), {} # A column stated with the C value feeding it takes that value: two C values of # one type leave the source of their columns to the statement, never to order. for c in fed: src = stated.get(c["name"], {}).get("from") - if src is None: + field = stated.get(c["name"], {}).get("field") + if src is None and field is None: continue named = [k for k, (slot, _, _) in enumerate(slots) - if slot["from"] == src and k not in used] + if src in (None, slot["from"]) and field in (None, slot.get("field")) + and k not in used] if len(named) != 1: return None used.add(named[0]) diff --git a/tests/test_sqlfn_rows.py b/tests/test_sqlfn_rows.py index 7dd9529..d68b619 100644 --- a/tests/test_sqlfn_rows.py +++ b/tests/test_sqlfn_rows.py @@ -139,6 +139,9 @@ def test_the_catalog_signature_lists_its_columns(self): {"name": "j", "cType": "int"}]}, {"name": "TBox", "fields": [{"name": "period", "cType": "Span"}, {"name": "span", "cType": "Span"}]}, + {"name": "ValueCount", "fields": [{"name": "value", "cType": "double"}, + {"name": "count", "cType": "uint32_t"}, + {"name": "percent", "cType": "double"}]}, ], } @@ -217,6 +220,18 @@ def _pairs(with_periods): PAIRS_OFFSET = {"i": {"offset": 1}, "j": {"offset": 1}} +def _value_count(sqlfn, column): + """A row of `sqlfn` reading the value of a `ValueCount` and one more field, built + as #_pairs builds the rows of a pair.""" + return {"name": "raster_value_count", "sqlfn": sqlfn, + "params": [{"name": "rast", "cType": "const Raster *"}, _count()], + "returnType": {"c": "ValueCount *"}, + "shape": {"arrayReturn": {"element": {"c": "ValueCount"}}, + "outParams": ["count"]}, + "sqlSignatures": [{"args": ["raster"], "ret": "record", "retSet": True, + "columns": _cols(("value", "float"), column)}]} + + class RowSourceTests(unittest.TestCase): def test_columns_match_their_source_by_type_not_by_position(self): @@ -351,6 +366,29 @@ def test_a_stated_source_feeds_its_column(self): self.assertEqual(cols, [{"name": "key", "type": "text", "from": "return"}, {"name": "value", "type": "text", "from": "values"}]) + def test_a_struct_field_no_column_reads_stops_the_catalog(self): + """As #test_a_c_value_feeding_no_column_stops_the_catalog, for a field.""" + with self.assertRaises(ValueError): + _attach(_value_count("valueCount", ("count", "integer"))) + + def test_a_row_naming_fields_reads_those_only(self): + """The rows of valueCount and valuePercent read two of the three fields of + a `ValueCount`, the second field the one each names.""" + for sqlfn, column in (("valueCount", ("count", "integer")), + ("valuePercent", ("percent", "float"))): + name = column[0] + cols, _ = _attach(_value_count(sqlfn, column), + {sqlfn: {"value": {"field": "value"}, + name: {"field": name}}}) + self.assertEqual([(c["name"], c["from"], c["field"]) for c in cols], + [("value", "return", "value"), (name, "return", name)]) + + def test_a_stated_field_naming_no_field_stops_the_catalog(self): + with self.assertRaises(ValueError): + _attach(_value_count("valueCount", ("count", "integer")), + {"valueCount": {"value": {"field": "value"}, + "count": {"field": "total"}}}) + class DeclaredColumnsTests(unittest.TestCase):