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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Improved regex for extracting Azure storage account names from URLs with containerName@storageAccountName format (#848)
- JSON Schema Check: Add globbing support for local JSON files
- Avro Importer: Optional and required enum types now work for arrays
- Fixed server section rendering for markdown exporter

## [0.10.34] - 2025-08-06
Expand Down
19 changes: 12 additions & 7 deletions datacontract/imports/avro_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ def import_avro_array_items(array_schema: avro.schema.ArraySchema) -> Field:
elif array_schema.items.type == "array":
items.type = "array"
items.items = import_avro_array_items(array_schema.items)
elif array_schema.items.type == "union":
items.type = import_type_from_union_schema(array_schema.items)
else: # primitive type
items.type = map_type_from_avro(array_schema.items.type)

Expand Down Expand Up @@ -227,25 +229,28 @@ def import_avro_map_values(map_schema: avro.schema.MapSchema) -> Field:


def import_type_of_optional_field(field: avro.schema.Field) -> str:
return import_type_from_union_schema(field.type)

def import_type_from_union_schema(union_schema: avro.schema.UnionSchema) -> str:
"""
Determine the type of optional field in an Avro union.
Extract the first non-null type from a union schema and map it to data contract type.

Args:
field: The Avro field with a union type.
union_schema (avro.schema.UnionSchema): The Avro union schema.

Returns:
str: The mapped type of the non-null field in the union.
str: The mapped type of the first non-null field in the union.

Raises:
DataContractException: If no non-null type is found in the union.
"""
for field_type in field.type.schemas:
if field_type.type != "null":
logical_type = field_type.get_prop("logicalType")
for schema_type in union_schema.schemas:
if schema_type.type != "null":
logical_type = schema_type.get_prop("logicalType")
if logical_type and logical_type in LOGICAL_TYPE_MAPPING:
return LOGICAL_TYPE_MAPPING[logical_type]
else:
return map_type_from_avro(field_type.type)
return map_type_from_avro(schema_type.type)
raise DataContractException(
type="schema",
result="failed",
Expand Down
17 changes: 17 additions & 0 deletions tests/fixtures/avro/data/non_nullable_union.avsc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"type": "record",
"name": "TestNonNullableUnion",
"doc": "Test record with non-nullable union types",
"fields": [
{
"name": "id",
"type": "string",
"doc": "Required field"
},
{
"name": "multi_type_field",
"type": ["string", "int", "boolean"],
"doc": "Field with multiple types takes first non-null type"
}
]
}
33 changes: 33 additions & 0 deletions tests/fixtures/avro/data/nullable_array_items.avsc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"type": "record",
"name": "TestNullableArrayItems",
"doc": "Test record with nullable unions in array items",
"fields": [
{
"name": "id",
"type": "string",
"doc": "Required field - works fine"
},
{
"name": "nullable_string",
"type": ["null", "string"],
"doc": "Simple nullable field",
"default": null
},
{
"name": "media_urls",
"type": [
"null",
{
"type": "array",
"items": [
"null",
"string"
]
}
],
"default": null,
"doc": "Nullable array with nullable string items "
}
]
}
20 changes: 20 additions & 0 deletions tests/fixtures/avro/export/datacontract_non_nullable_union.avsc
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"type": "record",
"name": "TestNonNullableUnion",
"doc": "Test record with non-nullable union types",
"fields": [
{
"name": "id",
"doc": "Required field",
"type": "string"
},
{
"name": "multi_type_field",
"doc": "Field with multiple types takes first non-null type",
"type": [
"null",
"string"
]
}
]
}
17 changes: 17 additions & 0 deletions tests/fixtures/avro/export/datacontract_non_nullable_union.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
dataContractSpecification: 1.2.0
id: non-nullable-union
info:
title: Test Non-Nullable Union
version: 0.0.1
models:
TestNonNullableUnion:
description: Test record with non-nullable union types
fields:
id:
type: string
required: true
description: Required field
multi_type_field:
type: string
required: false
description: Field with multiple types takes first non-null type
31 changes: 31 additions & 0 deletions tests/fixtures/avro/export/datacontract_nullable_array_items.avsc
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"type": "record",
"name": "TestNullableArrayItems",
"doc": "Test record with nullable unions in array items",
"fields": [
{
"name": "id",
"doc": "Required field",
"type": "string"
},
{
"name": "nullable_string",
"doc": "Simple nullable fields",
"type": [
"null",
"string"
]
},
{
"name": "media_urls",
"doc": "Nullable array with nullable string items",
"type": [
"null",
{
"type": "array",
"items": "string"
}
]
}
]
}
23 changes: 23 additions & 0 deletions tests/fixtures/avro/export/datacontract_nullable_array_items.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
dataContractSpecification: 1.2.0
id: nullable-array-items
info:
title: Test Nullable Array Items
version: 0.0.1
models:
TestNullableArrayItems:
description: Test record with nullable unions in array items
fields:
id:
type: string
required: true
description: Required field
nullable_string:
type: string
required: false
description: Simple nullable fields
media_urls:
type: array
required: false
description: Nullable array with nullable string items
items:
type: string
22 changes: 22 additions & 0 deletions tests/test_export_avro.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,25 @@ def test_to_field_float():
result = to_avro_schema_json(model_name, model)

assert json.loads(result) == json.loads(expected_avro_schema)


def test_export_avro_nullable_array_items():
data_contract = DataContractSpecification.from_file("fixtures/avro/export/datacontract_nullable_array_items.yaml")
with open("fixtures/avro/export/datacontract_nullable_array_items.avsc") as file:
expected_avro_schema = file.read()

model_name, model = next(iter(data_contract.models.items()))
result = to_avro_schema_json(model_name, model)

assert json.loads(result) == json.loads(expected_avro_schema)


def test_export_avro_non_nullable_union():
data_contract = DataContractSpecification.from_file("fixtures/avro/export/datacontract_non_nullable_union.yaml")
with open("fixtures/avro/export/datacontract_non_nullable_union.avsc") as file:
expected_avro_schema = file.read()

model_name, model = next(iter(data_contract.models.items()))
result = to_avro_schema_json(model_name, model)

assert json.loads(result) == json.loads(expected_avro_schema)
60 changes: 60 additions & 0 deletions tests/test_import_avro.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,63 @@ def test_import_avro_optional_enum():
print("Result:\n", result.to_yaml())
assert yaml.safe_load(result.to_yaml()) == yaml.safe_load(expected)
assert DataContract(data_contract_str=expected).lint().has_passed()


def test_import_avro_nullable_array_items():
result = DataContract().import_from_source("avro", "fixtures/avro/data/nullable_array_items.avsc")

expected = """
dataContractSpecification: 1.2.0
id: my-data-contract-id
info:
title: My Data Contract
version: 0.0.1
models:
TestNullableArrayItems:
description: Test record with nullable unions in array items
fields:
id:
type: string
required: true
description: Required field - works fine
nullable_string:
type: string
required: false
description: Simple nullable field
media_urls:
type: array
required: false
description: 'Nullable array with nullable string items '
items:
type: string
"""
print("Result:\n", result.to_yaml())
assert yaml.safe_load(result.to_yaml()) == yaml.safe_load(expected)
assert DataContract(data_contract_str=expected).lint(enabled_linters="none").has_passed()


def test_import_avro_non_nullable_union():
result = DataContract().import_from_source("avro", "fixtures/avro/data/non_nullable_union.avsc")

expected = """
dataContractSpecification: 1.2.0
id: my-data-contract-id
info:
title: My Data Contract
version: 0.0.1
models:
TestNonNullableUnion:
description: Test record with non-nullable union types
fields:
id:
type: string
required: true
description: Required field
multi_type_field:
type: string
required: false
description: Field with multiple types takes first non-null type
"""
print("Result:\n", result.to_yaml())
assert yaml.safe_load(result.to_yaml()) == yaml.safe_load(expected)
assert DataContract(data_contract_str=expected).lint().has_passed()
Loading