diff --git a/src/oold/model/__init__.py b/src/oold/model/__init__.py index bcca8a8..ae208cb 100644 --- a/src/oold/model/__init__.py +++ b/src/oold/model/__init__.py @@ -91,10 +91,10 @@ class LinkedBaseModelMetaClass(pydantic.main._model_construction.ModelMetaclass) flag our __getattribute__ override would return a truthy FieldInfo instead of the default None, causing false-positive field-name collision errors.""" - def __new__(mcs, name, bases, namespace): + def __new__(mcs, name, bases, namespace, **kwargs): LinkedBaseModelMetaClass._constructing = True try: - cls = super().__new__(mcs, name, bases, namespace) + cls = super().__new__(mcs, name, bases, namespace, **kwargs) finally: LinkedBaseModelMetaClass._constructing = False @@ -633,11 +633,70 @@ def model_dump(self, **kwargs): # extent BaseClass export function d = super().model_dump(**kwargs) # pprint(d) self._object_to_iri(d) + self._recursive_object_to_iri(d, self) if remove_none: d = self.remove_none(d) # pprint(d) return d + @staticmethod + def _recursive_object_to_iri(d: dict, model_obj): + """Recursively apply __iris__ replacement for nested model objects.""" + for name, value in list(d.items()): + if name not in model_obj.model_fields: + continue + # Access raw value without triggering IRI resolution + model_value = model_obj.__dict__.get(name) + if isinstance(value, list) and isinstance(model_value, list): + for item, model_item in zip(value, model_value): + if isinstance(item, dict) and hasattr(model_item, "__iris__"): + model_item._object_to_iri(item) + LinkedBaseModel._recursive_object_to_iri(item, model_item) + elif isinstance(value, dict) and hasattr(model_value, "__iris__"): + model_value._object_to_iri(value) + LinkedBaseModel._recursive_object_to_iri(value, model_value) + + def get_iri_ref(self, field_name: str): + """Return the stored IRI reference string(s) for a field without + triggering resolution. + + Parameters + ---------- + field_name + The name of the field to retrieve the IRI reference for. + + Returns + ------- + A string IRI, a list of string IRIs, or ``None`` if no IRI is + stored for the given field. + """ + iris = self.__iris__.get(field_name) + if iris is None: + return None + if isinstance(iris, list): + return iris if iris else None + return iris + + def get_raw(self, field_name: str): + """Return the raw value of a field without triggering IRI resolution. + + Unlike normal attribute access which may trigger network calls to + resolve IRI references, this returns the Python object as stored + internally (``None`` for unresolved IRIs, the model instance if + already resolved, or a plain value for non-IRI fields). + + Parameters + ---------- + field_name + The name of the field to retrieve. + + Returns + ------- + The raw field value, or ``None`` if the field is unresolved or + does not exist. + """ + return self.__dict__.get(field_name) + @staticmethod def _resolve(iris): resolver = get_resolver(GetResolverParam(iri=iris[0])).resolver @@ -792,6 +851,7 @@ def model_dump_json( # this may replace some None values with IRIs in case they were never resolved # thats why we handle exclude_none there self._object_to_iri(d) + self._recursive_object_to_iri(d, self) if exclude_none: d = self.remove_none(d) return json.dumps(d, **dumps_kwargs) diff --git a/src/oold/model/v1/__init__.py b/src/oold/model/v1/__init__.py index 08fc27f..bd9bca6 100644 --- a/src/oold/model/v1/__init__.py +++ b/src/oold/model/v1/__init__.py @@ -542,6 +542,47 @@ def __getattribute__(self, name): ) return result + def get_iri_ref(self, field_name: str): + """Return the stored IRI reference string(s) for a field without + triggering resolution. + + Parameters + ---------- + field_name + The name of the field to retrieve the IRI reference for. + + Returns + ------- + A string IRI, a list of string IRIs, or ``None`` if no IRI is + stored for the given field. + """ + iris = self.__iris__.get(field_name) + if iris is None: + return None + if isinstance(iris, list): + return iris if iris else None + return iris + + def get_raw(self, field_name: str): + """Return the raw value of a field without triggering IRI resolution. + + Unlike normal attribute access which may trigger network calls to + resolve IRI references, this returns the Python object as stored + internally (``None`` for unresolved IRIs, the model instance if + already resolved, or a plain value for non-IRI fields). + + Parameters + ---------- + field_name + The name of the field to retrieve. + + Returns + ------- + The raw field value, or ``None`` if the field is unresolved or + does not exist. + """ + return self.__dict__.get(field_name) + @staticmethod def _resolve(iris): resolver = get_resolver(GetResolverParam(iri=iris[0])).resolver @@ -617,6 +658,23 @@ def oold_query( """Allow access to the class by its IRI.""" return cls._oold_query(item) + @staticmethod + def _recursive_object_to_iri(d: dict, model_obj): + """Recursively apply __iris__ replacement for nested model objects.""" + for name, value in list(d.items()): + if name not in model_obj.__fields__: + continue + # Access raw value without triggering IRI resolution + model_value = model_obj.__dict__.get(name) + if isinstance(value, list) and isinstance(model_value, list): + for i, (item, model_item) in enumerate(zip(value, model_value)): + if isinstance(item, dict) and hasattr(model_item, "__iris__"): + model_item._object_to_iri(item) + LinkedBaseModel._recursive_object_to_iri(item, model_item) + elif isinstance(value, dict) and hasattr(model_value, "__iris__"): + model_value._object_to_iri(value) + LinkedBaseModel._recursive_object_to_iri(value, model_value) + # pydantic v1 def json( self, @@ -657,6 +715,8 @@ def json( # this may replace some None values with IRIs in case they were never resolved # thats why we handle exclude_none there self._object_to_iri(d) + # Recursively apply _object_to_iri for nested models + self._recursive_object_to_iri(d, self) if exclude_none: d = self.remove_none(d) return json.dumps(d, **dumps_kwargs) diff --git a/tests/data/test_core/model_v1_nested.py b/tests/data/test_core/model_v1_nested.py new file mode 100644 index 0000000..0e57d02 --- /dev/null +++ b/tests/data/test_core/model_v1_nested.py @@ -0,0 +1,45 @@ +"""Test models for nested IRI serialization.""" + +from __future__ import annotations + +from pydantic.v1 import Field + +from oold.model.v1 import LinkedBaseModel + + +class Bar2(LinkedBaseModel): + class Config: + schema_extra = {"title": "Bar2"} + + id: str | None = None + type: list[str] | None = ["Bar2"] + prop1: str | None = None + + +class Bar(Bar2): + class Config: + schema_extra = {"title": "Bar"} + + type: list[str] | None = ["Bar"] + prop2: str | None = None + + +class NestedItem(LinkedBaseModel): + """A nested item with an IRI reference field.""" + + class Config: + schema_extra = {"title": "NestedItem"} + + id: str | None = None + type: list[str] | None = ["NestedItem"] + ref: Bar | None = Field(None, range="Bar.json") + value: int | None = None + + +class Container(LinkedBaseModel): + class Config: + schema_extra = {"title": "Container"} + + id: str + type: list[str] | None = ["Container"] + items: list[NestedItem] | None = None diff --git a/tests/data/test_core/model_v2_nested.py b/tests/data/test_core/model_v2_nested.py new file mode 100644 index 0000000..e06da5c --- /dev/null +++ b/tests/data/test_core/model_v2_nested.py @@ -0,0 +1,41 @@ +"""Test models for nested IRI serialization (pydantic v2).""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from oold.model import LinkedBaseModel + + +class Bar2(LinkedBaseModel): + model_config = ConfigDict(json_schema_extra={"title": "Bar2"}) + + id: str | None = None + type: list[str] | None = ["Bar2"] + prop1: str | None = None + + +class Bar(Bar2): + model_config = ConfigDict(json_schema_extra={"title": "Bar"}) + + type: list[str] | None = ["Bar"] + prop2: str | None = None + + +class NestedItem(LinkedBaseModel): + """A nested item with an IRI reference field.""" + + model_config = ConfigDict(json_schema_extra={"title": "NestedItem"}) + + id: str | None = None + type: list[str] | None = ["NestedItem"] + ref: Bar | None = Field(None, json_schema_extra={"range": "Bar.json"}) + value: int | None = None + + +class Container(LinkedBaseModel): + model_config = ConfigDict(json_schema_extra={"title": "Container"}) + + id: str + type: list[str] | None = ["Container"] + items: list[NestedItem] | None = None diff --git a/tests/test_oold.py b/tests/test_oold.py index 5c1b0f9..a96cd6a 100644 --- a/tests/test_oold.py +++ b/tests/test_oold.py @@ -238,6 +238,42 @@ def test_core(pydantic_version, benchmark): benchmark(_run, pydantic_version) +@pytest.mark.parametrize("pydantic_version", ["v1", "v2"]) +def test_nested_iri_serialization(pydantic_version): + """Test that IRIs in nested model objects are preserved during serialization.""" + if pydantic_version == "v1": + from data.test_core.model_v1_nested import Container, NestedItem + else: + from data.test_core.model_v2_nested import Container, NestedItem + + c = Container( + id="ex:c", + items=[ + NestedItem(ref="ex:existing", value=1), + NestedItem(ref="ex:doesNotExist", value=2), + ], + ) + c_json = c.to_json() + assert "items" in c_json + assert len(c_json["items"]) == 2 + assert c_json["items"][0]["ref"] == "ex:existing" + assert c_json["items"][1]["ref"] == "ex:doesNotExist" + assert c_json["items"][0]["value"] == 1 + assert c_json["items"][1]["value"] == 2 + + # Test get_iri_ref helper + item0 = c.__dict__["items"][0] + assert item0.get_iri_ref("ref") == "ex:existing" + item1 = c.__dict__["items"][1] + assert item1.get_iri_ref("ref") == "ex:doesNotExist" + assert item0.get_iri_ref("value") is None # not an IRI field + + # Test get_raw helper + assert item0.get_raw("ref") is None # unresolved IRI → None internally + assert item0.get_raw("value") == 1 # plain value preserved + assert item0.get_raw("nonexistent") is None # missing field + + if __name__ == "__main__": _run("v1") _run("v2")