Sitelet https://github.com/datachain-ai/datachain/pull/1824
Skip to content

Union types - #1824

Open
dmpetrov wants to merge 74 commits into
mainfrom
union
Open

Union types#1824
dmpetrov wants to merge 74 commits into
mainfrom
union

Conversation

@dmpetrov

@dmpetrov dmpetrov commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Closes #1812
Adds multi-arm Union[...] signals — Union[int, str, float], Union[ModelA, ModelB], mixed, and nullable.

Storage reuses the hidden _type_tag discriminator: _type_tag holds the active arm's name ("int", "Text"), NULL means None. Optional[Model] is just the single-arm case.

Compatibility: the old numeric _type_tag (arm index) is still read, with a FutureWarning; 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 changed Int64String.

Out of scope (follow-up): collection arms (Union[str, list[str]]) — #1952

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.
@dmpetrov
dmpetrov marked this pull request as draft June 18, 2026 16:24
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 18, 2026

Copy link
Copy Markdown

Deploying datachain with  Cloudflare Pages  Cloudflare Pages

Latest commit: a0d47a2
Status: ✅  Deploy successful!
Preview URL: https://bf7eebe3.datachain-2g6.pages.dev
Branch Preview URL: https://union.datachain-2g6.pages.dev

View logs

@dmpetrov dmpetrov mentioned this pull request Jun 18, 2026
@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

dmpetrov and others added 24 commits June 19, 2026 10:19
@shcheklein
shcheklein requested a review from Copilot June 28, 2026 16:32
@dmpetrov

Copy link
Copy Markdown
Contributor Author

@datachain-ai/all any update on this. it's waiting for a while.

Comment thread CLAUDE.md
Comment on lines +10 to +14

## Rules I repeatedly miss

The AGENT.md section I most often violate: **"Comments and docstrings"** — weight
it accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related? 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really but it's better to keep 🙂
however, it's still messing up with comments 😅

@dreadatour

Copy link
Copy Markdown
Contributor

Storage reuses the hidden _type_tag discriminator: _type_tag >= 0 is the active arm's index, NULL means None. Optional[Model] is just the single-arm case.

Compatibility: drops the old _type_tag > 0 Optional-present flag in favor of this scheme.

Current implementation in main branch:

# _type_tag discriminator for Optional[DataModel]: this value marks the present arm.
OPTIONAL_PRESENT_TAG = 0
def optional_tag_is_absent(tag: "Any") -> bool:
"""An Optional[DataModel] subtree is absent when its ``_type_tag`` is NULL
(outer-join padding) or not the present-arm value."""
return tag is None or tag != OPTIONAL_PRESENT_TAG

This means 0 — model is not None, 1 — model is None.

And it looks like there is an issue here: _type_tag == 0 meant that an optional model was present in current implementation. The new reader only recognizes string tags. Every previously stored present Optional[Model] value becomes None after this PR. Reproduce:

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: [None]
Expected: [M(x=7)]

Found this one using LLM

Comment thread src/datachain/lib/udf.py Outdated
Comment on lines 337 to 340
if union_value_match(obj, anno):
flat.extend(flatten_value(obj, anno))
else:
flat.extend(self._obj_to_list(obj))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

str | list[str] now raises UdfSignatureError like the typing.Union spelling

Comment thread src/datachain/lib/signal_schema.py Outdated
for field in names:
if not isinstance(field, str):
raise SignalResolvingTypeError("select()", field)
# readable union-arm path (value.int) -> positional slot (value._0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ha, right! Fixed all the comments with old meaning.

@dreadatour dreadatour left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@dmpetrov

Copy link
Copy Markdown
Contributor Author

there is an issue here: _type_tag == 0 meant that an optional model was present in current implementation. The new reader only recognizes string tags. Every previously stored present Optional[Model] value becomes None after this PR.

Yes, it was discussed in Slack as I remember. So, we need to break the backward-compatability.
The best we can do for now: old Optional[DataModel] datasets still read, now behind a warning; removal is tracked in #1949. I'm implementing this.

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.

@dmpetrov

Copy link
Copy Markdown
Contributor Author

@dreadatour thank you for the review. all fixed - please take a look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Union type

4 participants