diff --git a/test/collection/test_object_to_dict.py b/test/collection/test_object_to_dict.py new file mode 100644 index 000000000..6fdcaafab --- /dev/null +++ b/test/collection/test_object_to_dict.py @@ -0,0 +1,127 @@ +import datetime +import uuid + +from weaviate.collections.classes.internal import ( + CrossReference, + MetadataReturn, + Object, +) + + +def test_object_to_dict_basic() -> None: + """A plain object with no references should round-trip into a JSON-serializable dict.""" + obj_uuid = uuid.uuid4() + obj = Object( + uuid=obj_uuid, + metadata=MetadataReturn(), + properties={"name": "Alice", "age": 30}, + references=None, + vector={}, + collection="Person", + ) + + result = obj.to_dict() + + assert result == { + "uuid": str(obj_uuid), + "metadata": { + "creation_time": None, + "last_update_time": None, + "distance": None, + "certainty": None, + "score": None, + "explain_score": None, + "is_consistent": None, + "rerank_score": None, + }, + "properties": {"name": "Alice", "age": 30}, + "references": {}, + "vector": {}, + "collection": "Person", + } + + +def test_object_to_dict_converts_datetime_and_uuid_properties() -> None: + """`datetime` and `UUID` property values are not JSON-serializable by default and must be + converted to strings.""" + created = datetime.datetime(2024, 2, 16, 12, 0, 0, tzinfo=datetime.timezone.utc) + friend_id = uuid.uuid4() + obj = Object( + uuid=uuid.uuid4(), + metadata=MetadataReturn(creation_time=created), + properties={"createdAt": created, "friendId": friend_id}, + references=None, + vector={"default": [0.1, 0.2, 0.3]}, + collection="Person", + ) + + result = obj.to_dict() + + assert result["properties"]["createdAt"] == created.isoformat() + assert result["properties"]["friendId"] == str(friend_id) + assert result["metadata"]["creation_time"] == created.isoformat() + assert result["vector"] == {"default": [0.1, 0.2, 0.3]} + + +def test_object_to_dict_expands_cross_references() -> None: + """Cross-referenced objects are recursively expanded into nested dicts rather than being + left as opaque `_CrossReference` instances.""" + referenced_uuid = uuid.uuid4() + referenced_obj = Object( + uuid=referenced_uuid, + metadata=MetadataReturn(), + properties={"title": "Referenced"}, + references=None, + vector={}, + collection="Article", + ) + obj = Object( + uuid=uuid.uuid4(), + metadata=MetadataReturn(), + properties={"name": "Bob"}, + references={"wrote": CrossReference([referenced_obj])}, + vector={}, + collection="Person", + ) + + result = obj.to_dict() + + assert result["references"] == { + "wrote": [ + { + "uuid": str(referenced_uuid), + "metadata": { + "creation_time": None, + "last_update_time": None, + "distance": None, + "certainty": None, + "score": None, + "explain_score": None, + "is_consistent": None, + "rerank_score": None, + }, + "properties": {"title": "Referenced"}, + "references": {}, + "vector": {}, + "collection": "Article", + } + ] + } + + +def test_cross_reference_repr_is_human_readable() -> None: + """`repr()` of a `_CrossReference` should show its objects instead of a bare memory address.""" + referenced_obj = Object( + uuid=uuid.uuid4(), + metadata=MetadataReturn(), + properties={"title": "Referenced"}, + references=None, + vector={}, + collection="Article", + ) + ref = CrossReference([referenced_obj]) + + result = repr(ref) + + assert result == f"CrossReference(objects={[referenced_obj]!r})" + assert "object at 0x" not in result diff --git a/weaviate/collections/classes/internal.py b/weaviate/collections/classes/internal.py index 55a69c5cd..e2a07cead 100644 --- a/weaviate/collections/classes/internal.py +++ b/weaviate/collections/classes/internal.py @@ -42,15 +42,18 @@ _QueryReferenceMultiTarget, ) from weaviate.collections.classes.types import ( + GeoCoordinate, IReferences, M, P, + PhoneNumber, Properties, R, References, TProperties, TReferences, WeaviateProperties, + _PhoneNumber, _WeaviateInput, ) from weaviate.exceptions import ( @@ -120,6 +123,21 @@ class GroupByMetadataReturn: distance: Optional[float] = None +def _weaviate_field_to_json(value: Any) -> Any: + """Recursively convert a `WeaviateField` property value into a JSON-serializable value.""" + if isinstance(value, datetime.datetime): + return value.isoformat() + if isinstance(value, uuid_package.UUID): + return str(value) + if isinstance(value, (GeoCoordinate, PhoneNumber, _PhoneNumber)): + return value.model_dump(mode="json") + if isinstance(value, Mapping): + return {k: _weaviate_field_to_json(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_weaviate_field_to_json(v) for v in value] + return value + + @dataclass class _Object(Generic[P, R, M]): uuid: uuid_package.UUID @@ -129,6 +147,26 @@ class _Object(Generic[P, R, M]): vector: Dict[str, Union[List[float], List[List[float]]]] collection: str + def to_dict(self) -> Dict[str, Any]: + """Convert this object into a JSON-serializable dictionary. + + Cross-references are expanded recursively into their own `to_dict()` representation, so + take care when calling this on objects that are part of a reference cycle. + """ + return { + "uuid": str(self.uuid), + "metadata": {k: _weaviate_field_to_json(v) for k, v in vars(self.metadata).items()}, + "properties": { + key: _weaviate_field_to_json(val) for key, val in self.properties.items() + }, + "references": { + link: [obj.to_dict() for obj in ref.objects] + for link, ref in (self.references or {}).items() + }, + "vector": self.vector, + "collection": self.collection, + } + @dataclass class Object(Generic[P, R], _Object[P, R, MetadataReturn]): @@ -535,6 +573,9 @@ def objects(self) -> List[Object[Properties, IReferences]]: """Returns the objects of the cross reference.""" return self.__objects or [] + def __repr__(self) -> str: + return f"CrossReference(objects={self.objects!r})" + CrossReference: TypeAlias = _CrossReference[Properties, IReferences] """Use this TypeAlias when you want to type hint a cross reference within a generic data model.