Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions pyiceberg/table/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@

INITIAL_SEQUENCE_NUMBER = 0
INITIAL_SPEC_ID = 0
INITIAL_ROW_ID = 0
DEFAULT_SCHEMA_ID = 0

SUPPORTED_TABLE_FORMAT_VERSION = 2
Expand Down Expand Up @@ -584,9 +585,6 @@ def construct_refs(self) -> TableMetadata:
next_row_id: int | None = Field(alias="next-row-id", default=None)
"""A long higher than all assigned row IDs; the next snapshot's `first-row-id`."""

def model_dump_json(self, exclude_none: bool = True, exclude: Any | None = None, by_alias: bool = True, **kwargs: Any) -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is row lineage fully wired up? If I'm not mistaken, row lineage would be a pre-requisite for v3 enablement?

raise NotImplementedError("Writing V3 is not yet supported, see: https://github.com/apache/iceberg-python/issues/1551")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One edge case this change surfaces: the v3 spec allows an optional encryption-keys list in table metadata, and the Java TableMetadataParser writes it when present. PyIceberg doesn't model the field, and the pydantic models ignore unknown fields, so it's dropped at parse time. With serialization enabled, loading a v3 table that carries encryption-keys and committing any update would rewrite the metadata without the keys, which would break an encrypted table. Would it make sense to add a passthrough encryption-keys field to TableMetadataV3 so it round-trips, even before encryption is actually supported?



TableMetadata = Annotated[TableMetadataV1 | TableMetadataV2 | TableMetadataV3, Field(discriminator="format_version")]

Expand Down Expand Up @@ -655,6 +653,7 @@ def new_table_metadata(
properties=properties,
last_partition_id=fresh_partition_spec.last_assigned_field_id,
table_uuid=table_uuid,
next_row_id=INITIAL_ROW_ID,
)
else:
raise ValidationError(f"Unknown format version: {format_version}")
Expand Down
30 changes: 26 additions & 4 deletions tests/integration/test_writes/test_writes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2373,10 +2373,10 @@ def test_nanosecond_support_on_catalog(

_create_table(session_catalog, identifier, {"format-version": "3"}, schema=arrow_table_schema_with_all_timestamp_precisions)

with pytest.raises(NotImplementedError, match="Writing V3 is not yet supported"):
catalog.create_table(
"ns.table1", schema=arrow_table_schema_with_all_timestamp_precisions, properties={"format-version": "3"}
)
table_v3 = catalog.create_table(
"ns.table1", schema=arrow_table_schema_with_all_timestamp_precisions, properties={"format-version": "3"}
)
assert table_v3.metadata.format_version == 3

with pytest.raises(
UnsupportedPyArrowTypeException, match=re.escape("Column 'timestamp_ns' has an unsupported type: timestamp[ns]")
Expand All @@ -2386,6 +2386,28 @@ def test_nanosecond_support_on_catalog(
)


@pytest.mark.integration
def test_spark_reads_v3_table_metadata_written_by_pyiceberg(spark: SparkSession, session_catalog_hive: Catalog) -> None:
"""Spark should be able to load a V3 table whose metadata file PyIceberg wrote.

The Hive catalog is used rather than REST because REST has the server build the metadata,
which would leave PyIceberg's serialization untested. Rows cannot be written to a V3 table
yet (`_manifest_writer` rejects version 3), so this covers the metadata itself.
"""
identifier = "default.test_spark_reads_v3_table_metadata_written_by_pyiceberg"
tbl = _create_table(session_catalog_hive, identifier, {"format-version": "3"})

assert tbl.metadata.format_version == 3
assert tbl.metadata.next_row_id == 0

properties = {row.key: row.value for row in spark.sql(f"SHOW TBLPROPERTIES hive.{identifier}").collect()}
assert properties["format-version"] == "3"

df = spark.table(f"hive.{identifier}")
assert df.count() == 0
assert df.columns == [field.name for field in tbl.schema().fields]


@pytest.mark.integration
@pytest.mark.parametrize("format_version", [1, 2])
def test_stage_only_delete(
Expand Down
26 changes: 22 additions & 4 deletions tests/table/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,15 @@ def test_serialize_v2(example_table_metadata_v2: dict[str, Any]) -> None:


def test_serialize_v3(example_table_metadata_v3: dict[str, Any]) -> None:
# Writing will be part of https://github.com/apache/iceberg-python/issues/1551
table_metadata = TableMetadataV3(**example_table_metadata_v3).model_dump_json()
expected = """{"location":"s3://bucket/test/location","table-uuid":"9c12d441-03fe-4693-9a96-a0705ddf69c1","last-updated-ms":1602638573590,"last-column-id":3,"schemas":[{"type":"struct","fields":[{"id":1,"name":"x","type":"long","required":true}],"schema-id":0,"identifier-field-ids":[]},{"type":"struct","fields":[{"id":1,"name":"x","type":"long","required":true},{"id":2,"name":"y","type":"long","required":true,"doc":"comment"},{"id":3,"name":"z","type":"long","required":true},{"id":4,"name":"u","type":"unknown","required":true},{"id":5,"name":"ns","type":"timestamp_ns","required":true},{"id":6,"name":"nstz","type":"timestamptz_ns","required":true}],"schema-id":1,"identifier-field-ids":[1,2]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[{"source-id":1,"field-id":1000,"transform":"identity","name":"x"}]}],"default-spec-id":0,"last-partition-id":1000,"properties":{"read.split.target.size":"134217728"},"current-snapshot-id":3055729675574597004,"snapshots":[{"snapshot-id":3051729675574597004,"sequence-number":0,"timestamp-ms":1515100955770,"manifest-list":"s3://a/b/1.avro","summary":{"operation":"append"}},{"snapshot-id":3055729675574597004,"parent-snapshot-id":3051729675574597004,"sequence-number":1,"timestamp-ms":1555100955770,"manifest-list":"s3://a/b/2.avro","summary":{"operation":"append"},"schema-id":1}],"snapshot-log":[{"snapshot-id":3051729675574597004,"timestamp-ms":1515100955770},{"snapshot-id":3055729675574597004,"timestamp-ms":1555100955770}],"metadata-log":[{"metadata-file":"s3://bucket/.../v1.json","timestamp-ms":1515100}],"sort-orders":[{"order-id":3,"fields":[{"source-id":2,"transform":"identity","direction":"asc","null-order":"nulls-first"},{"source-id":3,"transform":"bucket[4]","direction":"desc","null-order":"nulls-last"}]}],"default-sort-order-id":3,"refs":{"test":{"snapshot-id":3051729675574597004,"type":"tag","max-ref-age-ms":10000000},"main":{"snapshot-id":3055729675574597004,"type":"branch"}},"statistics":[],"partition-statistics":[],"format-version":3,"last-sequence-number":34,"next-row-id":1}"""
assert table_metadata == expected

with pytest.raises(NotImplementedError) as exc_info:
_ = TableMetadataV3(**example_table_metadata_v3).model_dump_json()

assert "Writing V3 is not yet supported, see: https://github.com/apache/iceberg-python/issues/1551" in str(exc_info.value)
def test_serialize_v3_round_trip(example_table_metadata_v3: dict[str, Any]) -> None:
table_metadata = TableMetadataV3(**example_table_metadata_v3)
reparsed = TableMetadataV3(**json.loads(table_metadata.model_dump_json()))
assert table_metadata == reparsed


def test_migrate_v1_schemas(example_table_metadata_v1: dict[str, Any]) -> None:
Expand Down Expand Up @@ -837,6 +840,7 @@ def test_new_table_metadata_with_v3_schema() -> None:
default_sort_order_id=1,
refs={},
format_version=3,
next_row_id=0,
)

assert actual.model_dump() == expected.model_dump()
Expand All @@ -845,6 +849,20 @@ def test_new_table_metadata_with_v3_schema() -> None:
assert actual.sort_orders == [expected_sort_order]


def test_new_table_metadata_v3_initializes_next_row_id() -> None:
"""`next-row-id` is required in V3, so a new table has to start it at 0 rather than leave it unset."""
actual = new_table_metadata(
schema=Schema(NestedField(field_id=1, name="foo", field_type=StringType(), required=False)),
partition_spec=PartitionSpec(),
sort_order=SortOrder(),
location="s3://some_v3_location/",
properties={"format-version": "3"},
)

assert actual.next_row_id == 0
assert json.loads(actual.model_dump_json())["next-row-id"] == 0


@pytest.mark.parametrize(
"field_type",
[
Expand Down
Loading