From 0a0e829d5b2c2107956d75baba8f0a9ec51fe94c Mon Sep 17 00:00:00 2001 From: Fokko Driesprong Date: Wed, 13 Dec 2023 22:35:31 +0100 Subject: [PATCH 1/6] Add name-mapping All the things to (de)serialize the name-mapping, and all the neccessary visitors and such --- pyiceberg/table/name_mapping.py | 204 ++++++++++++++++++++++ tests/table/test_name_mapping.py | 291 +++++++++++++++++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 pyiceberg/table/name_mapping.py create mode 100644 tests/table/test_name_mapping.py diff --git a/pyiceberg/table/name_mapping.py b/pyiceberg/table/name_mapping.py new file mode 100644 index 0000000000..32ffa0d1f8 --- /dev/null +++ b/pyiceberg/table/name_mapping.py @@ -0,0 +1,204 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +Contains everything around the name mapping. + +More information can be found on here: +https://iceberg.apache.org/spec/#name-mapping-serialization +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections import ChainMap +from functools import cached_property, singledispatch +from typing import Any, Dict, Generic, List, Set, TypeVar, Union + +from pydantic import Field, conset, model_serializer + +from pyiceberg.schema import Schema, SchemaVisitor, visit +from pyiceberg.typedef import IcebergBaseModel, IcebergRootModel +from pyiceberg.types import ListType, MapType, NestedField, PrimitiveType, StructType + + +class MappedField(IcebergBaseModel): + field_id: int = Field(alias="field-id") + names: Set[str] = conset(str, min_length=1) + fields: List[MappedField] = Field(default_factory=list) + + @model_serializer + def ser_model(self) -> Dict[str, Any]: + """Set custom serializer to leave out the field when it is empty.""" + fields = {'fields': self.fields} if len(self.fields) > 0 else {} + return { + 'field-id': self.field_id, + # Sort the names to give a consistent output in json + 'names': sorted([self.names]), + **fields, + } + + def __len__(self) -> int: + """Return the number of fields.""" + return len(self.fields) + + def __str__(self) -> str: + """Convert the mapped-field into a nicely formatted string.""" + # Otherwise the UTs fail because the order of the set can change + fields_str = ", ".join([str(e) for e in self.fields]) or "" + fields_str = " " + fields_str if fields_str else "" + return "([" + ", ".join(sorted(self.names)) + "] -> " + (str(self.field_id) or "?") + fields_str + ")" + + +class NameMapping(IcebergRootModel[List[MappedField]]): + root: List[MappedField] + + @cached_property + def _field_by_id(self) -> Dict[int, MappedField]: + return visit_name_mapping(self, _IndexById()) + + @cached_property + def _field_by_name(self) -> Dict[str, MappedField]: + return visit_name_mapping(self, _IndexByName()) + + def id(self, name: str) -> int: + try: + return self._field_by_name[name].field_id + except KeyError as e: + raise ValueError(f"Could not find field with name: {name}") from e + + def field(self, field_id: int) -> MappedField: + try: + return self._field_by_id[field_id] + except KeyError as e: + raise ValueError(f"Could not find field-id: {field_id}") from e + + def __len__(self) -> int: + """Return the number of mappings.""" + return len(self.root) + + def __str__(self) -> str: + """Convert the name-mapping into a nicely formatted string.""" + if len(self.root) == 0: + return "[]" + else: + return "[\n " + "\n ".join([str(e) for e in self.root]) + "\n]" + + +T = TypeVar("T") + + +class NameMappingVisitor(Generic[T], ABC): + @abstractmethod + def mapping(self, nm: NameMapping, field_results: T) -> T: + """Visit a NameMapping.""" + + @abstractmethod + def fields(self, struct: List[MappedField], field_results: List[T]) -> T: + """Visit a List[MappedField].""" + + @abstractmethod + def field(self, field: MappedField, field_result: T) -> T: + """Visit a MappedField.""" + + +class _IndexById(NameMappingVisitor[Dict[int, MappedField]]): + result: Dict[int, MappedField] + + def __init__(self) -> None: + self.result = {} + + def mapping(self, nm: NameMapping, field_results: Dict[int, MappedField]) -> Dict[int, MappedField]: + return field_results + + def fields(self, struct: List[MappedField], field_results: List[Dict[int, MappedField]]) -> Dict[int, MappedField]: + return self.result + + def field(self, field: MappedField, field_result: Dict[int, MappedField]) -> Dict[int, MappedField]: + if field.field_id in self.result: + raise ValueError(f"Invalid mapping: ID {field.field_id} is not unique") + + self.result[field.field_id] = field + + return self.result + + +class _IndexByName(NameMappingVisitor[Dict[str, MappedField]]): + def mapping(self, nm: NameMapping, field_results: Dict[str, MappedField]) -> Dict[str, MappedField]: + return field_results + + def fields(self, struct: List[MappedField], field_results: List[Dict[str, MappedField]]) -> Dict[str, MappedField]: + return dict(ChainMap(*field_results)) + + def field(self, field: MappedField, field_result: Dict[str, MappedField]) -> Dict[str, MappedField]: + result: Dict[str, MappedField] = { + f"{field_name}.{key}": result_field for key, result_field in field_result.items() for field_name in field.names + } + + for name in field.names: + result[name] = field + + return result + + +@singledispatch +def visit_name_mapping(obj: Union[NameMapping, List[MappedField], MappedField], visitor: NameMappingVisitor[T]) -> T: + """Traverse the name mapping in post-order traversal.""" + raise NotImplementedError(f"Cannot visit non-type: {obj}") + + +@visit_name_mapping.register(NameMapping) +def _(obj: NameMapping, visitor: NameMappingVisitor[T]) -> T: + return visitor.mapping(obj, visit_name_mapping(obj.root, visitor)) + + +@visit_name_mapping.register(list) +def _(fields: List[MappedField], visitor: NameMappingVisitor[T]) -> T: + results = [visitor.field(field, visit_name_mapping(field.fields, visitor)) for field in fields] + return visitor.fields(fields, results) + + +def load_mapping_from_json(mapping: str) -> NameMapping: + return NameMapping.model_validate_json(mapping) + + +class _CreateMapping(SchemaVisitor[List[MappedField]]): + def schema(self, schema: Schema, struct_result: List[MappedField]) -> List[MappedField]: + return struct_result + + def struct(self, struct: StructType, field_results: List[List[MappedField]]) -> List[MappedField]: + return [ + MappedField(field_id=field.field_id, names={field.name}, fields=result) + for field, result in zip(struct.fields, field_results) + ] + + def field(self, field: NestedField, field_result: List[MappedField]) -> List[MappedField]: + return field_result + + def list(self, list_type: ListType, element_result: List[MappedField]) -> List[MappedField]: + return [MappedField(field_id=list_type.element_id, names={"element"}, fields=element_result)] + + def map(self, map_type: MapType, key_result: List[MappedField], value_result: List[MappedField]) -> List[MappedField]: + return [ + MappedField(field_id=map_type.key_id, names={"key"}, fields=key_result), + MappedField(field_id=map_type.value_id, names={"value"}, fields=value_result), + ] + + def primitive(self, primitive: PrimitiveType) -> List[MappedField]: + return [] + + +def create_mapping_from_schema(schema: Schema) -> NameMapping: + return NameMapping(visit(schema, _CreateMapping())) diff --git a/tests/table/test_name_mapping.py b/tests/table/test_name_mapping.py new file mode 100644 index 0000000000..db5a1d8808 --- /dev/null +++ b/tests/table/test_name_mapping.py @@ -0,0 +1,291 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import pytest + +from pyiceberg.schema import Schema +from pyiceberg.table.name_mapping import MappedField, NameMapping, create_mapping_from_schema, load_mapping_from_json + + +@pytest.fixture(scope="session") +def table_name_mapping_nested() -> NameMapping: + return NameMapping( + [ + MappedField(field_id=1, names={'foo'}), + MappedField(field_id=2, names={'bar'}), + MappedField(field_id=3, names={'baz'}), + MappedField(field_id=4, names={'qux'}, fields=[MappedField(field_id=5, names={'element'})]), + MappedField( + field_id=6, + names={'quux'}, + fields=[ + MappedField(field_id=7, names={'key'}), + MappedField( + field_id=8, + names={'value'}, + fields=[ + MappedField(field_id=9, names={'key'}), + MappedField(field_id=10, names={'value'}), + ], + ), + ], + ), + MappedField( + field_id=11, + names={'location'}, + fields=[ + MappedField( + field_id=12, + names={'element'}, + fields=[ + MappedField(field_id=13, names={'latitude'}), + MappedField(field_id=14, names={'longitude'}), + ], + ) + ], + ), + MappedField( + field_id=15, + names={'person'}, + fields=[ + MappedField(field_id=16, names={'name'}), + MappedField(field_id=17, names={'age'}), + ], + ), + ] + ) + + +def test_json_deserialization() -> None: + name_mapping = """ +[ + { + "field-id": 1, + "names": [ + "id", + "record_id" + ] + }, + { + "field-id": 2, + "names": [ + "data" + ] + }, + { + "field-id": 3, + "names": [ + "location" + ], + "fields": [ + { + "field-id": 4, + "names": [ + "latitude", + "lat" + ] + }, + { + "field-id": 5, + "names": [ + "longitude", + "long" + ] + } + ] + } +] + """ + + assert load_mapping_from_json(name_mapping) == NameMapping( + [ + MappedField(field_id=1, names={'record_id', 'id'}), + MappedField(field_id=2, names={'data'}), + MappedField( + names={'location'}, + field_id=3, + fields=[ + MappedField(field_id=4, names={'lat', 'latitude'}), + MappedField(field_id=5, names={'longitude', 'long'}), + ], + ), + ] + ) + + +def test_json_serialization(table_name_mapping_nested: NameMapping) -> None: + assert ( + table_name_mapping_nested.model_dump_json() + == """[{"field-id":1,"names":[["foo"]]},{"field-id":2,"names":[["bar"]]},{"field-id":3,"names":[["baz"]]},{"field-id":4,"names":[["qux"]],"fields":[{"field-id":5,"names":[["element"]]}]},{"field-id":6,"names":[["quux"]],"fields":[{"field-id":7,"names":[["key"]]},{"field-id":8,"names":[["value"]],"fields":[{"field-id":9,"names":[["key"]]},{"field-id":10,"names":[["value"]]}]}]},{"field-id":11,"names":[["location"]],"fields":[{"field-id":12,"names":[["element"]],"fields":[{"field-id":13,"names":[["latitude"]]},{"field-id":14,"names":[["longitude"]]}]}]},{"field-id":15,"names":[["person"]],"fields":[{"field-id":16,"names":[["name"]]},{"field-id":17,"names":[["age"]]}]}]""" + ) + + +def test_name_mapping_to_string() -> None: + nm = NameMapping( + [ + MappedField(field_id=1, names={'record_id', 'id'}), + MappedField(field_id=2, names={'data'}), + MappedField( + names={'location'}, + field_id=3, + fields=[ + MappedField(field_id=4, names={'lat', 'latitude'}), + MappedField(field_id=5, names={'longitude', 'long'}), + ], + ), + ] + ) + + assert ( + str(nm) + == """[ + ([id, record_id] -> 1) + ([data] -> 2) + ([location] -> 3 ([lat, latitude] -> 4), ([long, longitude] -> 5)) +]""" + ) + + +def test_mapping_from_schema(table_schema_nested: Schema, table_name_mapping_nested: NameMapping) -> None: + nm = create_mapping_from_schema(table_schema_nested) + assert nm == table_name_mapping_nested + + +def test_mapping_by_id(table_name_mapping_nested: NameMapping) -> None: + assert table_name_mapping_nested._field_by_id == { + 1: MappedField(field_id=1, names={'foo'}), + 2: MappedField(field_id=2, names={'bar'}), + 3: MappedField(field_id=3, names={'baz'}), + 5: MappedField(field_id=5, names={'element'}), + 4: MappedField(field_id=4, names={'qux'}, fields=[MappedField(field_id=5, names={'element'})]), + 7: MappedField(field_id=7, names={'key'}), + 9: MappedField(field_id=9, names={'key'}), + 10: MappedField(field_id=10, names={'value'}), + 8: MappedField( + field_id=8, + names={'value'}, + fields=[MappedField(field_id=9, names={'key'}), MappedField(field_id=10, names={'value'})], + ), + 6: MappedField( + field_id=6, + names={'quux'}, + fields=[ + MappedField(field_id=7, names={'key'}), + MappedField( + field_id=8, + names={'value'}, + fields=[MappedField(field_id=9, names={'key'}), MappedField(field_id=10, names={'value'})], + ), + ], + ), + 13: MappedField(field_id=13, names={'latitude'}), + 14: MappedField(field_id=14, names={'longitude'}), + 12: MappedField( + field_id=12, + names={'element'}, + fields=[MappedField(field_id=13, names={'latitude'}), MappedField(field_id=14, names={'longitude'})], + ), + 11: MappedField( + field_id=11, + names={'location'}, + fields=[ + MappedField( + field_id=12, + names={'element'}, + fields=[ + MappedField(field_id=13, names={'latitude'}), + MappedField(field_id=14, names={'longitude'}), + ], + ) + ], + ), + 16: MappedField(field_id=16, names={'name'}), + 17: MappedField(field_id=17, names={'age'}), + 15: MappedField( + field_id=15, + names={'person'}, + fields=[MappedField(field_id=16, names={'name'}), MappedField(field_id=17, names={'age'})], + ), + } + + +def test_mapping_by_name(table_name_mapping_nested: NameMapping) -> None: + assert table_name_mapping_nested._field_by_name == { + 'person.age': MappedField(field_id=17, names={'age'}), + 'person.name': MappedField(field_id=16, names={'name'}), + 'person': MappedField( + field_id=15, + names={'person'}, + fields=[MappedField(field_id=16, names={'name'}), MappedField(field_id=17, names={'age'})], + ), + 'location.element.longitude': MappedField(field_id=14, names={'longitude'}), + 'location.element.latitude': MappedField(field_id=13, names={'latitude'}), + 'location.element': MappedField( + field_id=12, + names={'element'}, + fields=[MappedField(field_id=13, names={'latitude'}), MappedField(field_id=14, names={'longitude'})], + ), + 'location': MappedField( + field_id=11, + names={'location'}, + fields=[ + MappedField( + field_id=12, + names={'element'}, + fields=[MappedField(field_id=13, names={'latitude'}), MappedField(field_id=14, names={'longitude'})], + ) + ], + ), + 'quux.value.value': MappedField(field_id=10, names={'value'}), + 'quux.value.key': MappedField(field_id=9, names={'key'}), + 'quux.value': MappedField( + field_id=8, + names={'value'}, + fields=[MappedField(field_id=9, names={'key'}), MappedField(field_id=10, names={'value'})], + ), + 'quux.key': MappedField(field_id=7, names={'key'}), + 'quux': MappedField( + field_id=6, + names={'quux'}, + fields=[ + MappedField(field_id=7, names={'key'}), + MappedField( + field_id=8, + names={'value'}, + fields=[MappedField(field_id=9, names={'key'}), MappedField(field_id=10, names={'value'})], + ), + ], + ), + 'qux.element': MappedField(field_id=5, names={'element'}), + 'qux': MappedField(field_id=4, names={'qux'}, fields=[MappedField(field_id=5, names={'element'})]), + 'baz': MappedField(field_id=3, names={'baz'}), + 'bar': MappedField(field_id=2, names={'bar'}), + 'foo': MappedField(field_id=1, names={'foo'}), + } + + +def test_mapping_lookup_by_name(table_name_mapping_nested: NameMapping) -> None: + assert table_name_mapping_nested.id("foo") == 1 + + with pytest.raises(ValueError, match="Could not find field with name: boom"): + table_name_mapping_nested.id("boom") + + +def test_mapping_lookup_by_field_id(table_name_mapping_nested: NameMapping) -> None: + assert table_name_mapping_nested.field(1) == MappedField(field_id=1, names={'foo'}) + + with pytest.raises(ValueError, match="Could not find field-id: 22"): + table_name_mapping_nested.field(22) From 5a673d07509031f8d8ac6b369dbdb6f00a81ee0b Mon Sep 17 00:00:00 2001 From: Fokko Driesprong Date: Thu, 14 Dec 2023 10:13:29 +0100 Subject: [PATCH 2/6] Move the names from a `set` to a `list` --- pyiceberg/table/name_mapping.py | 9 ++++----- tests/table/test_name_mapping.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pyiceberg/table/name_mapping.py b/pyiceberg/table/name_mapping.py index 32ffa0d1f8..ea3c1495d1 100644 --- a/pyiceberg/table/name_mapping.py +++ b/pyiceberg/table/name_mapping.py @@ -27,7 +27,7 @@ from functools import cached_property, singledispatch from typing import Any, Dict, Generic, List, Set, TypeVar, Union -from pydantic import Field, conset, model_serializer +from pydantic import Field, conlist, model_serializer from pyiceberg.schema import Schema, SchemaVisitor, visit from pyiceberg.typedef import IcebergBaseModel, IcebergRootModel @@ -36,7 +36,7 @@ class MappedField(IcebergBaseModel): field_id: int = Field(alias="field-id") - names: Set[str] = conset(str, min_length=1) + names: Set[str] = conlist(str, min_length=1) fields: List[MappedField] = Field(default_factory=list) @model_serializer @@ -45,8 +45,7 @@ def ser_model(self) -> Dict[str, Any]: fields = {'fields': self.fields} if len(self.fields) > 0 else {} return { 'field-id': self.field_id, - # Sort the names to give a consistent output in json - 'names': sorted([self.names]), + 'names': self.names, **fields, } @@ -59,7 +58,7 @@ def __str__(self) -> str: # Otherwise the UTs fail because the order of the set can change fields_str = ", ".join([str(e) for e in self.fields]) or "" fields_str = " " + fields_str if fields_str else "" - return "([" + ", ".join(sorted(self.names)) + "] -> " + (str(self.field_id) or "?") + fields_str + ")" + return "([" + ", ".join(self.names) + "] -> " + (str(self.field_id) or "?") + fields_str + ")" class NameMapping(IcebergRootModel[List[MappedField]]): diff --git a/tests/table/test_name_mapping.py b/tests/table/test_name_mapping.py index db5a1d8808..97d2179165 100644 --- a/tests/table/test_name_mapping.py +++ b/tests/table/test_name_mapping.py @@ -129,7 +129,7 @@ def test_json_deserialization() -> None: def test_json_serialization(table_name_mapping_nested: NameMapping) -> None: assert ( table_name_mapping_nested.model_dump_json() - == """[{"field-id":1,"names":[["foo"]]},{"field-id":2,"names":[["bar"]]},{"field-id":3,"names":[["baz"]]},{"field-id":4,"names":[["qux"]],"fields":[{"field-id":5,"names":[["element"]]}]},{"field-id":6,"names":[["quux"]],"fields":[{"field-id":7,"names":[["key"]]},{"field-id":8,"names":[["value"]],"fields":[{"field-id":9,"names":[["key"]]},{"field-id":10,"names":[["value"]]}]}]},{"field-id":11,"names":[["location"]],"fields":[{"field-id":12,"names":[["element"]],"fields":[{"field-id":13,"names":[["latitude"]]},{"field-id":14,"names":[["longitude"]]}]}]},{"field-id":15,"names":[["person"]],"fields":[{"field-id":16,"names":[["name"]]},{"field-id":17,"names":[["age"]]}]}]""" + == """[{"field-id":1,"names":["foo"]},{"field-id":2,"names":["bar"]},{"field-id":3,"names":["baz"]},{"field-id":4,"names":["qux"],"fields":[{"field-id":5,"names":["element"]}]},{"field-id":6,"names":["quux"],"fields":[{"field-id":7,"names":["key"]},{"field-id":8,"names":["value"],"fields":[{"field-id":9,"names":["key"]},{"field-id":10,"names":["value"]}]}]},{"field-id":11,"names":["location"],"fields":[{"field-id":12,"names":["element"],"fields":[{"field-id":13,"names":["latitude"]},{"field-id":14,"names":["longitude"]}]}]},{"field-id":15,"names":["person"],"fields":[{"field-id":16,"names":["name"]},{"field-id":17,"names":["age"]}]}]""" ) From 2c9be7c1d5e6675e90acc97e9f36d991333d1e5a Mon Sep 17 00:00:00 2001 From: Fokko Driesprong Date: Thu, 14 Dec 2023 10:20:33 +0100 Subject: [PATCH 3/6] Move from `set` to `lint` in tests as well --- tests/table/test_name_mapping.py | 178 +++++++++++++++---------------- 1 file changed, 89 insertions(+), 89 deletions(-) diff --git a/tests/table/test_name_mapping.py b/tests/table/test_name_mapping.py index 97d2179165..41db1b9fc6 100644 --- a/tests/table/test_name_mapping.py +++ b/tests/table/test_name_mapping.py @@ -24,45 +24,45 @@ def table_name_mapping_nested() -> NameMapping: return NameMapping( [ - MappedField(field_id=1, names={'foo'}), - MappedField(field_id=2, names={'bar'}), - MappedField(field_id=3, names={'baz'}), - MappedField(field_id=4, names={'qux'}, fields=[MappedField(field_id=5, names={'element'})]), + MappedField(field_id=1, names=['foo']), + MappedField(field_id=2, names=['bar']), + MappedField(field_id=3, names=['baz']), + MappedField(field_id=4, names=['qux'], fields=[MappedField(field_id=5, names=['element'])]), MappedField( field_id=6, - names={'quux'}, + names=['quux'], fields=[ - MappedField(field_id=7, names={'key'}), + MappedField(field_id=7, names=['key']), MappedField( field_id=8, - names={'value'}, + names=['value'], fields=[ - MappedField(field_id=9, names={'key'}), - MappedField(field_id=10, names={'value'}), + MappedField(field_id=9, names=['key']), + MappedField(field_id=10, names=['value']), ], ), ], ), MappedField( field_id=11, - names={'location'}, + names=['location'], fields=[ MappedField( field_id=12, - names={'element'}, + names=['element'], fields=[ - MappedField(field_id=13, names={'latitude'}), - MappedField(field_id=14, names={'longitude'}), + MappedField(field_id=13, names=['latitude']), + MappedField(field_id=14, names=['longitude']), ], ) ], ), MappedField( field_id=15, - names={'person'}, + names=['person'], fields=[ - MappedField(field_id=16, names={'name'}), - MappedField(field_id=17, names={'age'}), + MappedField(field_id=16, names=['name']), + MappedField(field_id=17, names=['age']), ], ), ] @@ -72,54 +72,54 @@ def table_name_mapping_nested() -> NameMapping: def test_json_deserialization() -> None: name_mapping = """ [ - { + [ "field-id": 1, "names": [ "id", "record_id" ] - }, - { + ], + [ "field-id": 2, "names": [ "data" ] - }, - { + ], + [ "field-id": 3, "names": [ "location" ], "fields": [ - { + [ "field-id": 4, "names": [ "latitude", "lat" ] - }, - { + ], + [ "field-id": 5, "names": [ "longitude", "long" ] - } + ] ] - } + ] ] """ assert load_mapping_from_json(name_mapping) == NameMapping( [ - MappedField(field_id=1, names={'record_id', 'id'}), - MappedField(field_id=2, names={'data'}), + MappedField(field_id=1, names=['record_id', 'id']), + MappedField(field_id=2, names=['data']), MappedField( - names={'location'}, + names=['location'], field_id=3, fields=[ - MappedField(field_id=4, names={'lat', 'latitude'}), - MappedField(field_id=5, names={'longitude', 'long'}), + MappedField(field_id=4, names=['lat', 'latitude']), + MappedField(field_id=5, names=['longitude', 'long']), ], ), ] @@ -129,21 +129,21 @@ def test_json_deserialization() -> None: def test_json_serialization(table_name_mapping_nested: NameMapping) -> None: assert ( table_name_mapping_nested.model_dump_json() - == """[{"field-id":1,"names":["foo"]},{"field-id":2,"names":["bar"]},{"field-id":3,"names":["baz"]},{"field-id":4,"names":["qux"],"fields":[{"field-id":5,"names":["element"]}]},{"field-id":6,"names":["quux"],"fields":[{"field-id":7,"names":["key"]},{"field-id":8,"names":["value"],"fields":[{"field-id":9,"names":["key"]},{"field-id":10,"names":["value"]}]}]},{"field-id":11,"names":["location"],"fields":[{"field-id":12,"names":["element"],"fields":[{"field-id":13,"names":["latitude"]},{"field-id":14,"names":["longitude"]}]}]},{"field-id":15,"names":["person"],"fields":[{"field-id":16,"names":["name"]},{"field-id":17,"names":["age"]}]}]""" + == """[["field-id":1,"names":["foo"]],["field-id":2,"names":["bar"]],["field-id":3,"names":["baz"]],["field-id":4,"names":["qux"],"fields":[["field-id":5,"names":["element"]]]],["field-id":6,"names":["quux"],"fields":[["field-id":7,"names":["key"]],["field-id":8,"names":["value"],"fields":[["field-id":9,"names":["key"]],["field-id":10,"names":["value"]]]]]],["field-id":11,"names":["location"],"fields":[["field-id":12,"names":["element"],"fields":[["field-id":13,"names":["latitude"]],["field-id":14,"names":["longitude"]]]]]],["field-id":15,"names":["person"],"fields":[["field-id":16,"names":["name"]],["field-id":17,"names":["age"]]]]]""" ) def test_name_mapping_to_string() -> None: nm = NameMapping( [ - MappedField(field_id=1, names={'record_id', 'id'}), - MappedField(field_id=2, names={'data'}), + MappedField(field_id=1, names=['record_id', 'id']), + MappedField(field_id=2, names=['data']), MappedField( - names={'location'}, + names=['location'], field_id=3, fields=[ - MappedField(field_id=4, names={'lat', 'latitude'}), - MappedField(field_id=5, names={'longitude', 'long'}), + MappedField(field_id=4, names=['lat', 'latitude']), + MappedField(field_id=5, names=['longitude', 'long']), ], ), ] @@ -166,114 +166,114 @@ def test_mapping_from_schema(table_schema_nested: Schema, table_name_mapping_nes def test_mapping_by_id(table_name_mapping_nested: NameMapping) -> None: assert table_name_mapping_nested._field_by_id == { - 1: MappedField(field_id=1, names={'foo'}), - 2: MappedField(field_id=2, names={'bar'}), - 3: MappedField(field_id=3, names={'baz'}), - 5: MappedField(field_id=5, names={'element'}), - 4: MappedField(field_id=4, names={'qux'}, fields=[MappedField(field_id=5, names={'element'})]), - 7: MappedField(field_id=7, names={'key'}), - 9: MappedField(field_id=9, names={'key'}), - 10: MappedField(field_id=10, names={'value'}), + 1: MappedField(field_id=1, names=['foo']), + 2: MappedField(field_id=2, names=['bar']), + 3: MappedField(field_id=3, names=['baz']), + 5: MappedField(field_id=5, names=['element']), + 4: MappedField(field_id=4, names=['qux'], fields=[MappedField(field_id=5, names=['element'])]), + 7: MappedField(field_id=7, names=['key']), + 9: MappedField(field_id=9, names=['key']), + 10: MappedField(field_id=10, names=['value']), 8: MappedField( field_id=8, - names={'value'}, - fields=[MappedField(field_id=9, names={'key'}), MappedField(field_id=10, names={'value'})], + names=['value'], + fields=[MappedField(field_id=9, names=['key']), MappedField(field_id=10, names=['value'])], ), 6: MappedField( field_id=6, - names={'quux'}, + names=['quux'], fields=[ - MappedField(field_id=7, names={'key'}), + MappedField(field_id=7, names=['key']), MappedField( field_id=8, - names={'value'}, - fields=[MappedField(field_id=9, names={'key'}), MappedField(field_id=10, names={'value'})], + names=['value'], + fields=[MappedField(field_id=9, names=['key']), MappedField(field_id=10, names=['value'])], ), ], ), - 13: MappedField(field_id=13, names={'latitude'}), - 14: MappedField(field_id=14, names={'longitude'}), + 13: MappedField(field_id=13, names=['latitude']), + 14: MappedField(field_id=14, names=['longitude']), 12: MappedField( field_id=12, - names={'element'}, - fields=[MappedField(field_id=13, names={'latitude'}), MappedField(field_id=14, names={'longitude'})], + names=['element'], + fields=[MappedField(field_id=13, names=['latitude']), MappedField(field_id=14, names=['longitude'])], ), 11: MappedField( field_id=11, - names={'location'}, + names=['location'], fields=[ MappedField( field_id=12, - names={'element'}, + names=['element'], fields=[ - MappedField(field_id=13, names={'latitude'}), - MappedField(field_id=14, names={'longitude'}), + MappedField(field_id=13, names=['latitude']), + MappedField(field_id=14, names=['longitude']), ], ) ], ), - 16: MappedField(field_id=16, names={'name'}), - 17: MappedField(field_id=17, names={'age'}), + 16: MappedField(field_id=16, names=['name']), + 17: MappedField(field_id=17, names=['age']), 15: MappedField( field_id=15, - names={'person'}, - fields=[MappedField(field_id=16, names={'name'}), MappedField(field_id=17, names={'age'})], + names=['person'], + fields=[MappedField(field_id=16, names=['name']), MappedField(field_id=17, names=['age'])], ), } def test_mapping_by_name(table_name_mapping_nested: NameMapping) -> None: assert table_name_mapping_nested._field_by_name == { - 'person.age': MappedField(field_id=17, names={'age'}), - 'person.name': MappedField(field_id=16, names={'name'}), + 'person.age': MappedField(field_id=17, names=['age']), + 'person.name': MappedField(field_id=16, names=['name']), 'person': MappedField( field_id=15, - names={'person'}, - fields=[MappedField(field_id=16, names={'name'}), MappedField(field_id=17, names={'age'})], + names=['person'], + fields=[MappedField(field_id=16, names=['name']), MappedField(field_id=17, names=['age'])], ), - 'location.element.longitude': MappedField(field_id=14, names={'longitude'}), - 'location.element.latitude': MappedField(field_id=13, names={'latitude'}), + 'location.element.longitude': MappedField(field_id=14, names=['longitude']), + 'location.element.latitude': MappedField(field_id=13, names=['latitude']), 'location.element': MappedField( field_id=12, - names={'element'}, - fields=[MappedField(field_id=13, names={'latitude'}), MappedField(field_id=14, names={'longitude'})], + names=['element'], + fields=[MappedField(field_id=13, names=['latitude']), MappedField(field_id=14, names=['longitude'])], ), 'location': MappedField( field_id=11, - names={'location'}, + names=['location'], fields=[ MappedField( field_id=12, - names={'element'}, - fields=[MappedField(field_id=13, names={'latitude'}), MappedField(field_id=14, names={'longitude'})], + names=['element'], + fields=[MappedField(field_id=13, names=['latitude']), MappedField(field_id=14, names=['longitude'])], ) ], ), - 'quux.value.value': MappedField(field_id=10, names={'value'}), - 'quux.value.key': MappedField(field_id=9, names={'key'}), + 'quux.value.value': MappedField(field_id=10, names=['value']), + 'quux.value.key': MappedField(field_id=9, names=['key']), 'quux.value': MappedField( field_id=8, - names={'value'}, - fields=[MappedField(field_id=9, names={'key'}), MappedField(field_id=10, names={'value'})], + names=['value'], + fields=[MappedField(field_id=9, names=['key']), MappedField(field_id=10, names=['value'])], ), - 'quux.key': MappedField(field_id=7, names={'key'}), + 'quux.key': MappedField(field_id=7, names=['key']), 'quux': MappedField( field_id=6, - names={'quux'}, + names=['quux'], fields=[ - MappedField(field_id=7, names={'key'}), + MappedField(field_id=7, names=['key']), MappedField( field_id=8, - names={'value'}, - fields=[MappedField(field_id=9, names={'key'}), MappedField(field_id=10, names={'value'})], + names=['value'], + fields=[MappedField(field_id=9, names=['key']), MappedField(field_id=10, names=['value'])], ), ], ), - 'qux.element': MappedField(field_id=5, names={'element'}), - 'qux': MappedField(field_id=4, names={'qux'}, fields=[MappedField(field_id=5, names={'element'})]), - 'baz': MappedField(field_id=3, names={'baz'}), - 'bar': MappedField(field_id=2, names={'bar'}), - 'foo': MappedField(field_id=1, names={'foo'}), + 'qux.element': MappedField(field_id=5, names=['element']), + 'qux': MappedField(field_id=4, names=['qux'], fields=[MappedField(field_id=5, names=['element'])]), + 'baz': MappedField(field_id=3, names=['baz']), + 'bar': MappedField(field_id=2, names=['bar']), + 'foo': MappedField(field_id=1, names=['foo']), } @@ -285,7 +285,7 @@ def test_mapping_lookup_by_name(table_name_mapping_nested: NameMapping) -> None: def test_mapping_lookup_by_field_id(table_name_mapping_nested: NameMapping) -> None: - assert table_name_mapping_nested.field(1) == MappedField(field_id=1, names={'foo'}) + assert table_name_mapping_nested.field(1) == MappedField(field_id=1, names=['foo']) with pytest.raises(ValueError, match="Could not find field-id: 22"): table_name_mapping_nested.field(22) From c13e3b3097a29e6a8fd54319e678a06f4ee97f20 Mon Sep 17 00:00:00 2001 From: Fokko Driesprong Date: Thu, 14 Dec 2023 19:46:57 +0100 Subject: [PATCH 4/6] make tests happy --- pyiceberg/table/name_mapping.py | 4 ++-- tests/table/test_name_mapping.py | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pyiceberg/table/name_mapping.py b/pyiceberg/table/name_mapping.py index ea3c1495d1..6323feb1de 100644 --- a/pyiceberg/table/name_mapping.py +++ b/pyiceberg/table/name_mapping.py @@ -25,7 +25,7 @@ from abc import ABC, abstractmethod from collections import ChainMap from functools import cached_property, singledispatch -from typing import Any, Dict, Generic, List, Set, TypeVar, Union +from typing import Any, Dict, Generic, List, TypeVar, Union from pydantic import Field, conlist, model_serializer @@ -36,7 +36,7 @@ class MappedField(IcebergBaseModel): field_id: int = Field(alias="field-id") - names: Set[str] = conlist(str, min_length=1) + names: List[str] = conlist(str, min_length=1) fields: List[MappedField] = Field(default_factory=list) @model_serializer diff --git a/tests/table/test_name_mapping.py b/tests/table/test_name_mapping.py index 41db1b9fc6..d5cbb955e1 100644 --- a/tests/table/test_name_mapping.py +++ b/tests/table/test_name_mapping.py @@ -72,53 +72,53 @@ def table_name_mapping_nested() -> NameMapping: def test_json_deserialization() -> None: name_mapping = """ [ - [ + { "field-id": 1, "names": [ "id", "record_id" ] - ], - [ + }, + { "field-id": 2, "names": [ "data" ] - ], - [ + }, + { "field-id": 3, "names": [ "location" ], "fields": [ - [ + { "field-id": 4, "names": [ "latitude", "lat" ] - ], - [ + }, + { "field-id": 5, "names": [ "longitude", "long" ] - ] + } ] - ] + } ] """ assert load_mapping_from_json(name_mapping) == NameMapping( [ - MappedField(field_id=1, names=['record_id', 'id']), + MappedField(field_id=1, names=['id', 'record_id']), MappedField(field_id=2, names=['data']), MappedField( names=['location'], field_id=3, fields=[ - MappedField(field_id=4, names=['lat', 'latitude']), + MappedField(field_id=4, names=['latitude', 'lat']), MappedField(field_id=5, names=['longitude', 'long']), ], ), @@ -129,21 +129,21 @@ def test_json_deserialization() -> None: def test_json_serialization(table_name_mapping_nested: NameMapping) -> None: assert ( table_name_mapping_nested.model_dump_json() - == """[["field-id":1,"names":["foo"]],["field-id":2,"names":["bar"]],["field-id":3,"names":["baz"]],["field-id":4,"names":["qux"],"fields":[["field-id":5,"names":["element"]]]],["field-id":6,"names":["quux"],"fields":[["field-id":7,"names":["key"]],["field-id":8,"names":["value"],"fields":[["field-id":9,"names":["key"]],["field-id":10,"names":["value"]]]]]],["field-id":11,"names":["location"],"fields":[["field-id":12,"names":["element"],"fields":[["field-id":13,"names":["latitude"]],["field-id":14,"names":["longitude"]]]]]],["field-id":15,"names":["person"],"fields":[["field-id":16,"names":["name"]],["field-id":17,"names":["age"]]]]]""" + == """[{"field-id":1,"names":["foo"]},{"field-id":2,"names":["bar"]},{"field-id":3,"names":["baz"]},{"field-id":4,"names":["qux"],"fields":[{"field-id":5,"names":["element"]}]},{"field-id":6,"names":["quux"],"fields":[{"field-id":7,"names":["key"]},{"field-id":8,"names":["value"],"fields":[{"field-id":9,"names":["key"]},{"field-id":10,"names":["value"]}]}]},{"field-id":11,"names":["location"],"fields":[{"field-id":12,"names":["element"],"fields":[{"field-id":13,"names":["latitude"]},{"field-id":14,"names":["longitude"]}]}]},{"field-id":15,"names":["person"],"fields":[{"field-id":16,"names":["name"]},{"field-id":17,"names":["age"]}]}]""" ) def test_name_mapping_to_string() -> None: nm = NameMapping( [ - MappedField(field_id=1, names=['record_id', 'id']), + MappedField(field_id=1, names=['id', 'record_id']), MappedField(field_id=2, names=['data']), MappedField( names=['location'], field_id=3, fields=[ MappedField(field_id=4, names=['lat', 'latitude']), - MappedField(field_id=5, names=['longitude', 'long']), + MappedField(field_id=5, names=['long', 'longitude']), ], ), ] From 623ad6ad2091583bc4ea0993912dbb830ea8d72e Mon Sep 17 00:00:00 2001 From: Fokko Driesprong Date: Sun, 17 Dec 2023 19:47:55 +0100 Subject: [PATCH 5/6] Change to lists, thanks HonahX --- pyiceberg/table/name_mapping.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyiceberg/table/name_mapping.py b/pyiceberg/table/name_mapping.py index 6323feb1de..f3d1164eb4 100644 --- a/pyiceberg/table/name_mapping.py +++ b/pyiceberg/table/name_mapping.py @@ -179,7 +179,7 @@ def schema(self, schema: Schema, struct_result: List[MappedField]) -> List[Mappe def struct(self, struct: StructType, field_results: List[List[MappedField]]) -> List[MappedField]: return [ - MappedField(field_id=field.field_id, names={field.name}, fields=result) + MappedField(field_id=field.field_id, names=[field.name], fields=result) for field, result in zip(struct.fields, field_results) ] @@ -187,12 +187,12 @@ def field(self, field: NestedField, field_result: List[MappedField]) -> List[Map return field_result def list(self, list_type: ListType, element_result: List[MappedField]) -> List[MappedField]: - return [MappedField(field_id=list_type.element_id, names={"element"}, fields=element_result)] + return [MappedField(field_id=list_type.element_id, names=["element"], fields=element_result)] def map(self, map_type: MapType, key_result: List[MappedField], value_result: List[MappedField]) -> List[MappedField]: return [ - MappedField(field_id=map_type.key_id, names={"key"}, fields=key_result), - MappedField(field_id=map_type.value_id, names={"value"}, fields=value_result), + MappedField(field_id=map_type.key_id, names=["key"], fields=key_result), + MappedField(field_id=map_type.value_id, names=["value"], fields=value_result), ] def primitive(self, primitive: PrimitiveType) -> List[MappedField]: From e5982c6ecb7bfdb2b15d57760b9e8c9f151192f6 Mon Sep 17 00:00:00 2001 From: Fokko Driesprong Date: Mon, 18 Dec 2023 10:32:45 +0100 Subject: [PATCH 6/6] Thanks Ryan! --- pyiceberg/table/name_mapping.py | 45 ++++----------- tests/table/test_name_mapping.py | 95 +++++++++----------------------- 2 files changed, 35 insertions(+), 105 deletions(-) diff --git a/pyiceberg/table/name_mapping.py b/pyiceberg/table/name_mapping.py index f3d1164eb4..b07fbc0735 100644 --- a/pyiceberg/table/name_mapping.py +++ b/pyiceberg/table/name_mapping.py @@ -27,7 +27,7 @@ from functools import cached_property, singledispatch from typing import Any, Dict, Generic, List, TypeVar, Union -from pydantic import Field, conlist, model_serializer +from pydantic import Field, conlist, field_validator, model_serializer from pyiceberg.schema import Schema, SchemaVisitor, visit from pyiceberg.typedef import IcebergBaseModel, IcebergRootModel @@ -39,6 +39,11 @@ class MappedField(IcebergBaseModel): names: List[str] = conlist(str, min_length=1) fields: List[MappedField] = Field(default_factory=list) + @field_validator('fields', mode='before') + @classmethod + def convert_null_to_empty_List(cls, v: Any) -> Any: + return v or [] + @model_serializer def ser_model(self) -> Dict[str, Any]: """Set custom serializer to leave out the field when it is empty.""" @@ -64,26 +69,17 @@ def __str__(self) -> str: class NameMapping(IcebergRootModel[List[MappedField]]): root: List[MappedField] - @cached_property - def _field_by_id(self) -> Dict[int, MappedField]: - return visit_name_mapping(self, _IndexById()) - @cached_property def _field_by_name(self) -> Dict[str, MappedField]: return visit_name_mapping(self, _IndexByName()) - def id(self, name: str) -> int: + def find(self, *names: str) -> MappedField: + name = '.'.join(names) try: - return self._field_by_name[name].field_id + return self._field_by_name[name] except KeyError as e: raise ValueError(f"Could not find field with name: {name}") from e - def field(self, field_id: int) -> MappedField: - try: - return self._field_by_id[field_id] - except KeyError as e: - raise ValueError(f"Could not find field-id: {field_id}") from e - def __len__(self) -> int: """Return the number of mappings.""" return len(self.root) @@ -113,27 +109,6 @@ def field(self, field: MappedField, field_result: T) -> T: """Visit a MappedField.""" -class _IndexById(NameMappingVisitor[Dict[int, MappedField]]): - result: Dict[int, MappedField] - - def __init__(self) -> None: - self.result = {} - - def mapping(self, nm: NameMapping, field_results: Dict[int, MappedField]) -> Dict[int, MappedField]: - return field_results - - def fields(self, struct: List[MappedField], field_results: List[Dict[int, MappedField]]) -> Dict[int, MappedField]: - return self.result - - def field(self, field: MappedField, field_result: Dict[int, MappedField]) -> Dict[int, MappedField]: - if field.field_id in self.result: - raise ValueError(f"Invalid mapping: ID {field.field_id} is not unique") - - self.result[field.field_id] = field - - return self.result - - class _IndexByName(NameMappingVisitor[Dict[str, MappedField]]): def mapping(self, nm: NameMapping, field_results: Dict[str, MappedField]) -> Dict[str, MappedField]: return field_results @@ -169,7 +144,7 @@ def _(fields: List[MappedField], visitor: NameMappingVisitor[T]) -> T: return visitor.fields(fields, results) -def load_mapping_from_json(mapping: str) -> NameMapping: +def parse_mapping_from_json(mapping: str) -> NameMapping: return NameMapping.model_validate_json(mapping) diff --git a/tests/table/test_name_mapping.py b/tests/table/test_name_mapping.py index d5cbb955e1..37111a5e3e 100644 --- a/tests/table/test_name_mapping.py +++ b/tests/table/test_name_mapping.py @@ -17,7 +17,7 @@ import pytest from pyiceberg.schema import Schema -from pyiceberg.table.name_mapping import MappedField, NameMapping, create_mapping_from_schema, load_mapping_from_json +from pyiceberg.table.name_mapping import MappedField, NameMapping, create_mapping_from_schema, parse_mapping_from_json @pytest.fixture(scope="session") @@ -69,7 +69,24 @@ def table_name_mapping_nested() -> NameMapping: ) -def test_json_deserialization() -> None: +def test_json_mapped_field_deserialization() -> None: + mapped_field = """{ + "field-id": 1, + "names": ["id", "record_id"] + } + """ + assert MappedField(field_id=1, names=['id', 'record_id']) == MappedField.model_validate_json(mapped_field) + + mapped_field_with_null_fields = """{ + "field-id": 1, + "names": ["id", "record_id"], + "fields": null + } + """ + assert MappedField(field_id=1, names=['id', 'record_id']) == MappedField.model_validate_json(mapped_field_with_null_fields) + + +def test_json_name_mapping_deserialization() -> None: name_mapping = """ [ { @@ -110,7 +127,7 @@ def test_json_deserialization() -> None: ] """ - assert load_mapping_from_json(name_mapping) == NameMapping( + assert parse_mapping_from_json(name_mapping) == NameMapping( [ MappedField(field_id=1, names=['id', 'record_id']), MappedField(field_id=2, names=['data']), @@ -164,64 +181,6 @@ def test_mapping_from_schema(table_schema_nested: Schema, table_name_mapping_nes assert nm == table_name_mapping_nested -def test_mapping_by_id(table_name_mapping_nested: NameMapping) -> None: - assert table_name_mapping_nested._field_by_id == { - 1: MappedField(field_id=1, names=['foo']), - 2: MappedField(field_id=2, names=['bar']), - 3: MappedField(field_id=3, names=['baz']), - 5: MappedField(field_id=5, names=['element']), - 4: MappedField(field_id=4, names=['qux'], fields=[MappedField(field_id=5, names=['element'])]), - 7: MappedField(field_id=7, names=['key']), - 9: MappedField(field_id=9, names=['key']), - 10: MappedField(field_id=10, names=['value']), - 8: MappedField( - field_id=8, - names=['value'], - fields=[MappedField(field_id=9, names=['key']), MappedField(field_id=10, names=['value'])], - ), - 6: MappedField( - field_id=6, - names=['quux'], - fields=[ - MappedField(field_id=7, names=['key']), - MappedField( - field_id=8, - names=['value'], - fields=[MappedField(field_id=9, names=['key']), MappedField(field_id=10, names=['value'])], - ), - ], - ), - 13: MappedField(field_id=13, names=['latitude']), - 14: MappedField(field_id=14, names=['longitude']), - 12: MappedField( - field_id=12, - names=['element'], - fields=[MappedField(field_id=13, names=['latitude']), MappedField(field_id=14, names=['longitude'])], - ), - 11: MappedField( - field_id=11, - names=['location'], - fields=[ - MappedField( - field_id=12, - names=['element'], - fields=[ - MappedField(field_id=13, names=['latitude']), - MappedField(field_id=14, names=['longitude']), - ], - ) - ], - ), - 16: MappedField(field_id=16, names=['name']), - 17: MappedField(field_id=17, names=['age']), - 15: MappedField( - field_id=15, - names=['person'], - fields=[MappedField(field_id=16, names=['name']), MappedField(field_id=17, names=['age'])], - ), - } - - def test_mapping_by_name(table_name_mapping_nested: NameMapping) -> None: assert table_name_mapping_nested._field_by_name == { 'person.age': MappedField(field_id=17, names=['age']), @@ -278,14 +237,10 @@ def test_mapping_by_name(table_name_mapping_nested: NameMapping) -> None: def test_mapping_lookup_by_name(table_name_mapping_nested: NameMapping) -> None: - assert table_name_mapping_nested.id("foo") == 1 + assert table_name_mapping_nested.find("foo") == MappedField(field_id=1, names=['foo']) + assert table_name_mapping_nested.find("location.element.latitude") == MappedField(field_id=13, names=['latitude']) + assert table_name_mapping_nested.find("location", "element", "latitude") == MappedField(field_id=13, names=['latitude']) + assert table_name_mapping_nested.find(*["location", "element", "latitude"]) == MappedField(field_id=13, names=['latitude']) with pytest.raises(ValueError, match="Could not find field with name: boom"): - table_name_mapping_nested.id("boom") - - -def test_mapping_lookup_by_field_id(table_name_mapping_nested: NameMapping) -> None: - assert table_name_mapping_nested.field(1) == MappedField(field_id=1, names=['foo']) - - with pytest.raises(ValueError, match="Could not find field-id: 22"): - table_name_mapping_nested.field(22) + table_name_mapping_nested.find("boom")