Files
ray-project--ray/python/ray/data/tests/test_infer_schema.py
T
2026-07-13 13:17:40 +08:00

431 lines
16 KiB
Python

"""Integration tests for ``LogicalOperator.infer_schema()`` (Phase 1).
Asserts that ``Dataset.schema()`` resolves the output schema **without**
falling back to a ``limit(1)`` execution for every non-UDF chain. UDF
chains (``map``, ``map_batches``, ``flat_map``) correctly return ``None``.
The headline guarantee is verified by calling ``ds.schema(fetch_if_missing=False)``
through the public API: this disables the ``limit(1)`` fallback in
``_base_schema``, so a non-``None`` return proves the static
``LogicalOperator.infer_schema()`` path resolved the schema without
materializing any blocks.
"""
import pyarrow as pa
import pyarrow.parquet as pq
import pytest
import ray
from ray.data._internal.logical.operators import Project
from ray.data.aggregate import Count, Max, Mean, Min, Sum
from ray.data.expressions import col, lit, star
from ray.data.tests.conftest import * # noqa: F401,F403
from ray.data.tests.util import assert_exprs_equal
@pytest.fixture(scope="module")
def parquet_path(tmp_path_factory):
tmp = tmp_path_factory.mktemp("infer_schema")
table = pa.table(
{
"a": pa.array([1, 2, 3, 4, 5], type=pa.int32()),
"b": pa.array([1.0, 2.0, 3.0, 4.0, 5.0], type=pa.float32()),
"k": pa.array(["x", "x", "y", "y", "z"], type=pa.string()),
}
)
pq.write_table(table, tmp / "data.parquet")
return tmp
def _static_schema(ds: ray.data.Dataset) -> pa.Schema:
"""Return the dataset's schema via the static path only.
``fetch_if_missing=False`` disables ``_base_schema``'s ``limit(1)``
fallback, so a non-``None`` return proves the chain's
``infer_schema()`` resolved without materializing any blocks. Returns
``None`` if the static path failed.
"""
schema = ds.schema(fetch_if_missing=False)
return None if schema is None else schema.base_schema
class TestSourceAndPassthroughs:
def test_read_parquet(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = ray.data.read_parquet(str(parquet_path))
assert _static_schema(ds) == pa.schema(
[
pa.field("a", pa.int32()),
pa.field("b", pa.float32()),
pa.field("k", pa.string()),
]
)
def test_filter_with_expr_passthrough(
self, ray_start_regular_shared_2_cpus, parquet_path
):
ds = ray.data.read_parquet(str(parquet_path)).filter(expr=col("a") > 0)
assert _static_schema(ds) == pa.schema(
[
pa.field("a", pa.int32()),
pa.field("b", pa.float32()),
pa.field("k", pa.string()),
]
)
def test_filter_with_fn_passthrough(
self, ray_start_regular_shared_2_cpus, parquet_path
):
# Filter with a callable still preserves the input schema.
ds = ray.data.read_parquet(str(parquet_path)).filter(lambda row: row["a"] > 0)
assert _static_schema(ds) == pa.schema(
[
pa.field("a", pa.int32()),
pa.field("b", pa.float32()),
pa.field("k", pa.string()),
]
)
def test_limit_passthrough(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = ray.data.read_parquet(str(parquet_path)).limit(2)
assert _static_schema(ds) == pa.schema(
[
pa.field("a", pa.int32()),
pa.field("b", pa.float32()),
pa.field("k", pa.string()),
]
)
def test_sort_passthrough(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = ray.data.read_parquet(str(parquet_path)).sort("a")
assert _static_schema(ds) == pa.schema(
[
pa.field("a", pa.int32()),
pa.field("b", pa.float32()),
pa.field("k", pa.string()),
]
)
class TestProject:
def test_select_columns(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = ray.data.read_parquet(str(parquet_path)).select_columns(["a", "b"])
assert _static_schema(ds) == pa.schema(
[pa.field("a", pa.int32()), pa.field("b", pa.float32())]
)
def test_with_column(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = ray.data.read_parquet(str(parquet_path)).with_column(
"s", col("a") + col("b")
)
assert _static_schema(ds) == pa.schema(
[
pa.field("a", pa.int32()),
pa.field("b", pa.float32()),
pa.field("k", pa.string()),
pa.field("s", pa.float32()),
]
)
def test_rename_columns(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = ray.data.read_parquet(str(parquet_path)).rename_columns({"a": "x"})
# Renamed columns occupy the source column's position so the static
# schema matches the materialized column order.
assert _static_schema(ds) == pa.schema(
[
pa.field("x", pa.int32()),
pa.field("b", pa.float32()),
pa.field("k", pa.string()),
]
)
def test_with_column_chain(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = (
ray.data.read_parquet(str(parquet_path))
.with_column("s", col("a") + col("b"))
.with_column("d", col("s") * lit(2.0))
)
assert _static_schema(ds) == pa.schema(
[
pa.field("a", pa.int32()),
pa.field("b", pa.float32()),
pa.field("k", pa.string()),
pa.field("s", pa.float32()),
pa.field("d", pa.float64()),
]
)
class TestDropColumns:
"""Phase 3: ``Dataset.drop_columns`` reshapes into a ``Project`` over
the surviving columns when the input schema is known, so the typed
chain stays intact through it."""
def test_drop_columns_static_schema(
self, ray_start_regular_shared_2_cpus, parquet_path
):
ds = ray.data.read_parquet(str(parquet_path)).drop_columns(["b"])
# Reshaped to a ``Project`` (not a UDF ``MapBatches``), so the
# typed schema chain survives.
assert isinstance(ds._logical_plan.dag, Project)
assert _static_schema(ds) == pa.schema(
[pa.field("a", pa.int32()), pa.field("k", pa.string())]
)
def test_drop_columns_missing_raises_eagerly(
self, ray_start_regular_shared_2_cpus, parquet_path
):
ds = ray.data.read_parquet(str(parquet_path))
with pytest.raises(KeyError, match="not found in dataset schema"):
ds.drop_columns(["does_not_exist"])
def test_drop_columns_after_map_batches_falls_back(
self, ray_start_regular_shared_2_cpus, parquet_path
):
# Input schema is unknown downstream of ``map_batches``, so the
# implementation falls back to a ``MapBatches`` closure and the
# typed-chain guarantee no longer holds.
ds = (
ray.data.read_parquet(str(parquet_path))
.map_batches(lambda b: b)
.drop_columns(["b"])
)
assert ds.schema(fetch_if_missing=False) is None
class TestAggregate:
def test_groupby_multi_aggs(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = (
ray.data.read_parquet(str(parquet_path))
.groupby("k")
.aggregate(Sum("a"), Mean("b"), Count("a"), Max("a"), Min("a"))
)
assert _static_schema(ds) == pa.schema(
[
pa.field("k", pa.string()),
pa.field("sum(a)", pa.int64()),
pa.field("mean(b)", pa.float64()),
pa.field("count(a)", pa.int64(), nullable=False),
pa.field("max(a)", pa.int32()),
pa.field("min(a)", pa.int32()),
]
)
def test_groupby_multi_key(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = (
ray.data.read_parquet(str(parquet_path))
.groupby(["k", "a"])
.aggregate(Count("b"))
)
assert _static_schema(ds) == pa.schema(
[
pa.field("k", pa.string()),
pa.field("a", pa.int32()),
pa.field("count(b)", pa.int64(), nullable=False),
]
)
def test_groupby_then_sort(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = (
ray.data.read_parquet(str(parquet_path))
.groupby("k")
.aggregate(Sum("a"))
.sort("k")
)
assert _static_schema(ds) == pa.schema(
[pa.field("k", pa.string()), pa.field("sum(a)", pa.int64())]
)
class TestNAry:
def test_union(self, ray_start_regular_shared_2_cpus, parquet_path):
# Disjoint-but-overlapping column sets exercise the schema
# unification: ``a`` only in the first input, ``k`` only in the
# second, ``b`` shared. The output is the merged superset.
ds_a = ray.data.read_parquet(str(parquet_path)).select_columns(["a", "b"])
ds_b = ray.data.read_parquet(str(parquet_path)).select_columns(["b", "k"])
ds = ds_a.union(ds_b)
assert _static_schema(ds) == pa.schema(
[
pa.field("a", pa.int32()),
pa.field("b", pa.float32()),
pa.field("k", pa.string()),
]
)
def test_zip_disjoint_columns(self, ray_start_regular_shared_2_cpus, parquet_path):
ds_a = ray.data.read_parquet(str(parquet_path)).select_columns(["a"])
ds_b = ray.data.read_parquet(str(parquet_path)).select_columns(["b"])
ds = ds_a.zip(ds_b)
assert _static_schema(ds) == pa.schema(
[pa.field("a", pa.int32()), pa.field("b", pa.float32())]
)
def test_zip_overlapping_columns_get_suffixed(
self, ray_start_regular_shared_2_cpus, parquet_path
):
# When both inputs have a column named "a", the second input's
# column is renamed to "a_1" to match runtime ``_zip`` behavior.
ds_a = ray.data.read_parquet(str(parquet_path)).select_columns(["a"])
ds_b = ray.data.read_parquet(str(parquet_path)).select_columns(["a"])
ds = ds_a.zip(ds_b)
assert _static_schema(ds) == pa.schema(
[pa.field("a", pa.int32()), pa.field("a_1", pa.int32())]
)
def test_union_incompatible_column_types_returns_none(
self, ray_start_regular_shared_2_cpus, tmp_path
):
# Same column name, irreconcilable types: ``infer_schema`` must
# return None (and ``Dataset.schema(fetch_if_missing=False)`` along
# with it), not surface an ArrowTypeError from unify_schemas.
a_path = tmp_path / "a.parquet"
b_path = tmp_path / "b.parquet"
pq.write_table(pa.table({"x": pa.array([1, 2], type=pa.int32())}), a_path)
pq.write_table(
pa.table({"x": pa.array([[1.0], [2.0]], type=pa.list_(pa.float64()))}),
b_path,
)
ds = ray.data.read_parquet(str(a_path)).union(
ray.data.read_parquet(str(b_path))
)
assert ds.schema(fetch_if_missing=False) is None
class TestJoin:
def test_inner_join(self, ray_start_regular_shared_2_cpus, tmp_path):
left = pa.table(
{
"k": pa.array(["a", "b", "c"]),
"lval": pa.array([1, 2, 3], type=pa.int32()),
}
)
right = pa.table(
{
"k": pa.array(["a", "b", "c"]),
"rval": pa.array([10.0, 20.0, 30.0], type=pa.float32()),
}
)
l_path = tmp_path / "left.parquet"
r_path = tmp_path / "right.parquet"
pq.write_table(left, l_path)
pq.write_table(right, r_path)
ds = ray.data.read_parquet(str(l_path)).join(
ray.data.read_parquet(str(r_path)),
on=("k",),
join_type="inner",
num_partitions=2,
)
assert _static_schema(ds) == pa.schema(
[
pa.field("k", pa.string()),
pa.field("lval", pa.int32()),
pa.field("rval", pa.float32()),
]
)
class TestUDFFallback:
def test_map_batches_returns_none(
self, ray_start_regular_shared_2_cpus, parquet_path
):
# The headline guarantee inverse: UDF maps return None from
# infer_schema, so ds.schema(fetch_if_missing=False) returns None
# (it would only return a value via the limit(1) fallback, which
# we've disabled).
ds = ray.data.read_parquet(str(parquet_path)).map_batches(lambda b: b)
assert ds.schema(fetch_if_missing=False) is None
def test_select_after_map_batches_also_returns_none(
self, ray_start_regular_shared_2_cpus, parquet_path
):
# The transitive break: anything downstream of a UDF can't infer
# schema either.
ds = (
ray.data.read_parquet(str(parquet_path))
.map_batches(lambda b: b)
.select_columns(["a"])
)
assert ds.schema(fetch_if_missing=False) is None
class TestEndToEndStaticResolution:
"""The headline Phase 1 guarantee: a complex non-UDF chain resolves
via ``Dataset.schema()`` without triggering the ``limit(1)`` fallback.
The check is:
- ``ds.schema(fetch_if_missing=False)`` returns the expected schema.
Because ``fetch_if_missing=False`` disables the ``limit(1)``
fallback in ``_base_schema``, a non-``None`` return proves that
``LogicalOperator.infer_schema()`` resolved the schema statically.
"""
def test_complex_typed_chain(self, ray_start_regular_shared_2_cpus, parquet_path):
ds = (
ray.data.read_parquet(str(parquet_path))
.filter(expr=col("a") > 0)
.select_columns(["a", "b", "k"])
.with_column("s", col("a") + col("b"))
.groupby("k")
.aggregate(Sum("a"), Mean("b"))
.sort("k")
)
assert _static_schema(ds) == pa.schema(
[
pa.field("k", pa.string()),
pa.field("sum(a)", pa.int64()),
pa.field("mean(b)", pa.float64()),
]
)
class TestEagerStarExpansion:
"""``Project.__post_init__`` should expand ``StarExpr`` to
explicit ``col()`` references when the input schema is known, so
downstream optimizer rules never see ``StarExpr`` on typed chains."""
def test_with_column_expands_star(
self, ray_start_regular_shared_2_cpus, parquet_path
):
ds = ray.data.read_parquet(str(parquet_path)).with_column(
"new", col("a") + lit(10)
)
project = ds._logical_plan.dag
assert isinstance(project, Project)
# Star expanded to explicit input cols + the new aliased expr; no
# ``StarExpr`` remains on this typed chain.
assert_exprs_equal(
project.exprs,
[col("a"), col("b"), col("k"), (col("a") + lit(10)).alias("new")],
)
def test_rename_columns_expands_star(
self, ray_start_regular_shared_2_cpus, parquet_path
):
ds = ray.data.read_parquet(str(parquet_path)).rename_columns({"a": "A"})
project = ds._logical_plan.dag
assert isinstance(project, Project)
# The rename AliasExpr substitutes for its source column "a" *in
# place* (position preserved), matching runtime ``eval_projection``
# / ``exprlist_to_fields`` ordering.
assert_exprs_equal(project.exprs, [col("a")._rename("A"), col("b"), col("k")])
def test_udf_chain_preserves_star(
self, ray_start_regular_shared_2_cpus, parquet_path
):
# Input schema is unknown after map_batches, so StarExpr must
# stay for runtime ``eval_projection`` to expand per-block.
ds = (
ray.data.read_parquet(str(parquet_path))
.map_batches(lambda b: b)
.with_column("new", col("a") + lit(10))
)
project = ds._logical_plan.dag
assert isinstance(project, Project)
assert_exprs_equal(project.exprs, [star(), (col("a") + lit(10)).alias("new")])
if __name__ == "__main__":
import sys
sys.exit(pytest.main([__file__, "-xvs"]))