diff --git a/docs/source/index.rst b/docs/source/index.rst index 5ce050b7..abffa3af 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -39,6 +39,8 @@ Topics + :ref:`vector-data-types` ++ :ref:`other-data-types` + + :ref:`breaking-changes` @@ -51,6 +53,7 @@ Topics types/spatial.rst types/temporal.rst types/vector.rst + types/other.rst breaking_changes.rst diff --git a/docs/source/types/other.rst b/docs/source/types/other.rst new file mode 100644 index 00000000..8358de4f --- /dev/null +++ b/docs/source/types/other.rst @@ -0,0 +1,12 @@ +.. _other-data-types: + +*********************** +Other Driver Data Types +*********************** + +================ +Unsupported Type +================ + +.. autoclass:: neo4j.types.UnsupportedType + :members: diff --git a/src/neo4j/_codec/hydration/v3/hydration_handler.py b/src/neo4j/_codec/hydration/v3/hydration_handler.py index 169acecb..7ded148b 100644 --- a/src/neo4j/_codec/hydration/v3/hydration_handler.py +++ b/src/neo4j/_codec/hydration/v3/hydration_handler.py @@ -45,7 +45,10 @@ ) from ..v1.hydration_handler import _GraphHydrator from ..v2 import temporal as temporal_v2 -from . import vector +from . import ( + unsupported, + vector, +) class HydrationHandler(HydrationHandlerABC): # type: ignore[no-redef] @@ -64,6 +67,7 @@ def __init__(self): b"d": temporal_v2.hydrate_datetime, # no time zone b"E": temporal_v1.hydrate_duration, b"V": vector.hydrate_vector, + b"?": unsupported.hydrate_unsupported, } self.dehydration_hooks.update( exact_types={ diff --git a/src/neo4j/_codec/hydration/v3/unsupported.py b/src/neo4j/_codec/hydration/v3/unsupported.py new file mode 100644 index 00000000..95277bb6 --- /dev/null +++ b/src/neo4j/_codec/hydration/v3/unsupported.py @@ -0,0 +1,42 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from .... import _typing as t +from ....types import UnsupportedType + + +def hydrate_unsupported( + name: str, + min_bolt_major: int, + min_bolt_minor: int, + extra: dict[str, t.Any], +) -> UnsupportedType: + """ + Hydrator for `UnsupportedType` values. + + :param name: name of the type + :param min_bolt_major: minimum major version of the Bolt protocol + supporting this type + :param min_bolt_minor: minimum minor version of the Bolt protocol + supporting this type + :param extra: dict containing optional "message" key + :returns: UnsupportedType instance + """ + return UnsupportedType._new( + name, + (min_bolt_major, min_bolt_minor), + extra.get("message"), + ) diff --git a/src/neo4j/api.py b/src/neo4j/api.py index e5e70c89..d63d388b 100644 --- a/src/neo4j/api.py +++ b/src/neo4j/api.py @@ -296,7 +296,7 @@ def protocol_version(self) -> tuple[int, int]: """ Bolt protocol version with which the remote server communicates. - This is returned as a 2-tuple:class:`tuple` of ``(major, minor)`` + This is returned as a 2-:class:`tuple` of ``(major, minor)`` integers. """ return self._protocol_version diff --git a/src/neo4j/types/__init__.py b/src/neo4j/types/__init__.py new file mode 100644 index 00000000..e13b607a --- /dev/null +++ b/src/neo4j/types/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ._unsupported import UnsupportedType + + +__all__ = [ + "UnsupportedType", +] diff --git a/src/neo4j/types/_unsupported.py b/src/neo4j/types/_unsupported.py new file mode 100644 index 00000000..0b0ea14a --- /dev/null +++ b/src/neo4j/types/_unsupported.py @@ -0,0 +1,102 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +from .. import _typing as t # noqa: TC001 + + +class UnsupportedType: + """ + Represents a type unknown to the driver, received from the server. + + This type is used for instance when a newer DBMS produces a result + containing a type that the current version of the driver does not yet + understand. + + Note that this type may only be received from the server, but cannot be + sent to the server (e.g., as a query parameter). + + The attributes exposed by this type are meant for displaying and debugging + purposes. + They may change in future versions of the server, and should not be relied + upon for any logic in your application. + If your application requires handling this type, you must upgrade your + driver to a version that supports it. + """ + + _name: str + _minimum_protocol_version: tuple[int, int] + _message: str | None + + @classmethod + def _new( + cls, + name: str, + minimum_protocol_version: tuple[int, int], + message: str | None, + ) -> t.Self: + obj = cls.__new__(cls) + obj._name = name + obj._minimum_protocol_version = minimum_protocol_version + obj._message = message + return obj + + @property + def name(self) -> str: + """The name of the type.""" + return self._name + + @property + def minimum_protocol_version(self) -> tuple[int, int]: + """ + The minimum required Bolt protocol version that supports this type. + + This is a 2-:class:`tuple` of ``(major, minor)`` integers. + + To understand which driver version this corresponds to, refer to the + driver's release notes or documentation. + + + .. seealso:: + < + link to evolving doc listing which version of the driver + supports which Bolt version + > + """ + # TODO fix link above + return self._minimum_protocol_version + + @property + def message(self) -> str | None: + """ + Optional, further details about this type. + + Any additional information provided by the server about this type. + """ + return self._message + + def __str__(self) -> str: + return f"{self.__class__.__name__}<{self._name}>" + + def __repr__(self) -> str: + args = [ + f" name={self._name!r}", + f" minimum_protocol_version={self._minimum_protocol_version!r}", + ] + if self._message is not None: + args.append(f" message={self._message!r}") + return f"<{self.__class__.__name__}{''.join(args)}>" diff --git a/testkitbackend/test_config.json b/testkitbackend/test_config.json index 275de122..59eefc4e 100644 --- a/testkitbackend/test_config.json +++ b/testkitbackend/test_config.json @@ -39,6 +39,7 @@ "Feature:API:Summary:GqlStatusObjects": true, "Feature:API:Type.Spatial": true, "Feature:API:Type.Temporal": true, + "Feature:API:Type.UnsupportedType": true, "Feature:API:Type.Vector": true, "Feature:Auth:Bearer": true, "Feature:Auth:Custom": true, diff --git a/testkitbackend/totestkit.py b/testkitbackend/totestkit.py index fd9e68c5..7a06c5b8 100644 --- a/testkitbackend/totestkit.py +++ b/testkitbackend/totestkit.py @@ -39,6 +39,7 @@ Duration, Time, ) +from neo4j.types import UnsupportedType from neo4j.vector import Vector from ._warning_check import warning_check @@ -310,6 +311,20 @@ def to(name, val): "data": " ".join(f"{byte:02x}" for byte in v.raw()), }, } + if isinstance(v, UnsupportedType): + data = { + "name": v.name, + "minimumProtocol": ( + f"{v.minimum_protocol_version[0]}" + f".{v.minimum_protocol_version[1]}" + ), + } + if v.message is not None: + data["message"] = v.message + return { + "name": "CypherUnsupportedType", + "data": data, + } raise ValueError("Unhandled type:" + str(type(v))) diff --git a/tests/unit/common/codec/hydration/v3/test_unsupported_type_dehydration.py b/tests/unit/common/codec/hydration/v3/test_unsupported_type_dehydration.py new file mode 100644 index 00000000..4f553d14 --- /dev/null +++ b/tests/unit/common/codec/hydration/v3/test_unsupported_type_dehydration.py @@ -0,0 +1,35 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import pytest + +from neo4j._codec.hydration.v3 import HydrationHandler +from neo4j.types import UnsupportedType + +from .._base import HydrationHandlerTestBase + + +class TestUnsupportedTypeDehydration(HydrationHandlerTestBase): + @pytest.fixture + def hydration_handler(self): + return HydrationHandler() + + def test_has_no_transformer(self, hydration_scope): + value = UnsupportedType._new("UUID", (255, 255), None) + + transformer = hydration_scope.dehydration_hooks.get_transformer(value) + + assert transformer is None diff --git a/tests/unit/common/codec/hydration/v3/test_unsupported_type_hydration.py b/tests/unit/common/codec/hydration/v3/test_unsupported_type_hydration.py new file mode 100644 index 00000000..16c0e37b --- /dev/null +++ b/tests/unit/common/codec/hydration/v3/test_unsupported_type_hydration.py @@ -0,0 +1,50 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import pytest + +from neo4j._codec.hydration.v3 import HydrationHandler +from neo4j._codec.packstream import Structure +from neo4j.types import UnsupportedType + +from .._base import HydrationHandlerTestBase + + +class TestUnsupportedTypeHydration(HydrationHandlerTestBase): + @pytest.fixture + def hydration_handler(self): + return HydrationHandler() + + @pytest.mark.parametrize("with_message", (True, False)) + def test_vector(self, hydration_scope, with_message): + name = "2Cool4UType" + min_bolt_major = 42 + min_bolt_minor = 128 + extra = {} + if with_message: + expected_message = "If only your driver were cooler..." + extra["message"] = expected_message + else: + expected_message = None + expected_min_version = (min_bolt_major, min_bolt_minor) + + struct = Structure(b"?", name, min_bolt_major, min_bolt_minor, extra) + unsupported = hydration_scope.hydration_hooks[Structure](struct) + + assert isinstance(unsupported, UnsupportedType) + assert unsupported.name == name + assert unsupported.minimum_protocol_version == expected_min_version + assert unsupported.message == expected_message diff --git a/tests/unit/common/types/__init__.py b/tests/unit/common/types/__init__.py new file mode 100644 index 00000000..3f968099 --- /dev/null +++ b/tests/unit/common/types/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unit/common/types/unsupported.py b/tests/unit/common/types/unsupported.py new file mode 100644 index 00000000..ec65a125 --- /dev/null +++ b/tests/unit/common/types/unsupported.py @@ -0,0 +1,67 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import pytest + +from neo4j.types import UnsupportedType + + +def test_construction(): + value = UnsupportedType._new("UUID", (255, 128), None) + + assert isinstance(value, UnsupportedType) + assert value.name == "UUID" + assert value.minimum_protocol_version == (255, 128) + assert value.message is None + + +def test_construction_with_message(): + value = UnsupportedType._new("UUID", (255, 128), "Needs some config...") + + assert isinstance(value, UnsupportedType) + assert value.name == "UUID" + assert value.minimum_protocol_version == (255, 128) + assert value.message == "Needs some config..." + + +@pytest.mark.parametrize("name", ("FluxCompensationFactor", "", "Type")) +@pytest.mark.parametrize("message", (None, "", "Some cool text")) +def test_str(name, message): + value = UnsupportedType._new(name, (1, 2), message) + assert str(value) == f"UnsupportedType<{name}>" + + +@pytest.mark.parametrize("name", ("EncryptedValue", "", "Type")) +@pytest.mark.parametrize("version", ((0, 0), (1, 2), (255, 128), (128, 255))) +@pytest.mark.parametrize("message", (None, "", "Some cool text")) +def test_repr(name, version, message): + if message is not None: + expected_repr = ( + "" + ) + else: + expected_repr = ( + "" + ) + + value = UnsupportedType._new(name, version, message) + + assert repr(value) == expected_repr