Skip to content

Commit 4f95b2e

Browse files
committed
Avro: name fixed types by field-id instead of tracking defined names
Review feedback: a fixed, decimal or uuid type now gets the field-id of the field, list element or map key/value that uses it in its name, the same way records are named r<field-id>. Field-ids are unique, so every definition has its own name and the writer no longer needs to track which names it has defined. AVRO_NAMED_TYPES is a frozenset.
1 parent e0427e1 commit 4f95b2e

3 files changed

Lines changed: 51 additions & 38 deletions

File tree

‎pyiceberg/utils/schema_conversion.py‎

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282

8383

8484
# Avro types that have a name and can be referred to by it later in the schema
85-
AVRO_NAMED_TYPES = {"record", "enum", "fixed"}
85+
AVRO_NAMED_TYPES = frozenset({"record", "enum", "fixed"})
8686

8787

8888
class AvroSchemaConversion:
@@ -513,7 +513,6 @@ class ConvertSchemaToAvro(SchemaVisitorPerPrimitiveType[AvroType]):
513513
last_list_field_id: int
514514
last_map_key_field_id: int
515515
last_map_value_field_id: int
516-
defined_names: set[str]
517516

518517
def __init__(self, schema_name: str | None) -> None:
519518
"""Convert an Iceberg schema to an Avro schema.
@@ -522,18 +521,16 @@ def __init__(self, schema_name: str | None) -> None:
522521
schema_name: The name of the root record.
523522
"""
524523
self.schema_name = schema_name
525-
self.defined_names: set[str] = set()
526524

527-
def _define_once(self, named_type: dict[str, Any]) -> AvroType:
528-
"""Return the definition of a named type the first time, and a reference to its name after that.
525+
@staticmethod
526+
def _name_fixed(result: AvroType, field_id: int) -> None:
527+
"""Add the field-id to the name of a fixed type.
529528
530-
Avro allows only one definition per name; later uses must refer to it by name.
529+
Avro allows only one definition per name, and a decimal, uuid or fixed type can be used by more
530+
than one field. Field-ids are unique, so the names stay unique, the same way records are named.
531531
"""
532-
name = named_type["name"]
533-
if name in self.defined_names:
534-
return name
535-
self.defined_names.add(name)
536-
return named_type
532+
if isinstance(result, dict) and result.get("type") == "fixed":
533+
result["name"] = f"{result['name']}_{field_id}"
537534

538535
def schema(self, schema: Schema, struct_result: AvroType) -> AvroType:
539536
if isinstance(struct_result, dict) and self.schema_name is not None:
@@ -556,6 +553,7 @@ def field(self, field: NestedField, field_result: AvroType) -> AvroType:
556553
# Sets the schema name
557554
if isinstance(field_result, dict) and field_result.get("type") == "record":
558555
field_result["name"] = f"r{field.field_id}"
556+
self._name_fixed(field_result, field.field_id)
559557

560558
original_name = field.name
561559
sanitized_name = make_compatible_name(original_name)
@@ -583,9 +581,12 @@ def list(self, list_type: ListType, element_result: AvroType) -> AvroType:
583581
# Sets the schema name in case of a record
584582
if isinstance(element_result, dict) and element_result.get("type") == "record":
585583
element_result["name"] = f"r{self.last_list_field_id}"
584+
self._name_fixed(element_result, self.last_list_field_id)
586585
return {"type": "array", "element-id": self.last_list_field_id, "items": element_result}
587586

588587
def map(self, map_type: MapType, key_result: AvroType, value_result: AvroType) -> AvroType:
588+
self._name_fixed(key_result, self.last_map_key_field_id)
589+
self._name_fixed(value_result, self.last_map_value_field_id)
589590
if isinstance(key_result, StringType):
590591
# Avro Maps does not support other keys than a String,
591592
return {
@@ -611,19 +612,17 @@ def map(self, map_type: MapType, key_result: AvroType, value_result: AvroType) -
611612
}
612613

613614
def visit_fixed(self, fixed_type: FixedType) -> AvroType:
614-
return self._define_once({"type": "fixed", "size": len(fixed_type), "name": f"fixed_{len(fixed_type)}"})
615+
return {"type": "fixed", "size": len(fixed_type), "name": f"fixed_{len(fixed_type)}"}
615616

616617
def visit_decimal(self, decimal_type: DecimalType) -> AvroType:
617-
return self._define_once(
618-
{
619-
"type": "fixed",
620-
"size": decimal_required_bytes(decimal_type.precision),
621-
"logicalType": "decimal",
622-
"precision": decimal_type.precision,
623-
"scale": decimal_type.scale,
624-
"name": f"decimal_{decimal_type.precision}_{decimal_type.scale}",
625-
}
626-
)
618+
return {
619+
"type": "fixed",
620+
"size": decimal_required_bytes(decimal_type.precision),
621+
"logicalType": "decimal",
622+
"precision": decimal_type.precision,
623+
"scale": decimal_type.scale,
624+
"name": f"decimal_{decimal_type.precision}_{decimal_type.scale}",
625+
}
627626

628627
def visit_boolean(self, boolean_type: BooleanType) -> AvroType:
629628
return "boolean"
@@ -662,7 +661,7 @@ def visit_string(self, string_type: StringType) -> AvroType:
662661
return "string"
663662

664663
def visit_uuid(self, uuid_type: UUIDType) -> AvroType:
665-
return self._define_once({"type": "fixed", "size": 16, "logicalType": "uuid", "name": "uuid_fixed"})
664+
return {"type": "fixed", "size": 16, "logicalType": "uuid", "name": "uuid_fixed"}
666665

667666
def visit_binary(self, binary_type: BinaryType) -> AvroType:
668667
return "bytes"

‎tests/avro/test_file.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ def field_uuid(self) -> UUID:
456456

457457

458458
def test_repeated_named_types_round_trip() -> None:
459-
"""Named types used more than once are defined once in the file schema and read back by both readers"""
459+
"""Named types used by more than one field get unique names in the file schema and read back with both readers"""
460460
schema = Schema(
461461
NestedField(field_id=1, name="price", field_type=DecimalType(10, 2), required=True),
462462
NestedField(field_id=2, name="cost", field_type=DecimalType(10, 2), required=False),
@@ -485,8 +485,8 @@ def test_repeated_named_types_round_trip() -> None:
485485
file_fields = r.writer_schema["fields"]
486486
avro_entry_read_with_fastavro = list(next(iter(r)).values())
487487

488-
assert file_fields[1]["type"] == ["null", "decimal_10_2"]
489-
assert file_fields[3]["type"] == ["null", "uuid_fixed"]
488+
assert [f["name"] for f in (file_fields[0]["type"], file_fields[1]["type"][1])] == ["decimal_10_2_1", "decimal_10_2_2"]
489+
assert [f["name"] for f in (file_fields[2]["type"], file_fields[3]["type"][1])] == ["uuid_fixed_3", "uuid_fixed_4"]
490490
for idx in range(len(record)):
491491
assert record[idx] == avro_entry[idx]
492492
# fastavro returns a uuid stored as fixed as its raw bytes

‎tests/utils/test_schema_conversion.py‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,8 @@ def test_avro_to_iceberg_unknown_named_type_reference() -> None:
445445
AvroSchemaConversion().avro_to_iceberg(avro_schema)
446446

447447

448-
def test_iceberg_to_avro_defines_named_types_once() -> None:
448+
def test_iceberg_to_avro_names_fixed_types_by_field_id() -> None:
449+
"""A named type can be defined once per schema, so fixed types used by several fields get the field-id in their name"""
449450
schema = Schema(
450451
NestedField(field_id=1, name="a", field_type=DecimalType(10, 2), required=False),
451452
NestedField(field_id=2, name="b", field_type=DecimalType(10, 2), required=True),
@@ -460,22 +461,35 @@ def test_iceberg_to_avro_defines_named_types_once() -> None:
460461
required=True,
461462
),
462463
NestedField(field_id=7, name="f", field_type=UUIDType(), required=False),
463-
NestedField(field_id=8, name="g", field_type=DecimalType(9, 2), required=True),
464+
NestedField(
465+
field_id=8,
466+
name="g",
467+
field_type=ListType(element_id=9, element_type=UUIDType(), element_required=True),
468+
required=True,
469+
),
470+
NestedField(
471+
field_id=10,
472+
name="h",
473+
field_type=MapType(key_id=11, key_type=UUIDType(), value_id=12, value_type=DecimalType(10, 2)),
474+
required=True,
475+
),
464476
schema_id=1,
465477
)
466478

467479
avro_schema = AvroSchemaConversion().iceberg_to_avro(schema, schema_name="r102")
468480
assert isinstance(avro_schema, dict)
469481
fields = avro_schema["fields"]
470482
nested_fields = fields[3]["type"]["fields"]
471-
472-
assert fields[0]["type"] == ["null", DECIMAL_10_2]
473-
assert fields[1]["type"] == "decimal_10_2"
474-
assert fields[2]["type"] == {"type": "fixed", "size": 3, "name": "fixed_3"}
475-
assert nested_fields[0]["type"] == ["null", "fixed_3"]
476-
assert nested_fields[1]["type"] == {"type": "fixed", "size": 16, "logicalType": "uuid", "name": "uuid_fixed"}
477-
assert fields[4]["type"] == ["null", "uuid_fixed"]
478-
# a different decimal is a different name, so it gets its own definition
479-
assert fields[5]["type"]["name"] == "decimal_9_2"
480-
# and the references read back as the same types
483+
map_fields = fields[6]["type"]["items"]["fields"]
484+
485+
assert fields[0]["type"] == ["null", {**DECIMAL_10_2, "name": "decimal_10_2_1"}]
486+
assert fields[1]["type"] == {**DECIMAL_10_2, "name": "decimal_10_2_2"}
487+
assert fields[2]["type"] == {"type": "fixed", "size": 3, "name": "fixed_3_3"}
488+
assert nested_fields[0]["type"] == ["null", {"type": "fixed", "size": 3, "name": "fixed_3_5"}]
489+
assert nested_fields[1]["type"] == {"type": "fixed", "size": 16, "logicalType": "uuid", "name": "uuid_fixed_6"}
490+
assert fields[4]["type"] == ["null", {"type": "fixed", "size": 16, "logicalType": "uuid", "name": "uuid_fixed_7"}]
491+
assert fields[5]["type"]["items"]["name"] == "uuid_fixed_9"
492+
assert map_fields[0]["type"]["name"] == "uuid_fixed_11"
493+
assert map_fields[1]["type"]["name"] == "decimal_10_2_12"
494+
# and the schema reads back as the same types
481495
assert AvroSchemaConversion().avro_to_iceberg(avro_schema) == schema

0 commit comments

Comments
 (0)