From ba94773660762bd4f4ed53c35d699b641af78128 Mon Sep 17 00:00:00 2001 From: Shiyan Xu <2701446+xushiyan@users.noreply.github.com> Date: Mon, 1 Apr 2024 18:41:45 -0500 Subject: [PATCH] [FEAT] Support Hudi reader (#2011) - Add basic Hudi APIs - Add basic Hudi COW reader support --- daft/__init__.py | 2 + daft/hudi/__init__.py | 0 daft/hudi/hudi_scan.py | 139 +++++++++++++++++ daft/hudi/pyhudi/__init__.py | 0 daft/hudi/pyhudi/filegroup.py | 88 +++++++++++ daft/hudi/pyhudi/table.py | 171 +++++++++++++++++++++ daft/hudi/pyhudi/timeline.py | 66 ++++++++ daft/hudi/pyhudi/utils.py | 81 ++++++++++ daft/io/__init__.py | 2 + daft/io/_hudi.py | 42 +++++ tests/io/hudi/__init__.py | 0 tests/io/hudi/conftest.py | 15 ++ tests/io/hudi/data/0.x_cow_partitioned.zip | Bin 0 -> 46809 bytes tests/io/hudi/test_table_read.py | 24 +++ 14 files changed, 630 insertions(+) create mode 100644 daft/hudi/__init__.py create mode 100644 daft/hudi/hudi_scan.py create mode 100644 daft/hudi/pyhudi/__init__.py create mode 100644 daft/hudi/pyhudi/filegroup.py create mode 100644 daft/hudi/pyhudi/table.py create mode 100644 daft/hudi/pyhudi/timeline.py create mode 100644 daft/hudi/pyhudi/utils.py create mode 100644 daft/io/_hudi.py create mode 100644 tests/io/hudi/__init__.py create mode 100644 tests/io/hudi/conftest.py create mode 100644 tests/io/hudi/data/0.x_cow_partitioned.zip create mode 100644 tests/io/hudi/test_table_read.py diff --git a/daft/__init__.py b/daft/__init__.py index 9069810c42..71845465d8 100644 --- a/daft/__init__.py +++ b/daft/__init__.py @@ -73,6 +73,7 @@ def get_build_type() -> str: from_glob_path, read_csv, read_delta_lake, + read_hudi, read_iceberg, read_json, read_parquet, @@ -93,6 +94,7 @@ def get_build_type() -> str: "read_csv", "read_json", "read_parquet", + "read_hudi", "read_iceberg", "read_delta_lake", "read_sql", diff --git a/daft/hudi/__init__.py b/daft/hudi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/daft/hudi/hudi_scan.py b/daft/hudi/hudi_scan.py new file mode 100644 index 0000000000..a860113d07 --- /dev/null +++ b/daft/hudi/hudi_scan.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import logging +import os +from collections.abc import Iterator + +import daft +from daft.daft import ( + FileFormatConfig, + ParquetSourceConfig, + Pushdowns, + ScanTask, + StorageConfig, +) +from daft.filesystem import _resolve_paths_and_filesystem +from daft.hudi.pyhudi.table import HudiTable, HudiTableMetadata +from daft.io.scan import PartitionField, ScanOperator +from daft.logical.schema import Schema + +logger = logging.getLogger(__name__) + + +class HudiScanOperator(ScanOperator): + def __init__(self, table_uri: str, storage_config: StorageConfig) -> None: + super().__init__() + resolved_path, resolved_fs = _resolve_paths_and_filesystem(table_uri, storage_config.config.io_config) + self._table = HudiTable(table_uri, resolved_fs, resolved_path[0]) + self._storage_config = storage_config + self._schema = Schema.from_pyarrow_schema(self._table.schema) + partition_fields = set(self._table.props.partition_fields) + self._partition_keys = [ + PartitionField(field._field) for field in self._schema if field.name in partition_fields + ] + + def schema(self) -> Schema: + return self._schema + + def display_name(self) -> str: + return f"HudiScanOperator({self._table.props.name})" + + def partitioning_keys(self) -> list[PartitionField]: + return self._partition_keys + + def multiline_display(self) -> list[str]: + return [ + self.display_name(), + f"Schema = {self._schema}", + f"Partitioning keys = {self.partitioning_keys()}", + f"Storage config = {self._storage_config}", + ] + + def to_scan_tasks(self, pushdowns: Pushdowns) -> Iterator[ScanTask]: + import pyarrow as pa + + hudi_table_metadata: HudiTableMetadata = self._table.latest_table_metadata() + files_metadata = hudi_table_metadata.files_metadata + + if len(self.partitioning_keys()) > 0 and pushdowns.partition_filters is None: + logging.warning( + f"{self.display_name()} has partitioning keys = {self.partitioning_keys()}, but no partition filter was specified. This will result in a full table scan." + ) + + limit_files = pushdowns.limit is not None and pushdowns.filters is None and pushdowns.partition_filters is None + rows_left = pushdowns.limit if pushdowns.limit is not None else 0 + scan_tasks = [] + for task_idx in range(files_metadata.num_rows): + if limit_files and rows_left <= 0: + break + + path = os.path.join(self._table.table_uri, files_metadata["path"][task_idx].as_py()) + record_count = files_metadata["num_records"][task_idx].as_py() + try: + size_bytes = files_metadata["size_bytes"][task_idx].as_py() + except KeyError: + size_bytes = None + file_format_config = FileFormatConfig.from_parquet_config(ParquetSourceConfig()) + + if self._table.is_partitioned: + dtype = files_metadata.schema.field("partition_values").type + part_values = files_metadata["partition_values"][task_idx] + arrays = {} + for field_idx in range(dtype.num_fields): + field_name = dtype.field(field_idx).name + try: + arrow_arr = pa.array([part_values[field_name]], type=dtype.field(field_idx).type) + except (pa.ArrowInvalid, pa.ArrowTypeError, pa.ArrowNotImplementedError): + # pyarrow < 13.0.0 doesn't accept pyarrow scalars in the array constructor. + arrow_arr = pa.array([part_values[field_name].as_py()], type=dtype.field(field_idx).type) + arrays[field_name] = daft.Series.from_arrow(arrow_arr, field_name) + partition_values = daft.table.Table.from_pydict(arrays)._table + else: + partition_values = None + + # Populate scan task with column-wise stats. + schema = self._table.schema + min_values = hudi_table_metadata.colstats_min_values + max_values = hudi_table_metadata.colstats_max_values + arrays = {} + for field_idx in range(len(schema)): + field_name = schema.field(field_idx).name + field_type = schema.field(field_idx).type + try: + arrow_arr = pa.array( + [min_values[field_name][task_idx], max_values[field_name][task_idx]], type=field_type + ) + except (pa.ArrowInvalid, pa.ArrowTypeError, pa.ArrowNotImplementedError): + # pyarrow < 13.0.0 doesn't accept pyarrow scalars in the array constructor. + arrow_arr = pa.array( + [min_values[field_name][task_idx].as_py(), max_values[field_name][task_idx].as_py()], + type=field_type, + ) + arrays[field_name] = daft.Series.from_arrow(arrow_arr, field_name) + stats = daft.table.Table.from_pydict(arrays)._table + + st = ScanTask.catalog_scan_task( + file=path, + file_format=file_format_config, + schema=self._schema._schema, + num_rows=record_count, + storage_config=self._storage_config, + size_bytes=size_bytes, + pushdowns=pushdowns, + partition_values=partition_values, + stats=stats, + ) + if st is None: + continue + rows_left -= record_count + scan_tasks.append(st) + return iter(scan_tasks) + + def can_absorb_filter(self) -> bool: + return False + + def can_absorb_limit(self) -> bool: + return False + + def can_absorb_select(self) -> bool: + return True diff --git a/daft/hudi/pyhudi/__init__.py b/daft/hudi/pyhudi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/daft/hudi/pyhudi/filegroup.py b/daft/hudi/pyhudi/filegroup.py new file mode 100644 index 0000000000..298726d2ea --- /dev/null +++ b/daft/hudi/pyhudi/filegroup.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +import pyarrow as pa +from sortedcontainers import SortedDict + +from daft.hudi.pyhudi.utils import FsFileMetadata + + +@dataclass(init=False) +class BaseFile: + def __init__(self, fs_metadata: FsFileMetadata): + self.metadata = fs_metadata + file_name = fs_metadata.base_name + self.file_name = file_name + file_group_id, _, commit_time_ext = file_name.split("_") + self.file_group_id = file_group_id + self.commit_time = commit_time_ext.split(".")[0] + + @property + def path(self) -> str: + return self.metadata.path + + @property + def size(self) -> int: + return self.metadata.size + + @property + def num_records(self) -> int: + return self.metadata.num_records + + @property + def schema(self) -> pa.Schema: + return self.metadata.schema + + @property + def min_values(self): + return self.metadata.min_values + + @property + def max_values(self): + return self.metadata.max_values + + +@dataclass +class FileSlice: + FILES_METADATA_SCHEMA = pa.schema( + [ + ("path", pa.string()), + ("size", pa.uint32()), + ("num_records", pa.uint32()), + ("partition_path", pa.string()), + ] + ) + + file_group_id: str + partition_path: str + base_instant_time: str + base_file: BaseFile + + @property + def files_metadata(self): + return self.base_file.path, self.base_file.size, self.base_file.num_records, self.partition_path + + @property + def colstats_min_max(self): + return self.base_file.min_values, self.base_file.max_values + + +@dataclass +class FileGroup: + file_group_id: str + partition_path: str + file_slices: SortedDict[str, FileSlice] = field(default_factory=SortedDict) + + def add_base_file(self, base_file: BaseFile): + ct = base_file.commit_time + if ct in self.file_slices: + self.file_slices.get(ct).base_file = base_file + else: + self.file_slices[ct] = FileSlice(self.file_group_id, self.partition_path, ct, base_file) + + def get_latest_file_slice(self) -> FileSlice | None: + if not self.file_slices: + return None + + return self.file_slices.peekitem(-1)[1] diff --git a/daft/hudi/pyhudi/table.py b/daft/hudi/pyhudi/table.py new file mode 100644 index 0000000000..3f1dd893d4 --- /dev/null +++ b/daft/hudi/pyhudi/table.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import os +from collections import defaultdict +from dataclasses import dataclass + +import pyarrow as pa +import pyarrow.fs as pafs + +from daft.hudi.pyhudi.filegroup import BaseFile, FileGroup, FileSlice +from daft.hudi.pyhudi.timeline import Timeline +from daft.hudi.pyhudi.utils import ( + list_full_sub_dirs, + list_leaf_dirs, + list_relative_file_paths, +) + +# TODO(Shiyan): support base file in .orc +BASE_FILE_EXTENSIONS = [".parquet"] + + +@dataclass +class MetaClient: + fs: pafs.FileSystem + base_path: str + timeline: Timeline | None + + def get_active_timeline(self) -> Timeline: + if not self.timeline: + self.timeline = Timeline(self.base_path, self.fs) + return self.timeline + + def get_partition_paths(self, relative=True) -> list[str]: + first_level_full_partition_paths = list_full_sub_dirs(self.base_path, self.fs, excludes=[".hoodie"]) + partition_paths = [] + for p in first_level_full_partition_paths: + partition_paths.extend(list_leaf_dirs(p, self.fs)) + + common_prefix_len = len(self.base_path) + 1 if relative else 0 + return [p[common_prefix_len:] for p in partition_paths] + + def get_file_groups(self, partition_path: str) -> list[FileGroup]: + base_file_metadata = list_relative_file_paths( + self.base_path, partition_path, self.fs, includes=BASE_FILE_EXTENSIONS + ) + fg_id_to_base_files = defaultdict(list) + for metadata in base_file_metadata: + base_file = BaseFile(metadata) + fg_id_to_base_files[base_file.file_group_id].append(base_file) + file_groups = [] + for fg_id, base_files in fg_id_to_base_files.items(): + file_group = FileGroup(fg_id, partition_path) + for base_file in base_files: + file_group.add_base_file(base_file) + file_groups.append(file_group) + return file_groups + + +@dataclass(init=False) +class FileSystemView: + def __init__(self, meta_client: MetaClient): + self.meta_client = meta_client + self.partition_to_file_groups: dict[str, list[FileGroup]] = {} + self._load_partitions() + + def _load_partitions(self): + partition_paths = self.meta_client.get_partition_paths() + for partition_path in partition_paths: + self._load_partition(partition_path) + + def _load_partition(self, partition_path: str): + file_groups = self.meta_client.get_file_groups(partition_path) + self.partition_to_file_groups[partition_path] = file_groups + + def get_latest_file_slices(self) -> list[FileSlice]: + file_slices = [] + for file_groups in self.partition_to_file_groups.values(): + for file_group in file_groups: + file_slice = file_group.get_latest_file_slice() + if file_slice is not None: + file_slices.append(file_slice) + + return file_slices + + +@dataclass(init=False) +class HudiTableProps: + def __init__(self, fs: pafs.FileSystem, table_uri: str): + self._props = {} + hoodie_properties_file = os.path.join(table_uri, ".hoodie", "hoodie.properties") + with fs.open_input_file(hoodie_properties_file) as f: + lines = f.readall().decode("utf-8").splitlines() + for line in lines: + line = line.strip() + if not line or line.startswith("#"): + continue + key, value = line.split("=") + self._props[key] = value + + @property + def name(self) -> str: + return self._props["hoodie.table.name"] + + @property + def partition_fields(self) -> list[str]: + return self._props["hoodie.table.partition.fields"] + + def get_config(self, key: str) -> str: + return self._props[key] + + +@dataclass +class HudiTableMetadata: + + files_metadata: pa.RecordBatch + colstats_min_values: pa.RecordBatch + colstats_max_values: pa.RecordBatch + + +class UnsupportedException(Exception): + pass + + +@dataclass(init=False) +class HudiTable: + def __init__(self, table_uri: str, fs: pafs.FileSystem, base_path: str): + self.table_uri = table_uri + self._meta_client = MetaClient(fs, base_path, timeline=None) + self._props = HudiTableProps(fs, base_path) + self._validate_table_props() + + def _validate_table_props(self): + if self._props.get_config("hoodie.table.type") != "COPY_ON_WRITE": + raise UnsupportedException("Only support COPY_ON_WRITE table") + if self._props.get_config("hoodie.table.keygenerator.class") != "org.apache.hudi.keygen.SimpleKeyGenerator": + raise UnsupportedException("Only support using Simple Key Generator") + + def latest_table_metadata(self) -> HudiTableMetadata: + file_slices = FileSystemView(self._meta_client).get_latest_file_slices() + files_metadata = [] + min_vals_arr = [] + max_vals_arr = [] + for file_slice in file_slices: + files_metadata.append(file_slice.files_metadata) + min_vals, max_vals = file_slice.colstats_min_max + min_vals_arr.append(min_vals) + max_vals_arr.append(max_vals) + metadata_arrays = [pa.array(column) for column in list(zip(*files_metadata))] + min_value_arrays = [pa.array(column) for column in list(zip(*min_vals_arr))] + max_value_arrays = [pa.array(column) for column in list(zip(*max_vals_arr))] + return HudiTableMetadata( + pa.RecordBatch.from_arrays(metadata_arrays, schema=FileSlice.FILES_METADATA_SCHEMA), + pa.RecordBatch.from_arrays(min_value_arrays, schema=self.schema), + pa.RecordBatch.from_arrays(max_value_arrays, schema=self.schema), + ) + + @property + def base_path(self) -> str: + return self._meta_client.base_path + + @property + def schema(self) -> pa.Schema: + return self._meta_client.get_active_timeline().get_latest_commit_schema() + + @property + def is_partitioned(self) -> bool: + return self._props.partition_fields == "" + + @property + def props(self) -> HudiTableProps: + return self._props diff --git a/daft/hudi/pyhudi/timeline.py b/daft/hudi/pyhudi/timeline.py new file mode 100644 index 0000000000..57d88417d0 --- /dev/null +++ b/daft/hudi/pyhudi/timeline.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from enum import Enum + +import pyarrow as pa +import pyarrow.fs as pafs +import pyarrow.parquet as pq + + +class State(Enum): + REQUESTED = 0 + INFLIGHT = 1 + COMPLETED = 2 + + +@dataclass +class Instant: + state: State + action: str + timestamp: str + + @property + def file_name(self): + state = "" if self.state == State.COMPLETED else f".{self.state.name.lower()}" + return f"{self.timestamp}.{self.action}{state}" + + def __lt__(self, other: Instant): + return [self.timestamp, self.state] < [other.timestamp, other.state] + + +@dataclass(init=False) +class Timeline: + base_path: str + fs: pafs.FileSystem + instants: list[Instant] + + def __init__(self, base_path: str, fs: pafs.FileSystem): + self.base_path = base_path + self.fs = fs + self._load_completed_commit_instants() + + def _load_completed_commit_instants(self): + timeline_path = os.path.join(self.base_path, ".hoodie") + action = "commit" + ext = ".commit" + instants = [] + for file_info in self.fs.get_file_info(pafs.FileSelector(timeline_path)): + if file_info.base_name.endswith(ext): + timestamp = file_info.base_name[: -len(ext)] + instants.append(Instant(state=State.COMPLETED, action=action, timestamp=timestamp)) + self.instants = sorted(instants) + + def get_latest_commit_metadata(self) -> dict: + latest_instant_file_path = os.path.join(self.base_path, ".hoodie", self.instants[-1].file_name) + with self.fs.open_input_file(latest_instant_file_path) as f: + return json.load(f) + + def get_latest_commit_schema(self) -> pa.Schema: + latest_commit_metadata = self.get_latest_commit_metadata() + _, write_stats = next(iter(latest_commit_metadata["partitionToWriteStats"].items())) + base_file_path = os.path.join(self.base_path, write_stats[0]["path"]) + with self.fs.open_input_file(base_file_path) as f: + return pq.read_schema(f) diff --git a/daft/hudi/pyhudi/utils.py b/daft/hudi/pyhudi/utils.py new file mode 100644 index 0000000000..6f60df4698 --- /dev/null +++ b/daft/hudi/pyhudi/utils.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +import pyarrow as pa +import pyarrow.fs as pafs +import pyarrow.parquet as pq + + +@dataclass(init=False) +class FsFileMetadata: + def __init__(self, fs: pafs.FileSystem, base_path: str, path: str, base_name: str): + self.base_path = base_path + self.path = path + self.base_name = base_name + with fs.open_input_file(os.path.join(base_path, path)) as f: + metadata = pq.read_metadata(f) + self.size = metadata.serialized_size + self.num_records = metadata.num_rows + self.schema, self.min_values, self.max_values = FsFileMetadata._extract_min_max(metadata) + + @staticmethod + def _extract_min_max(metadata: pq.FileMetaData): + arrow_schema = pa.schema(metadata.schema.to_arrow_schema()) + n_columns = len(arrow_schema) + min_vals = [None] * n_columns + max_vals = [None] * n_columns + num_rg = metadata.num_row_groups + for rg in range(num_rg): + row_group = metadata.row_group(rg) + for col in range(n_columns): + column = row_group.column(col) + if column.is_stats_set and column.statistics.has_min_max: + if min_vals[col] is None or column.statistics.min < min_vals[col]: + min_vals[col] = column.statistics.min + if max_vals[col] is None or column.statistics.max > max_vals[col]: + max_vals[col] = column.statistics.max + return arrow_schema, min_vals, max_vals + + +def list_relative_file_paths( + base_path: str, sub_path: str, fs: pafs.FileSystem, includes: list[str] | None +) -> list[FsFileMetadata]: + listed_paths: list[pafs.FileInfo] = fs.get_file_info(pafs.FileSelector(os.path.join(base_path, sub_path))) + file_paths = [] + common_prefix_len = len(base_path) + 1 + for listed_path in listed_paths: + if listed_path.type == pafs.FileType.File: + if includes and os.path.splitext(listed_path.base_name)[-1] in includes: + file_paths.append( + FsFileMetadata(fs, base_path, listed_path.path[common_prefix_len:], listed_path.base_name) + ) + + return file_paths + + +def list_full_sub_dirs(path: str, fs: pafs.FileSystem, excludes: list[str] | None) -> list[str]: + sub_paths: list[pafs.FileInfo] = fs.get_file_info(pafs.FileSelector(path)) + sub_dirs = [] + for sub_path in sub_paths: + if sub_path.type == pafs.FileType.Directory: + if not excludes or (excludes and sub_path.base_name not in excludes): + sub_dirs.append(sub_path.path) + + return sub_dirs + + +def list_leaf_dirs(path: str, fs: pafs.FileSystem) -> list[str]: + sub_paths: list[pafs.FileInfo] = fs.get_file_info(pafs.FileSelector(path)) + leaf_dirs = [] + + for sub_path in sub_paths: + if sub_path.type == pafs.FileType.Directory: + leaf_dirs.extend(list_leaf_dirs(sub_path.path, fs)) + + # leaf directory + if len(leaf_dirs) == 0: + leaf_dirs.append(path) + + return leaf_dirs diff --git a/daft/io/__init__.py b/daft/io/__init__.py index ce21b86cf5..77b30d7142 100644 --- a/daft/io/__init__.py +++ b/daft/io/__init__.py @@ -11,6 +11,7 @@ ) from daft.io._csv import read_csv from daft.io._delta_lake import read_delta_lake +from daft.io._hudi import read_hudi from daft.io._iceberg import read_iceberg from daft.io._json import read_json from daft.io._parquet import read_parquet @@ -38,6 +39,7 @@ def _set_linux_cert_paths(): "read_json", "from_glob_path", "read_parquet", + "read_hudi", "read_iceberg", "read_delta_lake", "read_sql", diff --git a/daft/io/_hudi.py b/daft/io/_hudi.py new file mode 100644 index 0000000000..f6ea93947c --- /dev/null +++ b/daft/io/_hudi.py @@ -0,0 +1,42 @@ +# isort: dont-add-import: from __future__ import annotations + +from typing import Optional + +from daft import context +from daft.api_annotations import PublicAPI +from daft.daft import IOConfig, NativeStorageConfig, ScanOperatorHandle, StorageConfig +from daft.dataframe import DataFrame +from daft.logical.builder import LogicalPlanBuilder + + +@PublicAPI +def read_hudi( + table_uri: str, + io_config: Optional["IOConfig"] = None, +) -> DataFrame: + """Create a DataFrame from a Hudi table. + + Example: + >>> df = daft.read_hudi("some-table-uri") + >>> df = df.where(df["foo"] > 5) + >>> df.show() + + Args: + table_uri: URI to the Hudi table. + io_config: A custom IOConfig to use when accessing Hudi table object storage data. Defaults to None. + + Returns: + DataFrame: A DataFrame with the schema converted from the specified Hudi table. + """ + from daft.hudi.hudi_scan import HudiScanOperator + + io_config = context.get_context().daft_planning_config.default_io_config if io_config is None else io_config + + multithreaded_io = not context.get_context().is_ray_runner + storage_config = StorageConfig.native(NativeStorageConfig(multithreaded_io, io_config)) + + hudi_operator = HudiScanOperator(table_uri, storage_config=storage_config) + + handle = ScanOperatorHandle.from_python_scan_operator(hudi_operator) + builder = LogicalPlanBuilder.from_tabular_scan(scan_operator=handle) + return DataFrame(builder) diff --git a/tests/io/hudi/__init__.py b/tests/io/hudi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/io/hudi/conftest.py b/tests/io/hudi/conftest.py new file mode 100644 index 0000000000..6bf3803d4e --- /dev/null +++ b/tests/io/hudi/conftest.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import os +import zipfile +from pathlib import Path + +import pytest + + +@pytest.fixture +def unzip_table_0_x_cow_partitioned(tmp_path): + zip_file_path = Path(__file__).parent.joinpath("data", "0.x_cow_partitioned.zip") + with zipfile.ZipFile(zip_file_path, "r") as zip_ref: + zip_ref.extractall(tmp_path) + return os.path.join(tmp_path, "trips_table") diff --git a/tests/io/hudi/data/0.x_cow_partitioned.zip b/tests/io/hudi/data/0.x_cow_partitioned.zip new file mode 100644 index 0000000000000000000000000000000000000000..8e5482601ab03a50e6937f169b05a4fecd25205b GIT binary patch literal 46809 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 literal 0 HcmV?d00001 diff --git a/tests/io/hudi/test_table_read.py b/tests/io/hudi/test_table_read.py new file mode 100644 index 0000000000..6d8cc639fe --- /dev/null +++ b/tests/io/hudi/test_table_read.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import daft + + +def test_hudi_read_table(unzip_table_0_x_cow_partitioned): + path = unzip_table_0_x_cow_partitioned + df = daft.read_hudi(path) + assert df.schema().column_names() == [ + "_hoodie_commit_time", + "_hoodie_commit_seqno", + "_hoodie_record_key", + "_hoodie_partition_path", + "_hoodie_file_name", + "ts", + "uuid", + "rider", + "driver", + "fare", + "city", + ] + assert df.select("rider").sort("rider").to_pydict() == { + "rider": ["rider-A", "rider-C", "rider-D", "rider-F", "rider-J"] + }