|
17 | 17 |
|
18 | 18 | import unittest |
19 | 19 | from dataclasses import dataclass |
| 20 | +from datetime import date |
20 | 21 | from typing import List |
| 22 | +from unittest.mock import Mock |
| 23 | + |
| 24 | +from parameterized import parameterized |
21 | 25 |
|
22 | 26 | from pypaimon.index.index_file_meta import IndexFileMeta |
23 | 27 | from pypaimon.manifest.index_manifest_entry import IndexManifestEntry |
|
27 | 31 | from pypaimon.schema.data_types import AtomicType, DataField |
28 | 32 | from pypaimon.schema.table_schema import TableSchema |
29 | 33 | from pypaimon.table.row.generic_row import GenericRow |
| 34 | +from pypaimon.utils.file_store_path_factory import FileStorePathFactory |
30 | 35 | from pypaimon.execution.python.write.commit.conflict_detection import ( |
31 | 36 | ConflictDetection, |
32 | 37 | RowIdColumnConflictChecker, |
@@ -329,6 +334,61 @@ def test_bucket_num_mismatch_conflicts(self): |
329 | 334 | self.assertIn("Total buckets", str(result)) |
330 | 335 |
|
331 | 336 |
|
| 337 | +class TestFileDeletionConflictDiagnostics(unittest.TestCase): |
| 338 | + |
| 339 | + @parameterized.expand([ |
| 340 | + ("string", [("dt", "STRING")], ["2026-10-26"], False, "dt=2026-10-26"), |
| 341 | + ("multiple", [("dt", "STRING"), ("hr", "INT")], ["2026-10-26", 12], False, |
| 342 | + "dt=2026-10-26/hr=12"), |
| 343 | + ("escaped", [("a/b", "STRING"), ("hr", "STRING")], ["x/y=z", None], False, |
| 344 | + "a%2Fb=x%2Fy%3Dz/hr=MISSING"), |
| 345 | + ("null", [("dt", "STRING")], [None], False, "dt=MISSING"), |
| 346 | + ("empty", [("dt", "STRING")], [""], False, "dt=MISSING"), |
| 347 | + ("date", [("dt", "DATE")], [date(2026, 10, 26)], False, "dt=2026-10-26"), |
| 348 | + ("legacy_date", [("dt", "DATE")], [date(1970, 1, 2)], True, "dt=1"), |
| 349 | + ("non_partitioned", [], [], False, "<non-partitioned>"), |
| 350 | + ]) |
| 351 | + def test_missing_file_reports_partition_and_bucket( |
| 352 | + self, _, field_types, values, legacy, expected): |
| 353 | + fields = [DataField(i, name, AtomicType(type_name)) |
| 354 | + for i, (name, type_name) in enumerate(field_types)] |
| 355 | + table = Mock() |
| 356 | + table.path_factory.return_value = FileStorePathFactory( |
| 357 | + root="/table", partition_keys=[f.name for f in fields], |
| 358 | + partition_types=[f.type for f in fields], default_part_value="MISSING", |
| 359 | + format_identifier="parquet", data_file_prefix="data-", |
| 360 | + changelog_file_prefix="changelog-", legacy_partition_name=legacy, |
| 361 | + file_suffix_include_compression=False, file_compression="zstd", |
| 362 | + data_file_path_directory="data") |
| 363 | + |
| 364 | + result = self._missing_file_conflict(table, fields, values) |
| 365 | + |
| 366 | + self.assertIsInstance(result, RuntimeError) |
| 367 | + self.assertIn("Trying to delete file missing.parquet which is not previously added", str(result)) |
| 368 | + self.assertIn("partition={}, bucket=3".format(expected), str(result)) |
| 369 | + if not fields: |
| 370 | + table.path_factory.assert_not_called() |
| 371 | + |
| 372 | + def test_partition_formatting_failure_preserves_conflict(self): |
| 373 | + table = Mock() |
| 374 | + table.path_factory.return_value._canonical_partition.side_effect = ValueError("invalid partition") |
| 375 | + |
| 376 | + result = self._missing_file_conflict( |
| 377 | + table, [DataField(0, "dt", AtomicType("STRING"))], ["2026-10-26"]) |
| 378 | + |
| 379 | + self.assertIsInstance(result, RuntimeError) |
| 380 | + self.assertIn("Trying to delete file missing.parquet which is not previously added", str(result)) |
| 381 | + self.assertIn("partition=('2026-10-26',), bucket=3", str(result)) |
| 382 | + self.assertNotIn("invalid partition", str(result)) |
| 383 | + |
| 384 | + @staticmethod |
| 385 | + def _missing_file_conflict(table, fields, values): |
| 386 | + entry = _make_entry("missing.parquet", kind=1, bucket=3) |
| 387 | + entry.partition = GenericRow(values, fields) |
| 388 | + detection = ConflictDetection(False, None, None, table, None) |
| 389 | + return detection.check_conflicts(None, [], [entry], "OVERWRITE") |
| 390 | + |
| 391 | + |
332 | 392 | class _FakeSnapshot: |
333 | 393 |
|
334 | 394 | def __init__(self, snapshot_id, commit_kind, next_row_id=None): |
|
0 commit comments