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
11 changes: 9 additions & 2 deletions src/oold/model/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,11 +246,18 @@ def _raw_dict(self) -> dict[str, Any]:
return d

def to_json(self, exclude_defaults: bool = False) -> dict[str, Any]:
from oold.model._descriptor import _matches_link_default

result = json.loads(self.model_dump_json(exclude_none=True, exclude_defaults=exclude_defaults))
for name in type(self).__link_fields__:
iri = self.get_iri_ref(name)
if iri is not None and not result.get(name):
result[name] = iri
if iri is None or result.get(name):
continue
# This loop re-adds what the dump left out, so it also has to
# respect the one exclusion the dump applied to links.
if exclude_defaults and _matches_link_default(type(self), name, iri):
continue
result[name] = iri
return result

@classmethod
Expand Down
23 changes: 23 additions & 0 deletions src/oold/model/_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,24 @@ def _excluded(info: Any, name: str) -> bool:
return bool(exclude) and name in exclude


def _matches_link_default(cls: type, name: str, iris: Any) -> bool:
"""Whether a link still holds exactly the IRI(s) its field declares.

A link value never reaches the field pydantic validates, so that field sits
at ``None`` and pydantic's own ``exclude_defaults`` comparison always answers
"not a default". The declared IRI lives in ``__link_defaults__`` instead, and
is what a stored value has to be compared against.

Both sides are read as lists: a to-one default seeded into a to-many link is
stored as a one-element list, so the shapes differ while the value does not.
"""
declared = getattr(cls, "__link_defaults__", {}).get(name)
if declared is None:
return False
stored = iris if isinstance(iris, list) else [iris]
return stored == (declared if isinstance(declared, list) else [declared])


def _alias_strings(alias: Any) -> list[str]:
"""Every name an alias can be given under.

Expand Down Expand Up @@ -1469,6 +1487,11 @@ def _serialize_links(self, handler: Any, info: SerializationInfo) -> dict[str, A
iris = descr.iris(self)
if iris:
d.pop(name, None)
# A link left at the IRI its field declares is a default like
# any other. handler() cannot apply exclude_defaults to it,
# because the field it compares was emptied by the binding.
if getattr(info, "exclude_defaults", False) and _matches_link_default(type(self), name, iris):
continue
d[name_out] = iris
continue
stored = self._links.get(name)
Expand Down
11 changes: 10 additions & 1 deletion src/oold/model/v1/_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
_AutoLink,
_Constructing,
_default_iris,
_matches_link_default,
)

_MANY_SHAPES = {SHAPE_LIST, SHAPE_SET, SHAPE_TUPLE}
Expand Down Expand Up @@ -324,6 +325,11 @@ def get_cls_iri(cls) -> Any:
def dict(self, **kwargs: Any) -> dict[str, Any]:
"""v1 serialisation; link fields collapse to their IRIs."""
exclude_none = kwargs.pop("exclude_none", False)
exclude_defaults = bool(kwargs.get("exclude_defaults"))
exclude = kwargs.get("exclude") or ()
# The link keys are written back after super().dict() has applied the
# exclusions, so each has to be filtered again here or it reappears.
skip_unset = bool(exclude_none or exclude_defaults or kwargs.get("exclude_unset"))
links = type(self).__link_fields__
# Reading a link caches the resolved value in __dict__, which pydantic v1
# serialises - so whether a link had been read changed the output. Drop
Expand All @@ -335,9 +341,12 @@ def dict(self, **kwargs: Any) -> dict[str, Any]:
self.__dict__.update(cached)
for name, descr in links.items():
iris = descr.iris(self)
d.pop(name, None)
if iris:
if exclude_defaults and _matches_link_default(type(self), name, iris):
continue
d[name] = iris
else:
elif not (skip_unset or name in exclude):
d[name] = None
if exclude_none:
d = {k: v for k, v in d.items() if v is not None}
Expand Down
77 changes: 77 additions & 0 deletions tests/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,80 @@ class Foo(LinkedBaseModel):
obj = Foo(value=1.0)
result = obj.to_json()
assert "label" not in result


def _model_with_a_link_default(pydantic_version, tag):
"""A model whose link declares a default IRI, spelled as each version does.

Imports the descriptor bindings directly rather than through
``oold.model``: the legacy bindings do not record a link's declared default
at all, so what is asserted here would depend on OOLD_DESCRIPTOR_BINDING.
"""
if pydantic_version == "v2":
from oold.model._descriptor import Link, LinkedBaseModel, OoldField

class Org(LinkedBaseModel):
id: str
type: str | None = f"ex:{tag}Org"

class Person(LinkedBaseModel):
id: str
type: str | None = f"ex:{tag}Person"
employer: Link[Org | None] = OoldField(default="ex:default-org")
mentor: Link[Org | None] = OoldField()

return Person

from pydantic.v1 import Field

from oold.model.v1._descriptor import LinkedBaseModel

class Org(LinkedBaseModel):
id: str
type: str | None = f"ex:{tag}Org"

class Person(LinkedBaseModel):
id: str
type: str | None = f"ex:{tag}Person"
employer: Org | None = Field("ex:default-org", range="Org")
mentor: Org | None = Field(None, range="Org")

return Person


@pytest.mark.parametrize("pydantic_version", ["v1", "v2"])
def test_exclude_defaults_omits_a_link_left_at_its_declared_default(pydantic_version):
"""A link is declared with a default IRI, so a value equal to it is a default.

The binding routes link values out of the payload pydantic validates, so the
field always sits at None there and pydantic's own comparison cannot see
this. The declared IRI is kept in ``__link_defaults__`` and compared against.
"""
Person = _model_with_a_link_default(pydantic_version, "D")

result = Person(id="ex:p").to_json(exclude_defaults=True)

assert "employer" not in result, result
assert result["id"] == "ex:p"


@pytest.mark.parametrize("pydantic_version", ["v1", "v2"])
def test_exclude_defaults_keeps_a_link_set_to_another_iri(pydantic_version):
Person = _model_with_a_link_default(pydantic_version, "K")

result = Person(id="ex:p", employer="ex:acme").to_json(exclude_defaults=True)

assert result["employer"] == "ex:acme", result


@pytest.mark.parametrize("pydantic_version", ["v1", "v2"])
def test_exclude_defaults_omits_a_link_that_was_never_set(pydantic_version):
"""An unset link is at its default, and the key is re-added after the
handler has applied the exclusions - so it has to be skipped there too."""
Person = _model_with_a_link_default(pydantic_version, "U")
person = Person(id="ex:p")
dump = person.model_dump if pydantic_version == "v2" else person.dict

result = dump(exclude_defaults=True)

assert "mentor" not in result, result
Loading