From ff2168d338a59092abf853d53b8ab7a94c50a589 Mon Sep 17 00:00:00 2001 From: pm-osc Date: Mon, 25 Dec 2023 13:43:23 +0100 Subject: [PATCH] initial version of JanusGraph-Python supporting JanusGraph 1.0 Signed-off-by: pm-osc --- .github/workflows/python.yml | 44 ++++ .gitignore | 164 +++++++++++++ APACHE-2.0.txt | 4 +- BUILDING.md | 31 +++ README.md | 124 +++++++++- janusgraph_python/__init__.py | 0 janusgraph_python/driver/__init__.py | 0 janusgraph_python/driver/serializer.py | 23 ++ janusgraph_python/process/__init__.py | 0 janusgraph_python/process/traversal.py | 217 ++++++++++++++++ janusgraph_python/structure/__init__.py | 0 janusgraph_python/structure/io/__init__.py | 0 .../structure/io/graphsonV3d0.py | 54 ++++ janusgraph_python/structure/io/util.py | 69 ++++++ requirements.txt | 7 + setup.py | 41 ++++ tests/integration/RelationIdentifier_test.py | 45 ++++ tests/integration/Text_test.py | 103 ++++++++ tests/integration/__init__.py | 0 tests/integration/config.ini | 2 + tests/integration/conftest.py | 106 ++++++++ tests/integration/io/__init__.py | 0 tests/integration/io/test_graphsonV3d0.py | 36 +++ tests/integration/load_data.groovy | 46 ++++ tests/requirements.txt | 3 + tests/unit/__init__.py | 0 tests/unit/driver/__init__.py | 0 tests/unit/driver/test_serializer.py | 11 + tests/unit/process/__init__.py | 0 tests/unit/process/test_traversal.py | 231 ++++++++++++++++++ tests/unit/structure/__init__.py | 0 tests/unit/structure/io/__init__.py | 0 tests/unit/structure/io/test_graphsonV3d0.py | 109 +++++++++ tests/unit/structure/io/test_util.py | 42 ++++ 34 files changed, 1502 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/python.yml create mode 100644 .gitignore create mode 100644 BUILDING.md create mode 100644 janusgraph_python/__init__.py create mode 100644 janusgraph_python/driver/__init__.py create mode 100644 janusgraph_python/driver/serializer.py create mode 100644 janusgraph_python/process/__init__.py create mode 100644 janusgraph_python/process/traversal.py create mode 100644 janusgraph_python/structure/__init__.py create mode 100644 janusgraph_python/structure/io/__init__.py create mode 100644 janusgraph_python/structure/io/graphsonV3d0.py create mode 100644 janusgraph_python/structure/io/util.py create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 tests/integration/RelationIdentifier_test.py create mode 100644 tests/integration/Text_test.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/config.ini create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/io/__init__.py create mode 100644 tests/integration/io/test_graphsonV3d0.py create mode 100644 tests/integration/load_data.groovy create mode 100644 tests/requirements.txt create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/driver/__init__.py create mode 100644 tests/unit/driver/test_serializer.py create mode 100644 tests/unit/process/__init__.py create mode 100644 tests/unit/process/test_traversal.py create mode 100644 tests/unit/structure/__init__.py create mode 100644 tests/unit/structure/io/__init__.py create mode 100644 tests/unit/structure/io/test_graphsonV3d0.py create mode 100644 tests/unit/structure/io/test_util.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..58bafc5 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,44 @@ +# Copyright 2024 JanusGraph Authors +# +# 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 +# +# http://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. + +name: janusgraph-python + +on: + push: + branches: "**" + pull_request: + branches: "**" + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.9 + uses: actions/setup-python@v3 + with: + python-version: "3.9" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r tests/requirements.txt + - name: Test with pytest + run: | + python -m pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cf2f30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + + +.DS_Store +.coverage diff --git a/APACHE-2.0.txt b/APACHE-2.0.txt index d645695..7fd857b 100644 --- a/APACHE-2.0.txt +++ b/APACHE-2.0.txt @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2024 JanusGraph-Python Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -199,4 +199,4 @@ 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. + limitations under the License. \ No newline at end of file diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000..d2d42c2 --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,31 @@ +# Building JanusGraph-Python + +## Requirements + +* [Python 3.9][python39] or newer is needed to run the project. +* [Docker][docker] needs to be running in order to execute the integration tests as they automatically start a JanusGraph Docker container. +* [pytest][pytest] and [testcontainers][testcontainers] are needed to test the project. + +## Test + +The library can be tested by executing: + +```sh +pip install -r requirements.txt +pip install -r tests/requirements.txt + +python -m pytest +``` + +The library can be packed into PyPI package by executing: + +```sh +pip install build + +python -m build +``` + +[python39]: https://www.python.org/downloads/release/python-390/ +[docker]: https://www.docker.com/ +[pytest]: https://docs.pytest.org/ +[testcontainers]: https://pypi.org/project/testcontainers/ diff --git a/README.md b/README.md index c2e66a4..0d1bf40 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,116 @@ -# JanusGraph Python driver - -## License - -JanusGraph Python driver code is provided under the [Apache 2.0 -license](APACHE-2.0.txt) and documentation is provided under the [CC-BY-4.0 -license](CC-BY-4.0.txt). For details about this dual-license structure, please -see [`LICENSE.txt`](LICENSE.txt). +# JanusGraph-Python + +JanusGraph-Python extends Apache TinkerPop™'s [Gremlin-Python][gremlinpython] with +support for [JanusGraph][janusgraph]-specific types. + +## Usage + +To connect to JanusGraph Server, a `DriverRemoteConnection` instance needs to be +created and configured with a message serializer that adds support for +JanusGraph specific types. + +This can be done like this for GraphSON 3: + +```python +from gremlin_python.driver.driver_remote_connection import DriverRemoteConnection +from janusgraph_python.driver.serializer import JanusGraphSONSerializersV3d0 + +connection = DriverRemoteConnection( + 'ws://localhost:8182/gremlin', 'g', + message_serializer=JanusGraphSONSerializersV3d0()) +``` + +Note that the client should be disposed on shut down to release resources and +to close open connections with `connection.close()`. +The connection can then be used to configure a `GraphTraversalSource`: + +```python +from gremlin_python.process.anonymous_traversal import traversal + +g = traversal().with_remote(connection) +# Reuse 'g' across the application +``` + +The `GraphTraversalSource` `g` can now be used to spawn Gremlin traversals: + +```python +hercules_age = g.V().has("demigod", "name", "hercules").values("age").next() +print(f"Hercules is {hercules_age} years old.") +``` + +Refer to the chapter [Gremlin Query Language][gremlin-chapter] in the +JanusGraph docs for an introduction to Gremlin and pointers to further +resources. +The main syntactical difference for Gremlin-Python is that it follows Python naming +conventions, e.g., method names use snake_case instead of camelCase. Other difference is that when Python reserved words (e.g. "is") overlap with Gremlin steps or tokens, those gets underscore suffix (e.g. "is_"). + +### Text Predicates + +The `Text` class provides methods for +[full-text and string searches][text-predicates]: + +```python +from janusgraph_python.process.traversal import Text + +g.V().has("demigod", "name", Text.text_prefix("herc")).to_list() +``` + +The other text predicates can be used the same way. + +## Version Compatibility + +The lowest supported JanusGraph version is 1.0.0. +The following table shows the supported JanusGraph versions for each version +of JanusGraph-Python: + +| JanusGraph-Python | JanusGraph | +| ----------------- | ---------------------- | +| 1.0.z | 1.0.z | + +While it should also be possible to use JanusGraph-Python with other versions of +JanusGraph than mentioned here, compatibility is not tested and some +functionality (like added Gremlin steps) will not work as it is not supported +yet in case of an older JanusGraph version or was removed in a newer JanusGraph +version. + +## Serialization Formats + +JanusGraph-Python supports GraphSON 3 only. GraphBinary is not yet +supported. + +Not all of the JanusGraph-specific types are already supported by the formats: + +| Format | RelationIdentifier | Text predicates | Geoshapes | Geo predicates | +| ----------- | ------------------ | --------------- | --------- | -------------- | +| GraphSON3 | x | x | - | - | +| GraphBinary | - | - | - | - | + +## Community + +JanusGraph-Python uses the same communication channels as JanusGraph in general. +So, please refer to the +[_Community_ section in JanusGraph's main repository][janusgraph-community] +for more information about these various channels. + +Please use GitHub issues only to report bugs or request features. + +## Contributing + +Please see +[`CONTRIBUTING.md` in JanusGraph's main repository][janusgraph-contributing] +for more information, including CLAs and best practices for working with +GitHub. + +## License + +JanusGraph-Python code is provided under the [Apache 2.0 license](APACHE-2.0.txt) +and documentation is provided under the [CC-BY-4.0 license](CC-BY-4.0.txt). For +details about this dual-license structure, please see +[`LICENSE.txt`](LICENSE.txt). + +[janusgraph]: https://janusgraph.org/ +[gremlinpython]: https://tinkerpop.apache.org/docs/current/reference/#gremlin-python +[gremlin-chapter]: https://docs.janusgraph.org/getting-started/gremlin/ +[text-predicates]: https://docs.janusgraph.org/interactions/search-predicates/#text-predicate +[janusgraph-community]: https://github.com/JanusGraph/janusgraph#community +[janusgraph-contributing]: https://github.com/JanusGraph/janusgraph/blob/master/CONTRIBUTING.md \ No newline at end of file diff --git a/janusgraph_python/__init__.py b/janusgraph_python/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/janusgraph_python/driver/__init__.py b/janusgraph_python/driver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/janusgraph_python/driver/serializer.py b/janusgraph_python/driver/serializer.py new file mode 100644 index 0000000..7a0449a --- /dev/null +++ b/janusgraph_python/driver/serializer.py @@ -0,0 +1,23 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 gremlin_python.driver.serializer import GraphSONSerializersV3d0 +from janusgraph_python.structure.io import graphsonV3d0 + +class JanusGraphSONSerializersV3d0(GraphSONSerializersV3d0): + """Message serializer for GraphSON 3.0 extended with JanusGraph-specific types""" + def __init__(self): + reader = graphsonV3d0.JanusGraphSONReader() + writer = graphsonV3d0.JanusGraphSONWriter() + super(GraphSONSerializersV3d0, self).__init__(reader, writer) \ No newline at end of file diff --git a/janusgraph_python/process/__init__.py b/janusgraph_python/process/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/janusgraph_python/process/traversal.py b/janusgraph_python/process/traversal.py new file mode 100644 index 0000000..5c918a1 --- /dev/null +++ b/janusgraph_python/process/traversal.py @@ -0,0 +1,217 @@ + +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 numbers import Number +from gremlin_python.process.traversal import P +from janusgraph_python.structure.io.util import LongEncoding + +# Marked as 'private' class as it should not be used directly. +# If one uses _JanusGraphP.eq(), this will result invalid operator error. +class _JanusGraphP(P): + def __init__(self, operator, value, other=None): + self.operator = operator + self.value = value + self.other = other + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.operator == other.operator and self.value == other.value and self.other == other.other + + def __repr__(self): + return self.operator + "(" + str(self.value) + ")" if self.other is None else self.operator + "(" + str(self.value) + "," + str(self.other) + ")" + +class Text(object): + """ + Provides text search predicates. + """ + + @staticmethod + def text_contains(*args): + """ + Is true if (at least) one word inside the text string matches the query string. + + :param query: string, The query to search. + :return The text predicate. + """ + return _JanusGraphP("textContains", *args) + + @staticmethod + def text_contains_prefix(*args): + """ + Is true if (at least) one word inside the text string begins with the query string. + + :param query: string, The query to search. + :return The text predicate. + """ + return _JanusGraphP("textContainsPrefix", *args) + + @staticmethod + def text_contains_regex(*args): + """ + Is true if (at least) one word inside the text string matches the given regular expression. + + :param query: string, The query to search. + :return The text predicate. + """ + return _JanusGraphP("textContainsRegex", *args) + + @staticmethod + def text_contains_fuzzy(*args): + """ + Is true if (at least) one word inside the text string is similar to the query String (based on + Levenshtein edit distance). + + :param query: string, The query to search. + :return The text predicate. + """ + return _JanusGraphP("textContainsFuzzy", *args) + + @staticmethod + def text_prefix(*args): + """ + Is true if the string value starts with the given query string. + + :param query: string, The query to search. + :return The text predicate. + """ + return _JanusGraphP("textPrefix", *args) + + @staticmethod + def text_regex(*args): + """ + Is true if the string value matches the given regular expression in its entirety. + + :param query: string, The query to search. + :return The text predicate. + """ + return _JanusGraphP("textRegex", *args) + + @staticmethod + def text_fuzzy(*args): + """ + Is true if the string value is similar to the given query string (based on Levenshtein edit distance). + + :param query: string, The query to search. + :return The text predicate. + """ + return _JanusGraphP("textFuzzy", *args) + +class RelationIdentifier(object): + _TO_STRING_DELIMITER = '-' + + out_vertex_id = None + type_id = 0 + relation_id = 0 + in_vertex_id = None + string_representation = None + + def __init__(self, out_vertex_id, type_id, relation_id, in_vertex_id, string_representation): + """ + Initializes a new instance of the RelationIdentifier class + + :param out_vertex_id: object, The id of the outgoing vertex. + :param type_id: long, The JanusGraph internal type id. + :param relation_id: long, The JanusGraph internal relation id. + :param in_vertex_id: object, The id of the incoming vertex. + :param string_representation: string, The underlying relation id. + """ + self.out_vertex_id = out_vertex_id + self.type_id = type_id + self.relation_id = relation_id + self.in_vertex_id = in_vertex_id + self.string_representation = string_representation + + @classmethod + def from_string(cls, string_representation): + """ + Initializes a new instance of the RelationIdentifier class. + + :param string_representation: string, The underlying relation id. + """ + in_vertex_id = None + + elements = string_representation.split(cls._TO_STRING_DELIMITER) + + if len(elements) != 3 and len(elements) != 4: + raise ValueError(f"Not a valid relation identifier: {string_representation}") + + if elements[1][0] == LongEncoding.STRING_ENCODING_MARKER: + out_vertex_id = elements[1][1:] + else: + out_vertex_id = LongEncoding.decode(elements[1]) + + type_id = LongEncoding.decode(elements[2]) + relation_id = LongEncoding.decode(elements[0]) + + if len(elements) == 4: + if elements[3][0] == LongEncoding.STRING_ENCODING_MARKER: + in_vertex_id = elements[3][1:] + else: + in_vertex_id = LongEncoding.decode(elements[3]) + + return cls(out_vertex_id, type_id, relation_id, in_vertex_id, string_representation) + + @classmethod + def from_ids(cls, out_vertex_id, type_id, relation_id, in_vertex_id): + """ + Initializes a new instance of the RelationIdentifier class. + + :param out_vertex_id: object, The id of the outgoing vertex. + :param type_id: long, The JanusGraph internal type id. + :param relation_id: long, The JanusGraph internal relation id. + :param in_vertex_id: object, The id of the incoming vertex. + """ + + parts = [] + + parts.append(LongEncoding.encode(relation_id)) + parts.append(cls._TO_STRING_DELIMITER) + + if isinstance(out_vertex_id, Number): + parts.append(LongEncoding.encode(out_vertex_id)) + else: + parts.append(LongEncoding.STRING_ENCODING_MARKER) + parts.append(out_vertex_id) + + parts.append(cls._TO_STRING_DELIMITER) + parts.append(LongEncoding.encode(type_id)) + + if in_vertex_id: + parts.append(cls._TO_STRING_DELIMITER) + + if isinstance(in_vertex_id, Number): + parts.append(LongEncoding.encode(in_vertex_id)) + else: + parts.append(LongEncoding.STRING_ENCODING_MARKER) + parts.append(in_vertex_id) + + string_representation = ''.join(parts) + + return cls(out_vertex_id, type_id, relation_id, in_vertex_id, string_representation) + + def __eq__(self, other): + if other is None: + return False + + if other is self: + return True + + return self.string_representation == other.string_representation + + def __repr__(self): + return self.string_representation + + def __hash__(self): + return hash(self.string_representation) + \ No newline at end of file diff --git a/janusgraph_python/structure/__init__.py b/janusgraph_python/structure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/janusgraph_python/structure/io/__init__.py b/janusgraph_python/structure/io/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/janusgraph_python/structure/io/graphsonV3d0.py b/janusgraph_python/structure/io/graphsonV3d0.py new file mode 100644 index 0000000..d94aab8 --- /dev/null +++ b/janusgraph_python/structure/io/graphsonV3d0.py @@ -0,0 +1,54 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 collections import OrderedDict +from gremlin_python.structure.io.graphsonV3d0 import _GraphSONTypeIO, EdgeDeserializer, GraphSONUtil, GraphSONReader, GraphSONWriter +from gremlin_python.structure.graph import Edge, Vertex +from janusgraph_python.process.traversal import _JanusGraphP, RelationIdentifier + +class JanusGraphSONReader(GraphSONReader): + def __init__(self): + # register JanusGraph-specific RelationIdentifier deserializer + deserializer_map = { + 'janusgraph:RelationIdentifier': JanusGraphRelationIdentifierIO + } + GraphSONReader.__init__(self, deserializer_map) + +class JanusGraphSONWriter(GraphSONWriter): + def __init__(self): + # register JanusGraph-specific RelationIdentifier and text-predicate serializer + serializer_map = [ + (RelationIdentifier, JanusGraphRelationIdentifierIO), + (_JanusGraphP, JanusGraphPSerializer) + ] + GraphSONWriter.__init__(self, serializer_map) + + +class JanusGraphPSerializer(_GraphSONTypeIO): + @classmethod + def dictify(cls, p, writer): + out = {"predicate": p.operator, + "value": [writer.to_dict(p.value), writer.to_dict(p.other)] if p.other is not None else + writer.to_dict(p.value)} + return GraphSONUtil.typed_value("JanusGraphP", out, "janusgraph") + +class JanusGraphRelationIdentifierIO(_GraphSONTypeIO): + @classmethod + def objectify(cls, l, reader): + return RelationIdentifier.from_string(l['relationId']) + + @classmethod + def dictify(cls, relation_identifier, writer): + out = { "relationId": relation_identifier.string_representation } + return GraphSONUtil.typed_value("RelationIdentifier", out, "janusgraph") diff --git a/janusgraph_python/structure/io/util.py b/janusgraph_python/structure/io/util.py new file mode 100644 index 0000000..0d88016 --- /dev/null +++ b/janusgraph_python/structure/io/util.py @@ -0,0 +1,69 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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. + +class LongEncoding(object): + """ + Utility class for encoding longs in strings, re-implemented from its Java equivalent. + JanusGraph encodes long IDs in the RelationIdentifier as strings. + """ + + # The symbols used for the encoding. + BASE_SYMBOLS = "0123456789abcdefghijklmnopqrstuvwxyz" + NR_SYMBOLS = len(BASE_SYMBOLS) + + # Encoding used to indicate that an id is a string. + STRING_ENCODING_MARKER = 'S' + + @staticmethod + def decode(s): + """ + Decodes a string back into a long. + + :param s: string, The string to decode. + :return The decoded long value. + + exception: Thrown if the string contains any invalid characters. Only base_symbols are allowed. + """ + num = 0 + + for ch in s: + num *= LongEncoding.NR_SYMBOLS + + pos = LongEncoding.BASE_SYMBOLS.find(ch) + + if pos < 0: + raise ValueError(f"Symbol {ch} not allowed, only these symbols are: {LongEncoding.BASE_SYMBOLS}") + + num += pos + + return num + + @staticmethod + def encode(num): + """ + Encodes a long value as a string + + :param num: long, The long value to encode. + :return The encoded string value. + """ + + char_list = [] + + while num != 0: + char_list.append(LongEncoding.BASE_SYMBOLS[int(num % LongEncoding.NR_SYMBOLS)]) + num //= LongEncoding.NR_SYMBOLS + + char_list.reverse() + + return ''.join(char_list) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..633b35f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +# 3.9.1 does not work with Python 3.11 and 3.12 +aiohttp==3.9.0 + +# on Python 3.11 and 3.12 this is not installed automatically as dependency of aiohttp +async-timeout==4.0.3 + +gremlinpython==3.7.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5dffede --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +from setuptools import setup +from pathlib import Path +from os.path import isfile, join + +this_directory = Path(__file__).parent +requirements_path = join(this_directory, 'requirements.txt') + +def read_requirements(path): + install_requires = [] + + if isfile(path): + with open(path) as f: + install_requires = [line for line in map(str.strip, f.read().splitlines()) if len(line) > 0 and not line.startswith('#')] + + return install_requires + +install_requires = read_requirements(requirements_path) + +setup( + name='janusgraphpython', + version='1.0.0', + description='JanusGraph-Python extends Apache TinkerPop™''s Gremlin-Python with support for JanusGraph-specific types.', + long_description=(this_directory/'README.md').read_text(), + long_description_content_type='text/markdown', + url='https://janusgraph.org/', + author='JanusGraph', + license='Apache 2', + packages=['janusgraph_python', 'janusgraph_python.driver', + 'janusgraph_python.process', 'janusgraph_python.structure', + 'janusgraph_python.structure.io'], + zip_safe=False, + data_files=[('', ['LICENSE.txt', 'DCO.txt', 'CC-BY-4.0.txt', 'APACHE-2.0.txt', 'requirements.txt'])], + test_suite='tests', + install_requires=install_requires, + classifiers=[ + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Natural Language :: English', + 'Programming Language :: Python :: 3' + ] +) \ No newline at end of file diff --git a/tests/integration/RelationIdentifier_test.py b/tests/integration/RelationIdentifier_test.py new file mode 100644 index 0000000..e66442a --- /dev/null +++ b/tests/integration/RelationIdentifier_test.py @@ -0,0 +1,45 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 janusgraph_python.process.traversal import RelationIdentifier + +class _RelationIdentifierSerializer(object): + # g is expected to be set once this class is inherited + g = None + + def test_RelationIdentifier_as_edge_id(self): + edge_id = self.g.E().id_().next() + + count = self.g.E(edge_id).count().next() + assert count == 1 + + def test_Edge(self): + edge = self.g.E().next() + + count = self.g.E(edge).count().next() + assert count == 1 + +class _RelationIdentifierDeserializer(object): + # g is expected to be set once this class is inherited + g = None + + def test_valid_RelationIdentifier(self): + relation_identifier = self.g.V().has('demigod', 'name', 'hercules').out_e('father').id_().next() + + assert type(relation_identifier) is RelationIdentifier + + def test_Edge(self): + edge = self.g.V().has('demigod', 'name', 'hercules').out_e('father').next() + + assert type(edge.id) is RelationIdentifier \ No newline at end of file diff --git a/tests/integration/Text_test.py b/tests/integration/Text_test.py new file mode 100644 index 0000000..381790c --- /dev/null +++ b/tests/integration/Text_test.py @@ -0,0 +1,103 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 pytest import mark, param +from janusgraph_python.process.traversal import Text + +class _TextTests(object): + # g is expected to be set once this class is inherited + g = None + + @mark.parametrize( + 'search_text,expected_count', + [ + param('loves', 2), + param('shouldNotBeFound', 0), + ] + ) + def test_text_contains_given_search_text(self, search_text, expected_count): + count = self.g.E().has('reason', Text.text_contains(search_text)).count().next() + assert count == expected_count + + @mark.parametrize( + 'search_text,expected_count', + [ + param('wave', 1), + param('f', 2), + param('shouldNotBeFound', 0), + ] + ) + def test_text_contains_prefix_given_search_text(self, search_text, expected_count): + count = self.g.E().has('reason', Text.text_contains_prefix(search_text)).count().next() + assert count == expected_count + + @mark.parametrize( + 'search_text,expected_count', + [ + param('.*ave.*', 1), + param('f.{3,4}', 2), + param('shouldNotBeFound', 0), + ] + ) + def test_text_contains_regex_given_search_text(self, search_text, expected_count): + count = self.g.E().has('reason', Text.text_contains_regex(search_text)).count().next() + assert count == expected_count + + @mark.parametrize( + 'search_text,expected_count', + [ + param('waxes', 1), + param('shouldNotBeFound', 0), + ] + ) + def test_text_contains_fuzzy_given_search_text(self, search_text, expected_count): + count = self.g.E().has('reason', Text.text_contains_fuzzy(search_text)).count().next() + assert count == expected_count + + @mark.parametrize( + 'search_text,expected_count', + [ + param('herc', 1), + param('s', 3), + param('shouldNotBeFound', 0), + ] + ) + def test_text_prefix_given_search_text(self, search_text, expected_count): + count = self.g.V().has('name', Text.text_prefix(search_text)).count().next() + assert count == expected_count + + @mark.parametrize( + 'search_text,expected_count', + [ + param('.*rcule.*', 1), + param('s.{2}', 2), + param('shouldNotBeFound', 0), + ] + ) + def test_text_regex_given_search_text(self, search_text, expected_count): + count = self.g.V().has('name', Text.text_regex(search_text)).count().next() + assert count == expected_count + + @mark.parametrize( + 'search_text,expected_count', + [ + param('herculex', 1), + param('ska', 2), + param('shouldNotBeFound', 0), + ] + ) + def test_text_fuzzy_given_search_text(self, search_text, expected_count): + count = self.g.V().has('name', Text.text_fuzzy(search_text)).count().next() + assert count == expected_count + \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/config.ini b/tests/integration/config.ini new file mode 100644 index 0000000..a9d4717 --- /dev/null +++ b/tests/integration/config.ini @@ -0,0 +1,2 @@ +[docker] +image = janusgraph/janusgraph:1.0.0 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..239a662 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,106 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 os +import time +import pathlib +import configparser + +from pytest import fixture +from testcontainers.core.container import DockerContainer +from gremlin_python.process.anonymous_traversal import traversal +from gremlin_python.driver.driver_remote_connection import DriverRemoteConnection +from janusgraph_python.driver.serializer import JanusGraphSONSerializersV3d0 + +@fixture(scope='session') +def graph_connection_graphson(request, graph_container): + """ + Fixture for creating connection with JanusGraphSONSerializersV3d0 serializer + to the JanusGraph container + """ + return graph_connection(request, graph_container, JanusGraphSONSerializersV3d0()) + +def graph_connection(request, graph_container, serializer): + """ + Fixture for creating connection with given serializer to the + JanusGraph container + """ + connection = DriverRemoteConnection( + f'ws://{graph_container.get_container_host_ip()}:{graph_container.get_exposed_port(8182)}/gremlin', + 'g', + message_serializer=serializer + ) + + def close_connection(): + connection.close() + + request.addfinalizer(close_connection) + + g = traversal().with_remote(connection) + + return g + + +@fixture(scope='session') +def graph_container(request): + """ + Fixture for creating JanusGraph container before first test and dropping + container after last test in the test session + """ + container = None + current_path = pathlib.Path(__file__).parent.resolve() + + def is_server_ready(): + """ + Method to test if JanusGraph server is up and running and filled with test data + """ + connection = None + + while True: + try: + connection = DriverRemoteConnection( + f'ws://{container.get_container_host_ip()}:{container.get_exposed_port(8182)}/gremlin', + 'g', + message_serializer=JanusGraphSONSerializersV3d0() + ) + g = traversal().with_remote(connection) + + if g.V().has('name', 'hercules').has_next(): + break + except Exception as e: + pass + finally: + if connection: + connection.close() + + time.sleep(2) + + config = configparser.ConfigParser() + config.read(os.path.join(current_path, 'config.ini')) + + container = ( + DockerContainer(config['docker']['image']) + .with_name('janusgraph') + .with_exposed_ports(8182) + .with_volume_mapping(os.path.join(current_path, 'load_data.groovy'), '/docker-entrypoint-initdb.d/load_data.groovy') + .start() + ) + is_server_ready() + + def drop_container(): + container.stop() + + request.addfinalizer(drop_container) + + return container diff --git a/tests/integration/io/__init__.py b/tests/integration/io/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/io/test_graphsonV3d0.py b/tests/integration/io/test_graphsonV3d0.py new file mode 100644 index 0000000..18eb1ef --- /dev/null +++ b/tests/integration/io/test_graphsonV3d0.py @@ -0,0 +1,36 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 pytest import fixture + +from integration.RelationIdentifier_test import _RelationIdentifierSerializer, _RelationIdentifierDeserializer +from integration.Text_test import _TextTests + +class TestGraphSONRelationIdentifierSerializer(_RelationIdentifierSerializer): + @fixture(autouse=True) + def _graph_connection_graphson(self, graph_connection_graphson): + # setting up 'g' variable so parent class's methods can use it + self.g = graph_connection_graphson + +class TestGraphSONRelationIdentifierDeserializer(_RelationIdentifierDeserializer): + @fixture(autouse=True) + def _graph_connection_graphson(self, graph_connection_graphson): + # setting up 'g' variable so parent class's methods can use it + self.g = graph_connection_graphson + +class TestGraphSONText(_TextTests): + @fixture(autouse=True) + def _graph_connection_graphson(self, graph_connection_graphson): + # setting up 'g' variable so parent class's methods can use it + self.g = graph_connection_graphson \ No newline at end of file diff --git a/tests/integration/load_data.groovy b/tests/integration/load_data.groovy new file mode 100644 index 0000000..5173b6a --- /dev/null +++ b/tests/integration/load_data.groovy @@ -0,0 +1,46 @@ +// Copyright 2019 JanusGraph Authors +// +// 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 +// +// http://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 + +g = traversal().withRemote('conf/remote-graph.properties') + +g.addV('titan').property('name', 'saturn').property('age', 10000).as('saturn'). + addV('location').property('name', 'sky').as('sky'). + addV('location').property('name', 'sea').as('sea'). + addV('god').property('name', 'jupiter').property('age', 5000).as('jupiter'). + addV('god').property('name', 'neptune').property('age', 4500).as('neptune'). + addV('demigod').property('name', 'hercules').property('age', 30).as('hercules'). + addV('human').property('name', 'alcmene').property('age', 45).as('alcmene'). + addV('god').property('name', 'pluto').property('age', 4000).as('pluto'). + addV('monster').property('name', 'nemean').as('nemean'). + addV('monster').property('name', 'hydra').as('hydra'). + addV('monster').property('name', 'cerberus').as('cerberus'). + addV('location').property('name', 'tartarus').as('tartarus'). + addE('father').from('jupiter').to('saturn'). + addE('lives').from('jupiter').to('sky').property('reason', 'loves fresh breezes'). + addE('brother').from('jupiter').to('neptune'). + addE('brother').from('jupiter').to('pluto'). + addE('lives').from('neptune').to('sea').property('reason', 'loves waves'). + addE('brother').from('neptune').to('jupiter'). + addE('brother').from('neptune').to('pluto'). + addE('father').from('hercules').to('jupiter'). + addE('mother').from('hercules').to('alcmene'). + addE('battled').from('hercules').to('nemean').property('time', 1).property('place', Geoshape.point(38.1f, 23.7f)). + addE('battled').from('hercules').to('hydra').property('time', 2).property('place', Geoshape.point(37.7f, 23.9f)). + addE('battled').from('hercules').to('cerberus').property('time', 12).property('place', Geoshape.point(39f, 22f)). + addE('brother').from('pluto').to('jupiter'). + addE('brother').from('pluto').to('neptune'). + addE('lives').from('pluto').to('tartarus').property('reason', 'no fear of death'). + addE('pet').from('pluto').to('cerberus'). + addE('lives').from('cerberus').to('tartarus'). + iterate() \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..b0c3b5a --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest==7.4.3 +pytest-cov==4.1.0 +testcontainers==3.7.1 \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/driver/__init__.py b/tests/unit/driver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/driver/test_serializer.py b/tests/unit/driver/test_serializer.py new file mode 100644 index 0000000..68a3a04 --- /dev/null +++ b/tests/unit/driver/test_serializer.py @@ -0,0 +1,11 @@ +from janusgraph_python.driver.serializer import JanusGraphSONSerializersV3d0 +from janusgraph_python.structure.io import graphsonV3d0 + + +def test_graphson_serializer_v3(): + graphson_serializer_v3 = JanusGraphSONSerializersV3d0() + + assert graphson_serializer_v3.version == b"application/vnd.gremlin-v3.0+json" + assert isinstance(graphson_serializer_v3._graphson_reader, graphsonV3d0.JanusGraphSONReader) + assert isinstance(graphson_serializer_v3.standard._writer, graphsonV3d0.JanusGraphSONWriter) + assert isinstance(graphson_serializer_v3.traversal._writer, graphsonV3d0.JanusGraphSONWriter) \ No newline at end of file diff --git a/tests/unit/process/__init__.py b/tests/unit/process/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/process/test_traversal.py b/tests/unit/process/test_traversal.py new file mode 100644 index 0000000..82c7888 --- /dev/null +++ b/tests/unit/process/test_traversal.py @@ -0,0 +1,231 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 pytest import mark, param, raises +from janusgraph_python.process.traversal import RelationIdentifier, _JanusGraphP, Text + +class TestRelationIdentifier(object): + + def test_invalid_string_relation_id_to_string(self): + relation_id = '4qp-360' + + with raises(ValueError) as exception_info: + RelationIdentifier.from_string(relation_id) + + assert str(exception_info.value) == 'Not a valid relation identifier: 4qp-360' + + @mark.parametrize( + 'relation_id', + [ + param('4qp-360-7x1-3aw', id='valid relation ID'), + param('4qp-Sout_vertex_id-7x1-Sin_vertex_id', id='valid relation ID with string IDs') + ] + ) + def test_string_relation_id_to_string(self, relation_id): + relation_identifier = RelationIdentifier.from_string(relation_id) + + assert relation_id == str(relation_identifier) + + @mark.parametrize( + 'relation_id_str,out_vertex_id,type_id,relation_id,in_vertex_id', + [ + param( + '4qp-360-7x1-3aw', + 4104, + 10261, + 6145, + 4280, + id='valid relation ID' + ), + param( + '4qp-Sout_vertex_id-7x1-Sin_vertex_id', + 'out_vertex_id', + 10261, + 6145, + 'in_vertex_id', + id='valid relation ID with string IDs' + ) + ] + ) + def test_string_relation_id_to_ids(self, relation_id_str, out_vertex_id, type_id, relation_id, in_vertex_id): + relation_identifier = RelationIdentifier.from_string(relation_id_str) + + assert out_vertex_id == relation_identifier.out_vertex_id + assert type_id == relation_identifier.type_id + assert relation_id == relation_identifier.relation_id + assert in_vertex_id == relation_identifier.in_vertex_id + + @mark.parametrize( + 'out_vertex_id,type_id,relation_id,in_vertex_id,relation_id_str', + [ + param( + 4104, + 10261, + 6145, + 4280, + '4qp-360-7x1-3aw', + id='valid relation id' + ), + param( + 4104, + 10261, + 6145, + None, + '4qp-360-7x1', + id='valid relation id without in-vertex ID' + ), + param( + 'out_vertex_id', + 10261, + 6145, + 'in_vertex_id', + '4qp-Sout_vertex_id-7x1-Sin_vertex_id', + id='valid relation ID with string IDs' + ), + param( + 'out_vertex_id', + 10261, + 6145, + None, + '4qp-Sout_vertex_id-7x1', + id='valid relation ID with string IDs without in-vertex ID' + ) + ] + ) + def test_relation_id_to_string(self, out_vertex_id, type_id, relation_id, in_vertex_id, relation_id_str,): + relation_identifier = RelationIdentifier.from_ids(out_vertex_id, type_id, relation_id, in_vertex_id) + + assert relation_id_str == relation_identifier.string_representation + assert relation_id_str == str(relation_identifier) + + @mark.parametrize( + 'relation_id', + [ + param('4qp-360-7x1-3aw', id='valid relation ID'), + param('4qp-Sout_vertex_id-7x1-Sin_vertex_id', id='valid relation ID with string IDs') + ] + ) + def test_relation_id_hash(self, relation_id): + relation_identifier = RelationIdentifier.from_string(relation_id) + + assert hash(relation_id) == hash(relation_identifier) + + @mark.parametrize( + 'relation_id,other_relation_id,expected_result', + [ + param('4qp-360-7x1-3aw', None, False, id='compare to None'), + param('4qp-360-7x1-3aw', 'self', True, id='compare to self'), + param('4qp-360-7x1-3aw', '4qp-360-7x1-3aw', True, id='compare to other equal'), + param('4qp-360-7x1-3aw', '36j-6hk-2dx-3a0', False, id='compare to other non-equal'), + ] + ) + def test_relation_id_equals(self, relation_id, other_relation_id, expected_result): + other_relation_identifier = None + relation_identifier = RelationIdentifier.from_string(relation_id) + + if other_relation_id: + if other_relation_id == 'self': + other_relation_identifier = relation_identifier + else: + other_relation_identifier = RelationIdentifier.from_string(other_relation_id) + + result = (relation_identifier == other_relation_identifier) + + assert result == expected_result + +class TestJanusGraphP(object): + + @mark.parametrize( + 'predicate1,predicate2,expected', + [ + param( + _JanusGraphP('textContains', 'John'), + _JanusGraphP('textContains', 'John'), + True, + id='both operators and values are equal' + ), + param( + _JanusGraphP('textContains', 'John'), + _JanusGraphP('textContains', 'Juhn'), + False, + id='both operators are equals but values differ' + ), + param( + _JanusGraphP('textContains', 'John'), + _JanusGraphP('textFuzzy', 'John'), + False, + id='both values are equals but operators differ' + ), + param( + _JanusGraphP('textContains', 'John'), + _JanusGraphP('textFuzzy', 'Juhn'), + False, + id='both values and operators differ' + ) + ] + ) + def test_JanusGraphP_eq(self, predicate1, predicate2, expected): + result = predicate1 == predicate2 + assert result == expected + + @mark.parametrize( + 'expression,expected', + [ + param( + _JanusGraphP('textContains', 'John').and_(_JanusGraphP('textFuzzy', 'John')), + 'and(textContains(John),textFuzzy(John))', + id='order of operations with and' + ), + param( + ( + _JanusGraphP('textContains', 'John'). + or_(_JanusGraphP('textFuzzy', 'John')). + and_(_JanusGraphP('textPrefix', 'John')) + ), + 'and(or(textContains(John),textFuzzy(John)),textPrefix(John))', + id='order of operations with and/or' + ), + param( + ( + _JanusGraphP('textContains', 'John'). + or_(_JanusGraphP('textFuzzy', 'John')). + and_( + _JanusGraphP('textPrefix', 'John'). + or_(_JanusGraphP('textRegex', '.*hn.*')) + ) + ), + 'and(or(textContains(John),textFuzzy(John)),or(textPrefix(John),textRegex(.*hn.*)))', + id='order of operations with multiple and/or' + ) + ] + ) + def test_JanusGraphP(self, expression, expected): + assert str(expression) == expected + +class TestText(object): + + @mark.parametrize( + 'predicate,expected', + [ + param(Text.text_contains('John'), 'textContains(John)', id='text_contains'), + param(Text.text_contains_prefix('John'), 'textContainsPrefix(John)', id='text_contains_prefix'), + param(Text.text_contains_fuzzy('Juhn'), 'textContainsFuzzy(Juhn)', id='text_contains_fuzzy'), + param(Text.text_contains_regex('.*hn.*'), 'textContainsRegex(.*hn.*)', id='text_contains_regex'), + param(Text.text_fuzzy('Juhn'), 'textFuzzy(Juhn)', id='text_fuzzy'), + param(Text.text_prefix('John'), 'textPrefix(John)', id='text_prefix'), + param(Text.text_regex('.*hn.*'), 'textRegex(.*hn.*)', id='text_regex'), + ] + ) + def test_Text(self, predicate, expected): + assert str(predicate) == expected \ No newline at end of file diff --git a/tests/unit/structure/__init__.py b/tests/unit/structure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/structure/io/__init__.py b/tests/unit/structure/io/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/structure/io/test_graphsonV3d0.py b/tests/unit/structure/io/test_graphsonV3d0.py new file mode 100644 index 0000000..ab28365 --- /dev/null +++ b/tests/unit/structure/io/test_graphsonV3d0.py @@ -0,0 +1,109 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 json + +from janusgraph_python.structure.io.graphsonV3d0 import JanusGraphSONReader, JanusGraphSONWriter +from janusgraph_python.process.traversal import RelationIdentifier, _JanusGraphP + +class TestJanusGraphSONReader(object): + graphson_reader = JanusGraphSONReader() + + def test_RelationIdentifier(self): + relation_id = "3k1-360-6c5-39c" + expected_relation_identifier = RelationIdentifier.from_string(relation_id); + + graphSON = json.dumps({"@type":"janusgraph:RelationIdentifier","@value":{"relationId":relation_id}}) + + relation_identifier = self.graphson_reader.read_object(graphSON) + + assert relation_identifier == expected_relation_identifier + + +class TestJanusGraphSONWriter(object): + graphson_writer = JanusGraphSONWriter() + + def test_RelationIdentifier(self): + relation_id = "4qp-360-7x1-3aw" + expected = json.dumps({"@type":"janusgraph:RelationIdentifier","@value":{"relationId":relation_id}}, separators=(',', ':')) + + relation_identifier = RelationIdentifier.from_string(relation_id) + output = self.graphson_writer.write_object(relation_identifier) + + assert expected == output + + def test_JanusGraphP(self): + result_1 = { + "@type": "janusgraph:JanusGraphP", + "@value": { "predicate":"textContains", "value":"John" } + } + predicate_1 = _JanusGraphP("textContains", "John") + assert result_1 == json.loads(self.graphson_writer.write_object(predicate_1)) + + result_2 = { + "@type": "g:P", + "@value": { + "predicate": "and", + "value": [ + { + "@type":"g:P", + "@value": { + "predicate":"or", + "value":[ + { + "@type":"janusgraph:JanusGraphP", + "@value":{ + "predicate":"textContains", + "value":"John" + } + }, + { + "@type":"janusgraph:JanusGraphP", + "@value": { + "predicate":"textContainsPrefix", + "value":"John" + } + } + ] + } + }, + { + "@type":"janusgraph:JanusGraphP", + "@value": { + "predicate":"textContainsFuzzy", + "value":"Juhn" + } + } + ] + } + } + predicate_2 = ( + _JanusGraphP("textContains", "John"). + or_(_JanusGraphP("textContainsPrefix", "John")). + and_(_JanusGraphP("textContainsFuzzy", "Juhn")) + ) + assert result_2 == json.loads(self.graphson_writer.write_object(predicate_2)) + + +class TestJanusGraphSONReaderWriterSymmetricy(object): + graphson_writer = JanusGraphSONWriter() + graphson_reader = JanusGraphSONReader() + + def test_RelationIdentifier(self): + relation_identifier = RelationIdentifier.from_string("4qp-360-7x1-3aw") + + graphSON = self.graphson_writer.write_object(relation_identifier) + read_relation_identifier = self.graphson_reader.read_object(graphSON) + + assert relation_identifier == read_relation_identifier \ No newline at end of file diff --git a/tests/unit/structure/io/test_util.py b/tests/unit/structure/io/test_util.py new file mode 100644 index 0000000..9f0a8dd --- /dev/null +++ b/tests/unit/structure/io/test_util.py @@ -0,0 +1,42 @@ +# Copyright 2023 JanusGraph-Python Authors +# +# 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 +# +# http://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 pytest import mark, param, raises +from sys import maxsize +from janusgraph_python.structure.io.util import LongEncoding + +class TestLongEncoding(object): + + @mark.parametrize( + 'to_encode', + [ + param(0, id='Zero'), + param(1, id='One'), + param(1234567890, id='huge number'), + param(maxsize, id='max sized number') + ] + ) + def test_encode_and_decode__valid_value__same_value(self, to_encode): + encoded = LongEncoding.encode(to_encode) + decoded = LongEncoding.decode(encoded) + + assert to_encode == decoded + + def test_decode_invalid_char(self): + value_with_invalid_char = '33!' + + with raises(ValueError) as exception_info: + LongEncoding.decode(value_with_invalid_char) + + assert str(exception_info.value) == 'Symbol ! not allowed, only these symbols are: 0123456789abcdefghijklmnopqrstuvwxyz' \ No newline at end of file