diff --git a/src/oold/model/_compat.py b/src/oold/model/_compat.py index fb38247..4d1a3c9 100644 --- a/src/oold/model/_compat.py +++ b/src/oold/model/_compat.py @@ -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 diff --git a/src/oold/model/_descriptor.py b/src/oold/model/_descriptor.py index 3b60816..ca6f4ee 100644 --- a/src/oold/model/_descriptor.py +++ b/src/oold/model/_descriptor.py @@ -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. @@ -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) diff --git a/src/oold/model/v1/_descriptor.py b/src/oold/model/v1/_descriptor.py index ad19092..04c0130 100644 --- a/src/oold/model/v1/_descriptor.py +++ b/src/oold/model/v1/_descriptor.py @@ -33,6 +33,7 @@ _AutoLink, _Constructing, _default_iris, + _matches_link_default, ) _MANY_SHAPES = {SHAPE_LIST, SHAPE_SET, SHAPE_TUPLE} @@ -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 @@ -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} diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 2d62abf..c2ef4bc 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -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