| ################################################################################ | |
| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| ################################################################################ | |
| import unittest | |
| from dataclasses import dataclass | |
| from typing import Optional | |
| import pyarrow as pa | |
| import pytest | |
| pypaimon = pytest.importorskip("pypaimon") | |
| daft = pytest.importorskip("daft") | |
| from pypaimon.daft.daft_datasource import ( | |
| PaimonDataSource, | |
| _native_read_fields_compatible, | |
| _native_read_types_compatible, | |
| _promote_time32_schema_for_daft, | |
| ) | |
| from pypaimon.schema.data_types import ( | |
| ArrayType, | |
| AtomicType, | |
| DataField, | |
| MapType, | |
| RowType, | |
| VectorType, | |
| ) | |
| @dataclass | |
| class _DataFile: | |
| file_path: str | |
| external_path: Optional[str] = None | |
| @dataclass | |
| class _TableSchema: | |
| id: int | |
| fields: list[DataField] | |
| class _SchemaManager: | |
| def __init__(self, schemas: list[_TableSchema]): | |
| self._schemas = {schema.id: schema for schema in schemas} | |
| def get_schema(self, schema_id: int) -> _TableSchema: | |
| return self._schemas[schema_id] | |
| @dataclass | |
| class _TableWithSchemas: | |
| table_schema: _TableSchema | |
| schema_manager: _SchemaManager | |
| def _build_uri(warehouse_scheme: str, file_path: str) -> str: | |
| class _Stub: | |
| pass | |
| stub = _Stub() | |
| stub._warehouse_scheme = warehouse_scheme | |
| return PaimonDataSource._build_file_uri(stub, file_path) | |
| class BuildFileUriTest(unittest.TestCase): | |
| def test_passes_through_when_path_already_has_scheme(self): | |
| cases = [ | |
| ("", "oss://bucket/db.db/tbl/data.parquet"), | |
| ("", "s3://bucket/key.parquet"), | |
| ("", "s3a://bucket/key.parquet"), | |
| ("", "s3n://bucket/key.parquet"), | |
| ("", "hdfs://nameservice/path/data.parquet"), | |
| ("file", "file:///abs/path/data.parquet"), | |
| ("oss", "oss://bucket/db.db/tbl/data.parquet"), | |
| ("", "oss://clg-paimon-fe4767/db.db/tbl/bucket-0/data-0.parquet"), | |
| ] | |
| for warehouse_scheme, file_path in cases: | |
| with self.subTest(warehouse_scheme=warehouse_scheme, file_path=file_path): | |
| self.assertEqual(_build_uri(warehouse_scheme, file_path), file_path) | |
| def test_adds_warehouse_scheme_when_path_unschemed(self): | |
| self.assertEqual( | |
| _build_uri("oss", "bucket/db.db/tbl/data.parquet"), | |
| "oss://bucket/db.db/tbl/data.parquet", | |
| ) | |
| def test_defaults_to_file_scheme_when_both_unschemed(self): | |
| self.assertEqual( | |
| _build_uri("", "/tmp/pytest-xxx/db.db/tbl/data.parquet"), | |
| "file:///tmp/pytest-xxx/db.db/tbl/data.parquet", | |
| ) | |
| class DataFilePathTest(unittest.TestCase): | |
| def test_prefers_external_path(self): | |
| data_file = _DataFile( | |
| file_path="file:///warehouse/db.db/tbl/bucket-0/data.parquet", | |
| external_path="s3://external-bucket/data/db.db/tbl/bucket-0/data.parquet", | |
| ) | |
| self.assertEqual( | |
| PaimonDataSource._data_file_path(data_file), | |
| "s3://external-bucket/data/db.db/tbl/bucket-0/data.parquet", | |
| ) | |
| def test_falls_back_to_file_path(self): | |
| data_file = _DataFile( | |
| file_path="file:///warehouse/db.db/tbl/bucket-0/data.parquet", | |
| external_path=None, | |
| ) | |
| self.assertEqual( | |
| PaimonDataSource._data_file_path(data_file), | |
| "file:///warehouse/db.db/tbl/bucket-0/data.parquet", | |
| ) | |
| @pytest.mark.parametrize( | |
| ("file_fields", "current_fields"), | |
| [ | |
| pytest.param( | |
| [DataField(0, "id", AtomicType("INT"))], | |
| [ | |
| DataField(0, "id", AtomicType("INT")), | |
| DataField(1, "added", AtomicType("STRING")), | |
| ], | |
| id="add-nullable-field", | |
| ), | |
| pytest.param( | |
| [ | |
| DataField(0, "id", AtomicType("INT")), | |
| DataField(1, "removed", AtomicType("STRING")), | |
| ], | |
| [DataField(0, "id", AtomicType("INT"))], | |
| id="drop-field", | |
| ), | |
| pytest.param( | |
| [ | |
| DataField(0, "id", AtomicType("INT")), | |
| DataField(1, "value", AtomicType("STRING")), | |
| ], | |
| [ | |
| DataField(1, "value", AtomicType("STRING")), | |
| DataField(0, "id", AtomicType("INT")), | |
| ], | |
| id="reorder-fields", | |
| ), | |
| ], | |
| ) | |
| def test_native_read_fields_allow_name_aligned_evolution( | |
| file_fields, | |
| current_fields, | |
| ): | |
| assert _native_read_fields_compatible(file_fields, current_fields) | |
| @pytest.mark.parametrize( | |
| ("file_field", "current_field"), | |
| [ | |
| pytest.param( | |
| DataField(1, "value", AtomicType("STRING")), | |
| DataField(1, "renamed", AtomicType("STRING")), | |
| id="rename", | |
| ), | |
| pytest.param( | |
| DataField(1, "value", AtomicType("STRING")), | |
| DataField(2, "value", AtomicType("STRING")), | |
| id="drop-then-readd", | |
| ), | |
| ], | |
| ) | |
| def test_native_read_fields_reject_field_id_mapping(file_field, current_field): | |
| assert not _native_read_fields_compatible([file_field], [current_field]) | |
| @pytest.mark.parametrize( | |
| ("file_type", "current_type", "expected"), | |
| [ | |
| pytest.param( | |
| AtomicType("INT", nullable=False), | |
| AtomicType("INT", nullable=True), | |
| True, | |
| id="relax-nullability", | |
| ), | |
| pytest.param( | |
| AtomicType("INT"), | |
| AtomicType("BIGINT"), | |
| True, | |
| id="promote-int", | |
| ), | |
| pytest.param( | |
| AtomicType("INT"), | |
| AtomicType("INT", nullable=False), | |
| False, | |
| id="tighten-nullability", | |
| ), | |
| pytest.param( | |
| AtomicType("BIGINT"), | |
| AtomicType("INT"), | |
| False, | |
| id="narrow-int", | |
| ), | |
| pytest.param( | |
| AtomicType("DECIMAL(10, 4)"), | |
| AtomicType("DECIMAL(10, 2)"), | |
| False, | |
| id="decimal-scale-down", | |
| ), | |
| pytest.param( | |
| AtomicType("TIMESTAMP(3)"), | |
| AtomicType("TIME(3)"), | |
| False, | |
| id="timestamp-to-time", | |
| ), | |
| pytest.param( | |
| AtomicType("TIME(3)"), | |
| AtomicType("TIME(3)"), | |
| False, | |
| id="unsupported-time", | |
| ), | |
| ], | |
| ) | |
| def test_native_read_atomic_type_compatibility(file_type, current_type, expected): | |
| assert _native_read_types_compatible(file_type, current_type) is expected | |
| @pytest.mark.parametrize( | |
| ("file_type", "current_type"), | |
| [ | |
| pytest.param( | |
| RowType(True, [DataField(1, "value", AtomicType("INT"))]), | |
| RowType(True, [DataField(1, "value", AtomicType("BIGINT"))]), | |
| id="row", | |
| ), | |
| pytest.param( | |
| ArrayType(True, AtomicType("INT")), | |
| ArrayType(True, AtomicType("BIGINT")), | |
| id="array", | |
| ), | |
| pytest.param( | |
| MapType(True, AtomicType("STRING"), AtomicType("INT")), | |
| MapType(True, AtomicType("STRING"), AtomicType("BIGINT")), | |
| id="map", | |
| ), | |
| pytest.param( | |
| VectorType(True, AtomicType("INT"), 3), | |
| VectorType(True, AtomicType("BIGINT"), 3), | |
| id="vector", | |
| ), | |
| ], | |
| ) | |
| def test_native_read_types_allow_recursive_promotion(file_type, current_type): | |
| assert _native_read_types_compatible(file_type, current_type) | |
| def test_native_read_types_allow_nested_name_aligned_evolution(): | |
| file_fields = [ | |
| DataField(1, "first", AtomicType("INT")), | |
| DataField(2, "second", AtomicType("STRING")), | |
| ] | |
| current_fields = [ | |
| DataField(2, "second", AtomicType("STRING")), | |
| DataField(1, "first", AtomicType("INT")), | |
| ] | |
| assert _native_read_types_compatible( | |
| RowType(True, file_fields), | |
| RowType(True, current_fields), | |
| ) | |
| def _identity(data_type): | |
| return data_type | |
| def _array(data_type): | |
| return ArrayType(True, data_type) | |
| def _map_value(data_type): | |
| return MapType(True, AtomicType("STRING"), data_type) | |
| @pytest.mark.parametrize( | |
| ("wrap", "file_child", "current_child"), | |
| [ | |
| pytest.param( | |
| _identity, | |
| DataField(1, "value", AtomicType("INT")), | |
| DataField(1, "renamed", AtomicType("INT")), | |
| id="row-nested-rename", | |
| ), | |
| pytest.param( | |
| _array, | |
| DataField(1, "value", AtomicType("INT")), | |
| DataField(2, "value", AtomicType("INT")), | |
| id="array-row-nested-drop-then-readd", | |
| ), | |
| pytest.param( | |
| _map_value, | |
| DataField(1, "value", AtomicType("INT")), | |
| DataField(1, "renamed", AtomicType("INT")), | |
| id="map-row-nested-rename", | |
| ), | |
| ], | |
| ) | |
| def test_native_read_types_reject_nested_field_id_mapping( | |
| wrap, | |
| file_child, | |
| current_child, | |
| ): | |
| file_type = wrap(RowType(True, [file_child])) | |
| current_type = wrap(RowType(True, [current_child])) | |
| assert not _native_read_types_compatible(file_type, current_type) | |
| def test_native_read_types_reject_vector_length_change(): | |
| assert not _native_read_types_compatible( | |
| VectorType(True, AtomicType("INT"), 3), | |
| VectorType(True, AtomicType("INT"), 4), | |
| ) | |
| def _row_value(data_type): | |
| return RowType(True, [DataField(1, "value", data_type)]) | |
| @pytest.mark.parametrize( | |
| ("wrap", "file_type", "current_type"), | |
| [ | |
| pytest.param( | |
| _row_value, | |
| AtomicType("DECIMAL(10, 4)"), | |
| AtomicType("DECIMAL(10, 2)"), | |
| id="row-decimal-scale-down", | |
| ), | |
| pytest.param( | |
| _array, | |
| AtomicType("TIMESTAMP(3)"), | |
| AtomicType("TIME(3)"), | |
| id="array-timestamp-to-time", | |
| ), | |
| pytest.param( | |
| _map_value, | |
| AtomicType("DECIMAL(10, 4)"), | |
| AtomicType("DECIMAL(10, 2)"), | |
| id="map-decimal-scale-down", | |
| ), | |
| ], | |
| ) | |
| def test_native_read_types_reject_semantic_mismatch_recursively( | |
| wrap, | |
| file_type, | |
| current_type, | |
| ): | |
| assert not _native_read_types_compatible( | |
| wrap(file_type), | |
| wrap(current_type), | |
| ) | |
| def test_promote_time32_schema_for_daft_recursively(): | |
| schema = pa.schema( | |
| [ | |
| ("value", pa.time32("ms")), | |
| ("row", pa.struct([("value", pa.time32("ms"))])), | |
| ("array", pa.list_(pa.time32("ms"))), | |
| ("map", pa.map_(pa.string(), pa.time32("ms"))), | |
| ] | |
| ) | |
| assert _promote_time32_schema_for_daft(schema) == pa.schema( | |
| [ | |
| ("value", pa.time64("us")), | |
| ("row", pa.struct([("value", pa.time64("us"))])), | |
| ("array", pa.list_(pa.time64("us"))), | |
| ("map", pa.map_(pa.string(), pa.time64("us"))), | |
| ] | |
| ) | |
| def test_schema_compatibility_uses_task_columns_for_mixed_schema_split(): | |
| old_schema = _TableSchema( | |
| 0, | |
| [ | |
| DataField(0, "id", AtomicType("BIGINT")), | |
| DataField(1, "value", AtomicType("STRING")), | |
| ], | |
| ) | |
| current_schema = _TableSchema( | |
| 1, | |
| [ | |
| DataField(0, "id", AtomicType("BIGINT")), | |
| DataField(1, "renamed", AtomicType("STRING")), | |
| ], | |
| ) | |
| table = _TableWithSchemas( | |
| current_schema, | |
| _SchemaManager([old_schema, current_schema]), | |
| ) | |
| assert not PaimonDataSource._has_incompatible_file_schema( | |
| table, | |
| [current_schema.id, old_schema.id], | |
| ["id"], | |
| {}, | |
| ) | |
| assert PaimonDataSource._has_incompatible_file_schema( | |
| table, | |
| [current_schema.id, old_schema.id], | |
| ["renamed"], | |
| {}, | |
| ) | |
| if __name__ == "__main__": | |
| unittest.main() |