From bb2fa7aa302c21dc2b794370b10d27541d6cbe73 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Fri, 12 Sep 2025 19:29:14 +0200 Subject: [PATCH 1/2] ADR 033: UnsupportedType Bolt 6 introduces a new data type representing a value of a type that the server cannot transmit to the client due to the negotiation of an incompatible bolt protocol version. --- docs/source/index.rst | 3 + docs/source/types/other.rst | 12 +++ .../_codec/hydration/v3/hydration_handler.py | 6 +- src/neo4j/_codec/hydration/v3/unsupported.py | 42 ++++++++ src/neo4j/api.py | 2 +- src/neo4j/types/__init__.py | 21 ++++ src/neo4j/types/_unsupported.py | 102 ++++++++++++++++++ testkitbackend/test_config.json | 1 + testkitbackend/totestkit.py | 13 +++ .../v3/test_unsupported_type_dehydration.py | 35 ++++++ .../v3/test_unsupported_type_hydration.py | 50 +++++++++ tests/unit/common/types/__init__.py | 14 +++ tests/unit/common/types/unsupported.py | 67 ++++++++++++ 13 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 docs/source/types/other.rst create mode 100644 src/neo4j/_codec/hydration/v3/unsupported.py create mode 100644 src/neo4j/types/__init__.py create mode 100644 src/neo4j/types/_unsupported.py create mode 100644 tests/unit/common/codec/hydration/v3/test_unsupported_type_dehydration.py create mode 100644 tests/unit/common/codec/hydration/v3/test_unsupported_type_hydration.py create mode 100644 tests/unit/common/types/__init__.py create mode 100644 tests/unit/common/types/unsupported.py diff --git a/docs/source/index.rst b/docs/source/index.rst index 45829be64..00c308e5b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -38,6 +38,8 @@ Topics + :ref:`vector-data-types` ++ :ref:`other-data-types` + + :ref:`breaking-changes` @@ -50,6 +52,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 000000000..8358de4f2 --- /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 169acecb3..7ded148b7 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 000000000..95277bb61 --- /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 e5e70c89b..d63d388ba 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 000000000..e13b607a0 --- /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 000000000..0b0ea14a9 --- /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 275de1224..cfee59cb3 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.UnknownType": 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 fd9e68c51..81f42aace 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,18 @@ def to(name, val): "data": " ".join(f"{byte:02x}" for byte in v.raw()), }, } + if isinstance(v, UnsupportedType): + data = { + "name": v.name, + "minimumProtocolMajor": v.minimum_protocol_version[0], + "minimumProtocolMinor": v.minimum_protocol_version[1], + } + if v.message is not None: + data["message"] = v.message + return { + "name": "CypherUnknownType", + "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 000000000..4f553d14f --- /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 000000000..16c0e37b1 --- /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 000000000..3f9680994 --- /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 000000000..ec65a1255 --- /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 From f47bceca11734bf47f2d732177745b17f835de54 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Mon, 22 Sep 2025 15:22:30 +0200 Subject: [PATCH 2/2] Adjust TestKit protocol --- testkitbackend/test_config.json | 2 +- testkitbackend/totestkit.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/testkitbackend/test_config.json b/testkitbackend/test_config.json index cfee59cb3..59eefc4e6 100644 --- a/testkitbackend/test_config.json +++ b/testkitbackend/test_config.json @@ -39,7 +39,7 @@ "Feature:API:Summary:GqlStatusObjects": true, "Feature:API:Type.Spatial": true, "Feature:API:Type.Temporal": true, - "Feature:API:Type.UnknownType": 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 81f42aace..7a06c5b8b 100644 --- a/testkitbackend/totestkit.py +++ b/testkitbackend/totestkit.py @@ -314,13 +314,15 @@ def to(name, val): if isinstance(v, UnsupportedType): data = { "name": v.name, - "minimumProtocolMajor": v.minimum_protocol_version[0], - "minimumProtocolMinor": v.minimum_protocol_version[1], + "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": "CypherUnknownType", + "name": "CypherUnsupportedType", "data": data, }