Conversation
Adds support for multi-arm Union[...] signals (Union[basic,basic], Union[Model,Model], mixed, nullable, and collection arms like Union[str, list[str]]). A union stores a hidden _type_tag discriminator plus one column-group per arm; the active arm is identified by the tag, inactive arms are NULL/default. Optional[Model] is the single-arm case. Includes func.variant_type(), readable arm access, and cross-backend (SQLite + ClickHouse) round-trips with tests.
Deploying datachain with
|
| Latest commit: |
a0d47a2
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://bf7eebe3.datachain-2g6.pages.dev |
| Branch Preview URL: | https://union.datachain-2g6.pages.dev |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
…ng and removing items
for more information, see https://pre-commit.ci
|
@datachain-ai/all any update on this. it's waiting for a while. |
|
|
||
| ## Rules I repeatedly miss | ||
|
|
||
| The AGENT.md section I most often violate: **"Comments and docstrings"** — weight | ||
| it accordingly. |
There was a problem hiding this comment.
not really but it's better to keep 🙂
however, it's still messing up with comments 😅
Current implementation in datachain/src/datachain/lib/data_model.py Lines 229 to 236 in 8724645 This means And it looks like there is an issue here: from typing import Optional
from datachain.lib.data_model import DataModel
from datachain.lib.signal_schema import SignalSchema
class M(DataModel):
x: int
schema = SignalSchema({"m": Optional[M]})
# Physical representation written before this PR:
# 0 = model present, 7 = M.x
result = schema.row_to_objs((0, 7))
print(result)Actual: Found this one using LLM |
| if union_value_match(obj, anno): | ||
| flat.extend(flatten_value(obj, anno)) | ||
| else: | ||
| flat.extend(self._obj_to_list(obj)) |
There was a problem hiding this comment.
A multi-output UDF can silently reinterpret an unrelated BaseModel as a declared union arm.
This only succeeds when the unrelated model’s flattened fields happen to match the union’s physical column count/order/types. That constraint makes it less common, but the outcome is silent data corruption rather than an error.
Minimal reproducer:
from typing import Union
import datachain as dc
from datachain.lib.data_model import DataModel
from datachain.query import Session
class Foo(DataModel):
x: int
class Bar(DataModel):
y: int
class Wrong(DataModel):
tag: str
bar_y: int | None
foo_x: int
def bad(n):
return n, Wrong(tag="Foo", bar_y=None, foo_x=123)
session = Session.get(in_memory=True)
out = dc.read_values(n=[1], session=session).map(
bad,
output={"n2": int, "value": Union[Foo, Bar]},
)
print(out.to_values("value"))Actual: [Foo(x=123)]
Expected: DataChainParamsError: Wrong(...) does not match any arm of Union[Foo, Bar]
The same Wrong value is correctly rejected when the union is the UDF’s only output. The bug is specific to the multi-output path.
Could we distinguish a per-output union value from the wrapper/Parquet case here, reject unrelated models, and add a multi-output regression test?
There was a problem hiding this comment.
fixed. A cover model is now recognised by its fields matching the remaining declared outputs, so a non-arm model raises DataChainParamsError exactly as in the single-output path.
| right = dc.read_values( | ||
| id=[1, 2], k=["x", "y"], output={"id": int, "k": str}, session=test_session | ||
| ) | ||
| merged = left.merge(right, on="id", inner=False) |
There was a problem hiding this comment.
This test keeps value on the preserved left side, so it does not exercise a missing union value. If value: Union[int, str] is on the right, this left merge produces None for the unmatched row while the schema remains Union[int, str]:
left = dc.read_values(id=[1, 2], session=test_session)
right = dc.read_values(
right_id=[1],
value=["x"],
output={"right_id": int, "value": Union[int, str]},
session=test_session,
)
result = left.merge(
right, on="id", right_on="right_id", inner=False
).order_by("id")
assert result.to_list("id", "value") == [(1, "x"), (2, None)]
assert result.signals_schema.values["value"] == Union[int, str, None]Currently the second assertion fails because SignalSchema.merge() only widens direct scalar types. Could we widen a tagged union to Union[..., None] when its merge side is nullable and add this reverse-direction regression test?
There was a problem hiding this comment.
Fixed: SignalSchema._nullable() now widens a tagged union as well, and your reverse-direction case is in as test_merge_widens_union_on_the_padded_side.
| if (fr := ModelStore.to_pydantic(arm)) is not None | ||
| else None | ||
| ) | ||
| subtree[arm_selector(arm)] = (arm, arm_sub) |
There was a problem hiding this comment.
If a model arm’s stable name is _type_tag, this assignment overwrites the discriminator inserted above:
class _type_tag(DataModel):
x: int
annotation = Union[_type_tag, str]
schema = SignalSchema({"value": annotation})
assert schema.db_signals() == [
"value___type_tag",
"value___type_tag__x",
"value__str",
]Currently the first column is missing, and ingestion fails later with a misleading type error because the discriminator value is written into value___type_tag__x.
Could we reject _type_tag as a reserved arm selector during union-layout validation and add a regression test? The validation should use the stable ModelStore base name, since that is what arm_selector() returns.
There was a problem hiding this comment.
Good catch - it was actually fixed in one place but not the other.
Fixed: added a validation with an exception.
| arms, _ = union_arms(t) | ||
| if len(arms) >= 2: | ||
| # multi-arm union: supported only as a tagged union (scalar/DataModel arms) | ||
| return union_layout(t) is not None and all(is_chain_type(arm) for arm in arms) |
There was a problem hiding this comment.
Returning False here is expected for a collection arm, but on Python 3.10–3.13:
dc.read_values(
value=[["x"]],
output={"value": str | list[str]},
session=test_session,
)raises: AttributeError: 'types.UnionType' object has no attribute '__name__'
The equivalent typing.Union[str, list[str]] correctly raises UdfSignatureError. The difference comes from _validate_output() formatting the rejected annotation with value.__name__.
Could we format unsupported annotations safely and add both union spellings to a regression test? Collection-arm support can remain out of scope.
There was a problem hiding this comment.
str | list[str] now raises UdfSignatureError like the typing.Union spelling
| for field in names: | ||
| if not isinstance(field, str): | ||
| raise SignalResolvingTypeError("select()", field) | ||
| # readable union-arm path (value.int) -> positional slot (value._0) |
There was a problem hiding this comment.
The final layout is selector-keyed (value__int / value__str), so this no longer resolves to a positional value._0 slot, and _type_tag stores the selector string rather than an index.
Could we sweep the other stale positive descriptions in datachain.py:1549, signal_schema.py:1191/1670, test_union_adversarial.py:55/63/301, and test_union_types.py:239/246 plus the _type_tag index comment in the unit suite? Assertions that _0/_1 never appear publicly can remain as regression checks.
Current representation:
from typing import Union
from datachain.lib.signal_schema import SignalSchema
schema = SignalSchema({"value": Union[int, str]})
print(schema.db_signals())Output:
[
"value___type_tag",
"value__int",
"value__str",
]
There are no value._0 or value._1 slots, and _type_tag stores "int" or "str", not an arm index.
There was a problem hiding this comment.
ha, right! Fixed all the comments with old meaning.
dreadatour
left a comment
There was a problem hiding this comment.
@dmpetrov sorry for the delay, there are few conflicts with main now needs to be solved. Also description update is needed (see first comment).
PR is quite big, I have read though it — looks good to me!
Also ask LLM to check and post all the findings above.
Yes, it was discussed in Slack as I remember. So, we need to break the backward-compatability. Caveat: only reading is covered - the column type changed Int64 → String, so mixing a legacy dataset with a new one in one query still fails on CH. |
|
@dreadatour thank you for the review. all fixed - please take a look. |
Closes #1812
Adds multi-arm
Union[...]signals —Union[int, str, float],Union[ModelA, ModelB], mixed, and nullable.Storage reuses the hidden
_type_tagdiscriminator:_type_tagholds the active arm's name ("int","Text"),NULLmeansNone.Optional[Model]is just the single-arm case.Compatibility: the old numeric
_type_tag(arm index) is still read, with aFutureWarning; its removal is tracked in #1949. Mixing a legacy dataset with a new one in a single query is not supported — the discriminator column type changedInt64→String.Out of scope (follow-up): collection arms (
Union[str, list[str]]) — #1952