|
14 | 14 | # KIND, either express or implied. See the License for the |
15 | 15 | # specific language governing permissions and limitations |
16 | 16 | # under the License. |
| 17 | +import subprocess |
| 18 | +import sys |
| 19 | +import textwrap |
17 | 20 | from pathlib import PosixPath |
18 | 21 |
|
19 | 22 | import pyarrow as pa |
|
23 | 26 |
|
24 | 27 | from pyiceberg.catalog import Catalog |
25 | 28 | from pyiceberg.exceptions import NoSuchTableError |
26 | | -from pyiceberg.expressions import AlwaysTrue, And, EqualTo, Reference |
| 29 | +from pyiceberg.expressions import ( |
| 30 | + AlwaysFalse, |
| 31 | + AlwaysTrue, |
| 32 | + And, |
| 33 | + EqualTo, |
| 34 | + GreaterThanOrEqual, |
| 35 | + IsNull, |
| 36 | + LessThanOrEqual, |
| 37 | + Or, |
| 38 | + Reference, |
| 39 | +) |
27 | 40 | from pyiceberg.expressions.literals import LongLiteral |
28 | 41 | from pyiceberg.io.pyarrow import schema_to_pyarrow |
29 | 42 | from pyiceberg.schema import Schema |
30 | 43 | from pyiceberg.table import Table, UpsertResult |
31 | 44 | from pyiceberg.table.snapshots import Operation |
32 | | -from pyiceberg.table.upsert_util import create_match_filter |
| 45 | +from pyiceberg.table.upsert_util import create_file_match_filter, create_match_filter |
33 | 46 | from pyiceberg.types import IntegerType, NestedField, StringType, StructType |
34 | 47 | from tests.catalog.test_base import InMemoryCatalog |
35 | 48 |
|
@@ -443,6 +456,138 @@ def test_create_match_filter_single_condition() -> None: |
443 | 456 | ) |
444 | 457 |
|
445 | 458 |
|
| 459 | +def test_create_file_match_filter_empty_source_prunes_everything() -> None: |
| 460 | + table = pa.table({"order_id": pa.array([], type=pa.int64()), "order_line_id": pa.array([], type=pa.int64())}) |
| 461 | + |
| 462 | + assert create_file_match_filter(table, ["order_id", "order_line_id"]) == AlwaysFalse() |
| 463 | + |
| 464 | + |
| 465 | +def test_create_file_match_filter_multi_column_bounds() -> None: |
| 466 | + table = pa.table({"order_id": [1, 2, 3], "order_line_id": [100, 200, 300]}) |
| 467 | + |
| 468 | + expr = create_file_match_filter(table, ["order_id", "order_line_id"]) |
| 469 | + |
| 470 | + leaves: list[object] = [] |
| 471 | + |
| 472 | + def collect(node: object) -> None: |
| 473 | + if isinstance(node, And): |
| 474 | + collect(node.left) |
| 475 | + collect(node.right) |
| 476 | + else: |
| 477 | + leaves.append(node) |
| 478 | + |
| 479 | + collect(expr) |
| 480 | + bounds_by_col: dict[str, dict[type, object]] = {} |
| 481 | + for leaf in leaves: |
| 482 | + bounds_by_col.setdefault(leaf.term.name, {})[type(leaf)] = leaf.literal.value # type: ignore[attr-defined] |
| 483 | + |
| 484 | + assert bounds_by_col == { |
| 485 | + "order_id": {GreaterThanOrEqual: 1, LessThanOrEqual: 3}, |
| 486 | + "order_line_id": {GreaterThanOrEqual: 100, LessThanOrEqual: 300}, |
| 487 | + } |
| 488 | + |
| 489 | + |
| 490 | +@pytest.mark.parametrize( |
| 491 | + ("values", "expected"), |
| 492 | + [ |
| 493 | + ([1, 5], And(GreaterThanOrEqual("order_id", 1), LessThanOrEqual("order_id", 5))), |
| 494 | + ([None, None], IsNull("order_id")), |
| 495 | + ([1, None, 5], Or(And(GreaterThanOrEqual("order_id", 1), LessThanOrEqual("order_id", 5)), IsNull("order_id"))), |
| 496 | + ], |
| 497 | +) |
| 498 | +def test_create_file_match_filter_null_shape(values: list[int | None], expected: object) -> None: |
| 499 | + table = pa.table({"order_id": pa.array(values, type=pa.int64())}) |
| 500 | + |
| 501 | + assert create_file_match_filter(table, ["order_id"]) == expected |
| 502 | + |
| 503 | + |
| 504 | +def test_upsert_multi_col_file_match_filter_culls_false_positives(catalog: Catalog) -> None: |
| 505 | + identifier = "default.test_upsert_multi_col_file_match_filter_culls_false_positives" |
| 506 | + _drop_table(catalog, identifier) |
| 507 | + |
| 508 | + schema = pa.schema([("order_id", pa.int32()), ("order_line_id", pa.int32()), ("payload", pa.string())]) |
| 509 | + table = catalog.create_table(identifier, schema) |
| 510 | + table.append( |
| 511 | + pa.Table.from_pylist( |
| 512 | + [ |
| 513 | + {"order_id": 1, "order_line_id": 200, "payload": "keep-1"}, |
| 514 | + {"order_id": 2, "order_line_id": 100, "payload": "keep-2"}, |
| 515 | + {"order_id": 1, "order_line_id": 100, "payload": "old"}, |
| 516 | + ], |
| 517 | + schema=schema, |
| 518 | + ) |
| 519 | + ) |
| 520 | + |
| 521 | + source = pa.Table.from_pylist( |
| 522 | + [ |
| 523 | + {"order_id": 1, "order_line_id": 100, "payload": "new"}, |
| 524 | + {"order_id": 2, "order_line_id": 200, "payload": "insert"}, |
| 525 | + ], |
| 526 | + schema=schema, |
| 527 | + ) |
| 528 | + |
| 529 | + result = table.upsert(source, join_cols=["order_id", "order_line_id"]) |
| 530 | + |
| 531 | + assert result.rows_updated == 1 |
| 532 | + assert result.rows_inserted == 1 |
| 533 | + rows_by_key = {(row["order_id"], row["order_line_id"]): row["payload"] for row in table.scan().to_arrow().to_pylist()} |
| 534 | + assert rows_by_key == { |
| 535 | + (1, 200): "keep-1", |
| 536 | + (2, 100): "keep-2", |
| 537 | + (1, 100): "new", |
| 538 | + (2, 200): "insert", |
| 539 | + } |
| 540 | + |
| 541 | + |
| 542 | +def test_upsert_large_composite_key_initial_scan_does_not_recurse(tmp_path: PosixPath) -> None: |
| 543 | + """Regression: initial scan planning must not build the exact composite-key tree. |
| 544 | +
|
| 545 | + Running this in a subprocess keeps the test runner alive on runtimes where the |
| 546 | + old recursive visitor shape can overflow the C stack. |
| 547 | + """ |
| 548 | + script = textwrap.dedent( |
| 549 | + f""" |
| 550 | + import sys |
| 551 | +
|
| 552 | + sys.setrecursionlimit(10**7) |
| 553 | +
|
| 554 | + import pyarrow as pa |
| 555 | +
|
| 556 | + from tests.catalog.test_base import InMemoryCatalog |
| 557 | +
|
| 558 | + n = 30_000 |
| 559 | + catalog = InMemoryCatalog("test", warehouse={str(tmp_path)!r}) |
| 560 | + catalog.create_namespace("default") |
| 561 | + schema = pa.schema([ |
| 562 | + ("order_id", pa.int64()), |
| 563 | + ("order_line_id", pa.int64()), |
| 564 | + ("payload", pa.string()), |
| 565 | + ]) |
| 566 | + table = catalog.create_table("default.regression", schema) |
| 567 | + table.append( |
| 568 | + pa.Table.from_pylist( |
| 569 | + [{{"order_id": 0, "order_line_id": 100_000, "payload": "old"}}], |
| 570 | + schema=schema, |
| 571 | + ) |
| 572 | + ) |
| 573 | + source = pa.table({{ |
| 574 | + "order_id": pa.array(range(n), type=pa.int64()), |
| 575 | + "order_line_id": pa.array(range(100_000, 100_000 + n), type=pa.int64()), |
| 576 | + "payload": pa.array(["old"] * n, type=pa.string()), |
| 577 | + }}) |
| 578 | +
|
| 579 | + table.upsert( |
| 580 | + source, |
| 581 | + join_cols=["order_id", "order_line_id"], |
| 582 | + when_not_matched_insert_all=False, |
| 583 | + ) |
| 584 | + """ |
| 585 | + ) |
| 586 | + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=False) |
| 587 | + |
| 588 | + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" |
| 589 | + |
| 590 | + |
446 | 591 | def test_upsert_with_duplicate_rows_in_table(catalog: Catalog) -> None: |
447 | 592 | identifier = "default.test_upsert_with_duplicate_rows_in_table" |
448 | 593 |
|
|
0 commit comments