Skip to content

Commit d0eb85f

Browse files
rustyconoverclaude
andcommitted
feat(arguments): preserve union tag when decoding union-typed args
A DuckDB UNION / Arrow union argument is tagged — the discriminator lives in UnionScalar.type_code, which plain Scalar.as_py() discards, so a union arg arrived as just its member value with no way to tell which member was set. Decode union scalars to a new TaggedUnion(tag, value) instead: tag is the active member's field name (resolved from type_code via the union type), value is its Python value. Routed through Arguments.get()/get_varargs(), so both direct args.get() and table-function params.args.* benefit; every non-union type is unchanged. TaggedUnion is exported from vgi and vgi.arguments. Verified end-to-end over the RPC path (sparse union). Note: the C++ extension still rejects dense unions; full union support needs a fix there too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 34978e2 commit d0eb85f

3 files changed

Lines changed: 107 additions & 2 deletions

File tree

‎tests/test_union_argument.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright 2025, 2026 Query Farm LLC - https://query.farm
2+
3+
"""Union-typed argument decoding.
4+
5+
A DuckDB ``UNION`` / Arrow union argument is tagged: the discriminator lives in
6+
``UnionScalar.type_code``, which plain ``Scalar.as_py()`` discards. ``Arguments``
7+
decodes such scalars to ``TaggedUnion`` so the active member name is preserved.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import pyarrow as pa
13+
14+
from vgi.arguments import Arguments, TaggedUnion
15+
16+
_RF = pa.struct([pa.field("n_estimators", pa.list_(pa.int64())), pa.field("max_depth", pa.list_(pa.int64()))])
17+
_SVC = pa.struct([pa.field("C", pa.list_(pa.float64())), pa.field("kernel", pa.list_(pa.string()))])
18+
19+
20+
def _union_scalar(tag: str, code: int, members: dict) -> pa.UnionScalar:
21+
"""Build a one-element sparse-union scalar with named members, active = ``tag``."""
22+
arr = pa.UnionArray.from_sparse(
23+
pa.array([code], type=pa.int8()),
24+
[
25+
pa.array([members.get("random_forest_classifier")], type=_RF),
26+
pa.array([members.get("svc")], type=_SVC),
27+
],
28+
field_names=["random_forest_classifier", "svc"],
29+
type_codes=[0, 1],
30+
)
31+
return arr[0]
32+
33+
34+
def test_union_arg_preserves_tag() -> None:
35+
scalar = _union_scalar(
36+
"random_forest_classifier", 0, {"random_forest_classifier": {"n_estimators": [100, 300], "max_depth": [3, 5]}}
37+
)
38+
got = Arguments(named={"config": scalar}).get("config")
39+
assert isinstance(got, TaggedUnion)
40+
assert got.tag == "random_forest_classifier"
41+
assert got.value == {"n_estimators": [100, 300], "max_depth": [3, 5]}
42+
43+
44+
def test_union_arg_other_member() -> None:
45+
scalar = _union_scalar("svc", 1, {"svc": {"C": [1.0, 10.0], "kernel": ["rbf", "linear"]}})
46+
got = Arguments(named={"config": scalar}).get("config")
47+
assert got.tag == "svc"
48+
assert got.value == {"C": [1.0, 10.0], "kernel": ["rbf", "linear"]}
49+
50+
51+
def test_non_union_args_unchanged() -> None:
52+
args = Arguments(named={"n": pa.scalar(5), "s": pa.scalar("hi")}, positional=(pa.scalar(1.5),))
53+
assert args.get("n") == 5
54+
assert args.get("s") == "hi"
55+
assert args.get(0) == 1.5

‎vgi/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
Param,
4040
Returns,
4141
TableInput,
42+
TaggedUnion,
4243
)
4344
from vgi.auth import AuthContext, CallContext
4445
from vgi.metadata import (
@@ -143,6 +144,7 @@
143144
"TableInOutGenerator",
144145
"TableInput",
145146
"TableInputValidationError",
147+
"TaggedUnion",
146148
"TypeMismatchError",
147149
"Worker",
148150
"functions_to_arrow",

‎vgi/arguments.py‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def _accepts_none(annotated_inner_type: Any) -> bool:
159159
"PYTHON_TO_ARROW",
160160
"Returns",
161161
"TableInput",
162+
"TaggedUnion",
162163
"TypeBoundPredicate",
163164
"OutputLength",
164165
"Setting",
@@ -168,6 +169,53 @@ def _accepts_none(annotated_inner_type: Any) -> bool:
168169
]
169170

170171

172+
@dataclass(frozen=True, slots=True)
173+
class TaggedUnion:
174+
"""A decoded union-typed argument: which member is set (``tag``) and its ``value``.
175+
176+
DuckDB ``UNION`` / Arrow union arguments are *tagged*: the discriminator
177+
(which member is present) lives in the Arrow ``UnionScalar.type_code``, not
178+
in the member value. Plain ``Scalar.as_py()`` returns only the member value
179+
and drops that tag, so union arguments are decoded into this wrapper
180+
instead — ``tag`` is the active member's field name and ``value`` is its
181+
Python value.
182+
183+
Example::
184+
185+
config: Annotated[TaggedUnion, Arg("config", arrow_type=pa.sparse_union([...]))]
186+
...
187+
cfg = params.args.config # TaggedUnion(tag=..., value=...)
188+
if cfg.tag == "random_forest_classifier":
189+
grid = cfg.value # the member struct, as a dict
190+
191+
"""
192+
193+
tag: str | None
194+
value: Any
195+
196+
197+
def _scalar_to_py(scalar: "Scalar") -> Any:
198+
"""Convert an argument scalar to a Python value, preserving union tags.
199+
200+
Identical to ``scalar.as_py()`` for every type except unions: a
201+
``UnionScalar`` is decoded to a [`TaggedUnion`][] so the member
202+
discriminator (which ``as_py()`` discards) is retained.
203+
"""
204+
if isinstance(scalar, pa.UnionScalar):
205+
union_type = scalar.type
206+
tag = next(
207+
(
208+
union_type.field(i).name
209+
for i in range(union_type.num_fields)
210+
if union_type.type_codes[i] == scalar.type_code
211+
),
212+
None,
213+
)
214+
inner = scalar.value
215+
return TaggedUnion(tag=tag, value=inner.as_py() if inner is not None else None)
216+
return scalar.as_py()
217+
218+
171219
class TableInput:
172220
"""Sentinel type for table input parameters in table-in-out functions.
173221
@@ -377,7 +425,7 @@ def get(
377425
else:
378426
raise TypeError(f"Argument '{key}': expected {type}, got {scalar.type}")
379427

380-
return scalar.as_py()
428+
return _scalar_to_py(scalar)
381429

382430
def get_varargs(
383431
self,
@@ -410,7 +458,7 @@ def get_varargs(
410458
if type is not None and scalar.type != type:
411459
raise TypeError(f"Argument {i}: expected {type}, got {scalar.type}")
412460

413-
values.append(scalar.as_py())
461+
values.append(_scalar_to_py(scalar))
414462

415463
return tuple(values)
416464

0 commit comments

Comments
 (0)