blob: 484cd052fffe1fb67d240660804e90b30f910450 [file]
################################################################################
# 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()