Skip to content
Merged
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
64 changes: 62 additions & 2 deletions src/oold/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions src/oold/model/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions tests/data/test_core/model_v1_nested.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions tests/data/test_core/model_v2_nested.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions tests/test_oold.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading