├── setup.cfg ├── .gitmodules ├── pytest.ini ├── .clang-format ├── .gitignore ├── tools ├── get_memgraph_version.sh ├── wait_for_memgraph.sh └── install_linux_deps.sh ├── docs ├── source │ ├── cursor.rst │ ├── index.rst │ ├── module.rst │ ├── connection.rst │ ├── conf.py │ ├── usage.rst │ └── introduction.rst └── Makefile ├── MANIFEST.in ├── src ├── exceptions.h ├── column.h ├── glue.h ├── cursor.h ├── types.h ├── connection.h ├── column.c ├── connection-int.c ├── mgclientmodule.c ├── connection.c ├── types.c └── cursor.c ├── test ├── test_types.py ├── memory_leak.py ├── common.py ├── test_connection.py ├── test_glue.py └── test_cursor.py ├── .github └── workflows │ ├── ci.yml │ ├── release.yml │ ├── build_docs.yml │ └── reusable_buildtest.yml ├── README.md ├── CHANGELOG.rst ├── LICENSE └── setup.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_ext] 2 | static_openssl = 1 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "mgclient"] 2 | path = mgclient 3 | url = https://github.com/memgraph/mgclient.git 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | temporal: mark a test that tests temporal types. 4 | norecursedirs = memgraph 5 | -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | BasedOnStyle: Google 4 | UseTab: Never 5 | DerivePointerAlignment: false 6 | PointerAlignment: Right 7 | ColumnLimit : 80 8 | IncludeBlocks: Preserve 9 | ... 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build directory 2 | build/ 3 | dist/ 4 | pymgclient.egg-info/ 5 | 6 | # vim temporary files 7 | *.swp 8 | *.swo 9 | 10 | # python temporary files 11 | .venv 12 | *.pyc 13 | *.pyo 14 | __pycache__/ -------------------------------------------------------------------------------- /tools/get_memgraph_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | mgversion=$( 3 | curl -s https://api.github.com/repos/memgraph/memgraph/releases/latest \ 4 | | grep -m1 '"tag_name":' \ 5 | | sed -E 's/.*"([^"]+)".*/\1/' \ 6 | | sed 's/^v//' 7 | ) 8 | echo "$mgversion" -------------------------------------------------------------------------------- /docs/source/cursor.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | :class:`Cursor` class 3 | =============================== 4 | 5 | .. py:module:: mgclient 6 | 7 | .. autoclass:: mgclient.Cursor 8 | :members: 9 | 10 | ##################### 11 | :class:`Column` class 12 | ##################### 13 | 14 | :attr:`Cursor.description` list consists of instances of :class:`Column` class. 15 | 16 | .. autoclass:: mgclient.Column 17 | :members: 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include src *.c *.h 2 | recursive-include docs * 3 | prune docs/build 4 | recursive-include tests *.py 5 | include LICENSE MANIFEST.in README.md setup.cfg setup.py CHANGELOG.rst 6 | 7 | 8 | recursive-include mgclient/src *.c *.h CMakeLists.txt 9 | recursive-include mgclient/include *.h *.hpp 10 | recursive-include mgclient/cmake *.cmake 11 | recursive-include mgclient/tests *.cpp *.hpp CMakeLists.txt 12 | include mgclient/README.md mgclient/LICENSE mgclient/CMakeLists.txt 13 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. pymgclient documentation master file, created by 2 | sphinx-quickstart on Thu May 16 09:51:30 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ======================================================== 7 | pymgclient -- DB-API 2.0 interface for Memgraph database 8 | ======================================================== 9 | 10 | pymgclient is a `Memgraph `_ database adapter for Python 11 | language compliant with the DB-API 2.0 specification described by :pep:`249`. 12 | 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | :caption: Contents: 17 | 18 | introduction 19 | usage 20 | module 21 | connection 22 | cursor 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`search` 29 | -------------------------------------------------------------------------------- /src/exceptions.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #ifndef PYMGCLIENT_EXCEPTIONS_H 16 | #define PYMGCLIENT_EXCEPTIONS_H 17 | 18 | extern PyObject *Warning; 19 | extern PyObject *Error; 20 | extern PyObject *InterfaceError; 21 | extern PyObject *DatabaseError; 22 | extern PyObject *DataError; 23 | extern PyObject *OperationalError; 24 | extern PyObject *IntegrityError; 25 | extern PyObject *InternalError; 26 | extern PyObject *ProgrammingError; 27 | extern PyObject *NotSupportedError; 28 | 29 | #endif 30 | -------------------------------------------------------------------------------- /src/column.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #ifndef PYMGCLIENT_COLUMN_H 16 | #define PYMGCLIENT_COLUMN_H 17 | 18 | #include 19 | 20 | // clang-format off 21 | typedef struct { 22 | PyObject_HEAD 23 | 24 | PyObject *name; 25 | PyObject *type_code; 26 | PyObject *display_size; 27 | PyObject *internal_size; 28 | PyObject *precision; 29 | PyObject *scale; 30 | PyObject *null_ok; 31 | } ColumnObject; 32 | // clang-format on 33 | 34 | extern PyTypeObject ColumnType; 35 | 36 | #endif 37 | -------------------------------------------------------------------------------- /src/glue.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #ifndef PYMGCLIENT_GLUE_H 16 | #define PYMGCLIENT_GLUE_H 17 | 18 | #include 19 | 20 | #include 21 | 22 | PyObject *mg_list_to_py_tuple(const mg_list *list); 23 | 24 | PyObject *mg_list_to_py_list(const mg_list *list); 25 | 26 | PyObject *mg_value_to_py_object(const mg_value *value); 27 | 28 | mg_map *py_dict_to_mg_map(PyObject *dict); 29 | 30 | mg_value *py_object_to_mg_value(PyObject *object); 31 | 32 | mg_date_time_zone_id *py_date_time_to_mg_date_time_zone_id(PyObject *obj); 33 | 34 | void py_datetime_import_init(); 35 | #endif 36 | -------------------------------------------------------------------------------- /src/cursor.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #ifndef PYMGCLIENT_CURSOR_H 16 | #define PYMGCLIENT_CURSOR_H 17 | 18 | #include 19 | 20 | struct ConnectionObject; 21 | 22 | #define CURSOR_STATUS_READY 0 23 | #define CURSOR_STATUS_EXECUTING 1 24 | #define CURSOR_STATUS_CLOSED 3 25 | 26 | // clang-format off 27 | typedef struct { 28 | PyObject_HEAD 29 | 30 | struct ConnectionObject *conn; 31 | int status; 32 | int hasresults; 33 | long arraysize; 34 | 35 | Py_ssize_t rowindex; 36 | Py_ssize_t rowcount; 37 | PyObject *rows; 38 | PyObject *description; 39 | } CursorObject; 40 | // clang-format on 41 | 42 | extern PyTypeObject CursorType; 43 | 44 | #endif 45 | -------------------------------------------------------------------------------- /src/types.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #ifndef PYMGCLIENT_TYPES_H 16 | #define PYMGCLIENT_TYPES_H 17 | 18 | #include 19 | 20 | // clang-format off 21 | typedef struct { 22 | PyObject_HEAD 23 | 24 | int64_t id; 25 | PyObject *labels; 26 | PyObject *properties; 27 | } NodeObject; 28 | 29 | typedef struct { 30 | PyObject_HEAD 31 | 32 | int64_t id; 33 | int64_t start_id; 34 | int64_t end_id; 35 | PyObject *type; 36 | PyObject *properties; 37 | } RelationshipObject; 38 | 39 | typedef struct { 40 | PyObject_HEAD 41 | 42 | PyObject *nodes; 43 | PyObject *relationships; 44 | } PathObject; 45 | // clang-format on 46 | 47 | extern PyTypeObject NodeType; 48 | extern PyTypeObject RelationshipType; 49 | extern PyTypeObject PathType; 50 | 51 | #endif 52 | -------------------------------------------------------------------------------- /test/test_types.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import mgclient 16 | 17 | 18 | def test_node(): 19 | node1 = mgclient.Node(1, set(), {}) 20 | assert str(node1) == "()" 21 | 22 | node2 = mgclient.Node(1, set(["Label1"]), {}) 23 | assert str(node2) == "(:Label1)" 24 | 25 | node3 = mgclient.Node(1, set(["Label2"]), {"prop": 1}) 26 | assert str(node3) == "(:Label2 {'prop': 1})" 27 | 28 | node4 = mgclient.Node(1, set(), {"prop": 1}) 29 | assert str(node4) == "({'prop': 1})" 30 | 31 | 32 | def test_relationship(): 33 | rel1 = mgclient.Relationship(0, 1, 2, "Type", {}) 34 | assert str(rel1) == "[:Type]" 35 | 36 | rel2 = mgclient.Relationship(0, 1, 2, "Type", {"prop": 1}) 37 | assert str(rel2) == "[:Type {'prop': 1}]" 38 | 39 | 40 | def test_path(): 41 | n1 = mgclient.Node(1, set(["Label1"]), {}) 42 | n2 = mgclient.Node(2, set(["Label2"]), {}) 43 | n3 = mgclient.Node(3, set(["Label3"]), {}) 44 | 45 | e1 = mgclient.Relationship(1, 1, 2, "Edge1", {}) 46 | e2 = mgclient.Relationship(2, 3, 2, "Edge2", {}) 47 | 48 | path = mgclient.Path([n1, n2, n3], [e1, e2]) 49 | assert str(path) == "(:Label1)-[:Edge1]->(:Label2)<-[:Edge2]-(:Label3)" 50 | -------------------------------------------------------------------------------- /src/connection.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #ifndef PYMGCLIENT_CONNECTION_H 16 | #define PYMGCLIENT_CONNECTION_H 17 | 18 | #include 19 | 20 | #include 21 | 22 | // Connection status constants. 23 | #define CONN_STATUS_READY 0 24 | #define CONN_STATUS_IN_TRANSACTION 1 25 | #define CONN_STATUS_EXECUTING 2 26 | #define CONN_STATUS_FETCHING 3 27 | #define CONN_STATUS_CLOSED 4 28 | #define CONN_STATUS_BAD (-1) 29 | 30 | // clang-format off 31 | typedef struct ConnectionObject { 32 | PyObject_HEAD 33 | 34 | mg_session *session; 35 | int status; 36 | int autocommit; 37 | int lazy; 38 | } ConnectionObject; 39 | // clang-format on 40 | 41 | extern PyTypeObject ConnectionType; 42 | 43 | int connection_raise_if_bad_status(const ConnectionObject *conn); 44 | 45 | int connection_run_without_results(ConnectionObject *conn, const char *query); 46 | 47 | int connection_run(ConnectionObject *conn, const char *query, PyObject *params, 48 | PyObject **columns); 49 | 50 | int connection_pull(ConnectionObject *conn, long n); 51 | 52 | int connection_fetch(ConnectionObject *conn, PyObject **row, int *has_more); 53 | 54 | int connection_begin(ConnectionObject *conn); 55 | 56 | void connection_discard_all(ConnectionObject *conn); 57 | 58 | #endif 59 | -------------------------------------------------------------------------------- /test/memory_leak.py: -------------------------------------------------------------------------------- 1 | import linecache 2 | import os 3 | import tracemalloc 4 | import mgclient 5 | 6 | 7 | def display_top(snapshot, key_type="lineno", limit=3): 8 | snapshot = snapshot.filter_traces( 9 | ( 10 | tracemalloc.Filter(False, ""), 11 | tracemalloc.Filter(False, ""), 12 | ) 13 | ) 14 | top_stats = snapshot.statistics(key_type) 15 | 16 | print("Top %s lines" % limit) 17 | for index, stat in enumerate(top_stats[:limit], 1): 18 | frame = stat.traceback[0] 19 | filename = os.sep.join(frame.filename.split(os.sep)[-2:]) 20 | print( 21 | "#%s: %s:%s: %.1f KiB" % (index, filename, frame.lineno, stat.size / 1024) 22 | ) 23 | line = linecache.getline(frame.filename, frame.lineno).strip() 24 | if line: 25 | print(" %s" % line) 26 | other = top_stats[limit:] 27 | if other: 28 | size = sum(stat.size for stat in other) 29 | print("%s other: %.1f KiB" % (len(other), size / 1024)) 30 | total = sum(stat.size for stat in top_stats) 31 | print("Total allocated size: %.1f KiB" % (total / 1024)) 32 | 33 | 34 | def main(): 35 | conn = mgclient.connect(host="127.0.0.1", port=7687) 36 | cursor = conn.cursor() 37 | for _ in range(1, 10000): 38 | cursor.execute( 39 | """ 40 | CREATE (n:Person {name: 'John'})-[e:KNOWS]-> 41 | (m:Person {name: 'Steve'}); 42 | """ 43 | ) 44 | 45 | while True: 46 | tracemalloc.start() 47 | 48 | cursor.execute( 49 | """ 50 | MATCH (n:Person {name: 'John'})-[e:KNOWS]-> 51 | (m:Person {name: 'Steve'}) 52 | RETURN n, m, e 53 | """ 54 | ) 55 | cursor.fetchone() 56 | 57 | snapshot = tracemalloc.take_snapshot() 58 | display_top(snapshot) 59 | 60 | 61 | if __name__ == "__main__": 62 | main() 63 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | concurrency: 3 | group: ${{ github.head_ref || github.sha }} 4 | cancel-in-progress: true 5 | 6 | on: 7 | pull_request: 8 | workflow_dispatch: 9 | inputs: 10 | test_linux: 11 | type: boolean 12 | default: true 13 | description: "Run Linux Build and Test" 14 | test_windows: 15 | type: boolean 16 | default: true 17 | description: "Run Windows Build and Test" 18 | test_macintosh: 19 | type: boolean 20 | default: true 21 | description: "Run Mac OS Build" 22 | build_source_dist: 23 | type: boolean 24 | default: true 25 | description: "Build Source Distribution" 26 | upload_artifacts: 27 | type: boolean 28 | default: true 29 | description: "Upload Artifacts" 30 | 31 | schedule: 32 | - cron: "0 0 * * 0" 33 | 34 | jobs: 35 | weekly_build: 36 | if: ${{ github.event_name == 'schedule' }} 37 | name: Weekly Build 38 | uses: "./.github/workflows/reusable_buildtest.yml" 39 | with: 40 | test_linux: true 41 | test_windows: true 42 | test_macintosh: true 43 | build_source_dist: false 44 | upload_artifacts: false 45 | secrets: inherit 46 | 47 | pr_test: 48 | if: ${{ github.event_name == 'pull_request' }} 49 | name: Pull Request Tests 50 | uses: "./.github/workflows/reusable_buildtest.yml" 51 | with: 52 | test_linux: true 53 | test_windows: true 54 | test_macintosh: true 55 | build_source_dist: true 56 | upload_artifacts: false 57 | secrets: inherit 58 | 59 | manual_test: 60 | if: ${{ github.event_name == 'workflow_dispatch' }} 61 | name: Manual Test 62 | uses: "./.github/workflows/reusable_buildtest.yml" 63 | with: 64 | test_linux: ${{ inputs.test_linux }} 65 | test_windows: ${{ inputs.test_windows }} 66 | test_macintosh: ${{ inputs.test_macintosh }} 67 | build_source_dist: ${{ inputs.build_source_dist }} 68 | upload_artifacts: ${{ inputs.upload_artifacts }} 69 | secrets: inherit 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Actions Status](https://github.com/memgraph/pymgclient/workflows/CI/badge.svg)](https://github.com/memgraph/pymgclient/actions) 2 | 3 | # pymgclient - Memgraph database adapter for Python language 4 | 5 | pymgclient is a [Memgraph](https://memgraph.com) database adapter for Python 6 | programming language compliant with the DB-API 2.0 specification described by 7 | PEP 249. 8 | 9 | mgclient module is the current implementation of the adapter. It is implemented 10 | in C as a wrapper around [mgclient](https://github.com/memgraph/mgclient), the 11 | official Memgraph client library. As a C extension, it is only compatible with 12 | the CPython implementation of the Python programming language. 13 | 14 | pymgclient only works with Python 3. 15 | 16 | Check out the documentation if you need help with 17 | [installation](https://memgraph.github.io/pymgclient/introduction.html#installation) 18 | or if you want to 19 | [build](https://memgraph.github.io/pymgclient/introduction.html#install-from-source) 20 | pymgclient for yourself! 21 | ## Documentation 22 | 23 | Online documentation can be found on [GitHub 24 | pages](https://memgraph.github.io/pymgclient/). 25 | 26 | You can also build a local version of the documentation by running `make` from 27 | the `docs` directory. You will need [Sphinx](http://www.sphinx-doc.org/) 28 | installed in order to do that. 29 | 30 | ## Code sample 31 | 32 | Here is an example of an interactive session showing some of the basic 33 | commands: 34 | 35 | ```python 36 | >>> import mgclient 37 | 38 | # Make a connection to the database 39 | >>> conn = mgclient.connect(host='127.0.0.1', port=7687) 40 | 41 | # Create a cursor for query execution 42 | >>> cursor = conn.cursor() 43 | 44 | # Execute a query 45 | >>> cursor.execute(""" 46 | CREATE (n:Person {name: 'John'})-[e:KNOWS]-> 47 | (m:Person {name: 'Steve'}) 48 | RETURN n, e, m 49 | """) 50 | 51 | # Fetch one row of query results 52 | >>> row = cursor.fetchone() 53 | 54 | >>> print(row[0]) 55 | (:Person {'name': 'John'}) 56 | 57 | >>> print(row[1]) 58 | [:KNOWS] 59 | 60 | >>> print(row[2]) 61 | (:Person {'name': 'Steve'}) 62 | 63 | # Make database changes persistent 64 | >>> conn.commit() 65 | ``` 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version: 8 | required: true 9 | type: string 10 | publish_doc: 11 | description: 'If set to true, then the documentation will be published.' 12 | default: false 13 | required: false 14 | type: boolean 15 | publish_pypi: 16 | default: false 17 | type: boolean 18 | description: "Attempt to publish packages to PyPI" 19 | test: 20 | default: true 21 | description: "Run test release (no docs, publish to test.pypi.org)" 22 | type: boolean 23 | 24 | 25 | env: 26 | PYMGCLIENT_OVERRIDE_VERSION: "${{ github.event.inputs.version }}" 27 | 28 | jobs: 29 | release_tests: 30 | name: Release Package and Test 31 | uses: "./.github/workflows/reusable_buildtest.yml" 32 | with: 33 | test_linux: true 34 | test_windows: true 35 | test_macintosh: true 36 | build_source_dist: true 37 | upload_artifacts: true 38 | version: ${{ inputs.version }} 39 | secrets: inherit 40 | 41 | build_docs: 42 | needs: [release_tests] 43 | name: Build Docs 44 | uses: "./.github/workflows/build_docs.yml" 45 | with: 46 | publish_doc: false 47 | secrets: inherit 48 | 49 | publish_artifacts: 50 | name: Collect Artifacts 51 | runs-on: ubuntu-24.04 52 | needs: [release_tests, build_docs] 53 | 54 | steps: 55 | - name: Download all artifacts 56 | uses: actions/download-artifact@v4 57 | with: 58 | # omit ‘name’ to fetch *every* artifact uploaded earlier 59 | path: ./downloaded-artifacts 60 | 61 | - name: Move Artifacts 62 | run: | 63 | mkdir -p dist 64 | mv -v downloaded-artifacts/*/* dist/ 65 | 66 | - name: Show contents 67 | run: | 68 | ls dist/ 69 | 70 | - name: Publish Package to PyPI 71 | if: ${{ inputs.publish_pypi == true && inputs.test == false }} 72 | uses: pypa/gh-action-pypi-publish@v1.4.2 73 | with: 74 | user: __token__ 75 | password: ${{ secrets.PYPI_API_TOKEN }} 76 | 77 | - name: Publish Package to PyPI (TEST) 78 | if: ${{ inputs.publish_pypi == true && inputs.test }} 79 | uses: pypa/gh-action-pypi-publish@v1.4.2 80 | with: 81 | user: __token__ 82 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 83 | repository_url: https://test.pypi.org/legacy/ 84 | verbose: true -------------------------------------------------------------------------------- /docs/source/module.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | :mod:`mgclient` module content 3 | =============================== 4 | 5 | The module interface respects the DB-API 2.0 standard defined in :pep:`249`. 6 | 7 | .. py:module:: mgclient 8 | 9 | .. autofunction:: mgclient.connect 10 | 11 | See :ref:`lazy-connections` section to learn about 12 | advantages and limitations of using the ``lazy`` parameter. 13 | 14 | ################ 15 | Module constants 16 | ################ 17 | 18 | DB-API 2.0 requires the following constants to be defined: 19 | 20 | .. data:: mgclient.apilevel 21 | 22 | String constant stating the supported DB API level. For :mod:`mgclient` it 23 | is ``2.0``. 24 | 25 | .. data:: mgclient.threadsafety 26 | 27 | Integer constant stating the level of thread safety the interface supports. 28 | For :mod:`mgclient` it is 1, meaning that threads may share the module, but 29 | not connections. 30 | 31 | .. data:: mgclient.paramstyle 32 | 33 | String constant stating the type of parameter marker formatting expected by 34 | the interface. For :mod:`mgclient` it is ``cypher``, which is not a valid 35 | value by DB-API 2.0 specification. See Passing parameters section for more 36 | details. 37 | 38 | ########## 39 | Exceptions 40 | ########## 41 | 42 | By DB-API 2.0 specification, the module makes all error information available 43 | through these exceptions or subclasses thereof: 44 | 45 | .. autoexception:: mgclient.Warning 46 | 47 | .. autoexception:: mgclient.Error 48 | 49 | .. autoexception:: mgclient.InterfaceError 50 | 51 | .. autoexception:: mgclient.DatabaseError 52 | 53 | .. autoexception:: mgclient.DataError 54 | 55 | .. autoexception:: mgclient.OperationalError 56 | 57 | .. autoexception:: mgclient.IntegrityError 58 | 59 | .. autoexception:: mgclient.InternalError 60 | 61 | .. autoexception:: mgclient.ProgrammingError 62 | 63 | .. autoexception:: mgclient.NotSupportedError 64 | 65 | .. NOTE:: 66 | 67 | In the current state, :exc:`OperationalError` is raised for all errors 68 | obtained from the database. This will probably be improved in the future. 69 | 70 | ################## 71 | Graph type objects 72 | ################## 73 | 74 | .. autoclass:: mgclient.Node 75 | 76 | .. autoattribute:: mgclient.Node.id 77 | .. autoattribute:: mgclient.Node.labels 78 | .. autoattribute:: mgclient.Node.properties 79 | 80 | .. autoclass:: mgclient.Relationship 81 | 82 | .. autoattribute:: mgclient.Relationship.id 83 | .. autoattribute:: mgclient.Relationship.start_id 84 | .. autoattribute:: mgclient.Relationship.end_id 85 | .. autoattribute:: mgclient.Relationship.type 86 | .. autoattribute:: mgclient.Relationship.properties 87 | 88 | 89 | .. autoclass:: mgclient.Path 90 | :members: 91 | -------------------------------------------------------------------------------- /docs/source/connection.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | :class:`Connection` class 3 | =============================== 4 | 5 | .. py:module:: mgclient 6 | 7 | .. autoclass:: mgclient.Connection 8 | :members: 9 | 10 | .. _lazy-connections: 11 | 12 | ################ 13 | Lazy connections 14 | ################ 15 | 16 | When a query is executed using :meth:`.execute` on a cursor, the default 17 | :mod:`mgclient` behaviour is to wait for all of the results to be available 18 | and store them into cursor's internal result list. On one hand, that means that 19 | :meth:`.execute` will block until all of the results are ready and all results 20 | will be stored in memory at the same time. On the other hand, that also means 21 | that result fetching methods will never block. 22 | 23 | Sometimes, that behaviour is unwanted. Maybe we don't need all results in 24 | memory at the same time, because we only want to do row-by-row processing on 25 | huge result sets. In that case, we may use a lazy connection. 26 | 27 | A lazy connection is created by passing ``True`` for ``lazy`` parameter when 28 | calling :func:`.connect`. Cursors created using lazy connections will only try 29 | to read results from the network socket when :meth:`.fetchone`, 30 | :meth:`.fetchmany` or :meth:`.fetchall` is called. Also, they can allocate less 31 | memory because they don't have to store the entire result set in memory at 32 | once. 33 | 34 | However, lazy connections have two limitations: 35 | 36 | 1. They are always in autocommit mode. If necessary, transactions can be 37 | explicitly managed by executing ``BEGIN``, ``COMMIT`` or ``ROLLBACK`` 38 | queries. 39 | 40 | 2. At most one query can execute at a given time. Trying to execute multiple 41 | queries at once will raise an :exc:`InterfaceError`. 42 | 43 | Before trying to execute a new query, all results of the previous query 44 | must be fetched from its corresponding cursor (for example by calling 45 | :meth:`.fetchone` until it returns :obj:`None`, or by calling 46 | :meth:`.fetchmany`). 47 | 48 | Here's an example usage of a lazy connection:: 49 | 50 | >>> import mgclient 51 | 52 | >>> conn = mgclient.connect(host="127.0.0.1", port=7687, lazy=True) 53 | 54 | >>> cursor = conn.cursor() 55 | 56 | >>> cursor.execute("UNWIND range(1, 3) AS n RETURN n * n") 57 | 58 | >>> cursor.fetchone() 59 | (1, ) 60 | 61 | >>> cursor.fetchone() 62 | (4, ) 63 | 64 | >>> cursor.fetchone() 65 | (9, ) 66 | 67 | # We still didn't get None from fetchone() 68 | >>> cursor.execute("RETURN 100") 69 | Traceback (most recent call last): 70 | File "", line 1, in 71 | mgclient.InterfaceError: cannot call execute during execution of a query 72 | 73 | >>> cursor.fetchone() 74 | None 75 | 76 | # Now we can execute a new query 77 | >>> cursor.execute("RETURN 100") 78 | 79 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | import inspect 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = "pymgclient" 22 | copyright = "2022, Memgraph Ltd." 23 | author = "Memgraph Ltd." 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = "1.3.1" 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ["sphinx.ext.autodoc"] 35 | autodoc_docstring_signature = True 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ["_templates"] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = [] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | # 51 | html_theme = "alabaster" 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = ["_static"] 57 | 58 | 59 | def setup(app): 60 | # Type signature from C docstring is stripped and stored in 61 | # __text_signature__ attribute, so __doc__ attribute doesn't contain it. 62 | # Because of this, `autodoc_docstring_signature` doesn't work so we have to 63 | # do it manually. 64 | def process_signature(app, what, name, obj, options, signature, return_annotation): 65 | if signature: 66 | return (signature, return_annotation) 67 | 68 | try: 69 | sig = inspect.signature(obj) 70 | return (str(sig), return_annotation) 71 | except BaseException: 72 | return (None, None) 73 | 74 | app.connect("autodoc-process-signature", process_signature) 75 | -------------------------------------------------------------------------------- /.github/workflows/build_docs.yml: -------------------------------------------------------------------------------- 1 | name: Build Docs 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | publish_doc: 7 | type: boolean 8 | description: 'If set to true, then the documentation will be published.' 9 | default: false 10 | required: false 11 | workflow_call: 12 | inputs: 13 | publish_doc: 14 | type: boolean 15 | description: 'If set to true, then the documentation will be published.' 16 | default: false 17 | required: false 18 | 19 | jobs: 20 | build_docs: 21 | name: Build Documentation 22 | runs-on: [self-hosted, Linux, Ubuntu24.04] 23 | steps: 24 | - name: Checkout repository and submodules 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | submodules: recursive 29 | 30 | 31 | - name: Install Python Dependencies 32 | run: | 33 | python3 -m venv env 34 | source env/bin/activate 35 | pip install sphinx 36 | 37 | - name: Get Current Python Version 38 | run: | 39 | version="$(( python3 --version 2>&1 || echo ) | grep -Po '(?<=Python )\d+\.\d+' || true)" 40 | echo "Found version $version" 41 | echo "PYTHON_VERSION=$version" >> $GITHUB_ENV 42 | 43 | - name: Download Python Artifact 44 | id: download 45 | uses: actions/download-artifact@v4 46 | continue-on-error: true 47 | with: 48 | name: pymgclient-linux-${{ env.PYTHON_VERSION }} 49 | path: ./dist 50 | 51 | - name: Record download status 52 | if: always() 53 | id: record 54 | run: | 55 | echo "Download step outcome: ${{ steps.download.outcome }}" 56 | if [[ "${{ steps.download.outcome }}" == "success" ]]; then 57 | echo "DOWNLOAD_STATUS=success" >> $GITHUB_OUTPUT 58 | else 59 | echo "DOWNLOAD_STATUS=failure" >> $GITHUB_OUTPUT 60 | fi 61 | 62 | - name: Install Pymgclient (Artifact) 63 | if: steps.record.outputs.DOWNLOAD_STATUS == 'success' 64 | run: | 65 | source env/bin/activate 66 | pip install dist/*.whl 67 | 68 | - name: Install Pymgclient (PyPI) 69 | if: steps.record.outputs.DOWNLOAD_STATUS == 'failure' 70 | run: | 71 | source env/bin/activate 72 | pip install pymgclient 73 | 74 | - name: Build docs 75 | run: | 76 | source env/bin/activate 77 | cd docs 78 | make html 79 | rm build/html/.buildinfo 80 | touch build/html/.nojekyll 81 | 82 | - name: Deploy docs 83 | if: ${{ github.event.inputs.publish_doc == 'true' }} 84 | uses: peaceiris/actions-gh-pages@v3 85 | with: 86 | github_token: ${{ secrets.GITHUB_TOKEN }} 87 | publish_dir: ./docs/build/html 88 | 89 | - name: Cleanup 90 | if: always() 91 | run: | 92 | rm -rf env || true -------------------------------------------------------------------------------- /tools/wait_for_memgraph.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | TIMEOUT=10 5 | MEMGRAPH_CONSOLE_BINARY=${MEMGRAPH_CONSOLE_BINARY:-} 6 | 7 | usage() { 8 | cat < [port] 10 | --timeout SECONDS Max seconds to wait (default: $TIMEOUT) 11 | Memgraph host to check 12 | [port] Memgraph port (default: 7687) 13 | EOF 14 | exit 1 15 | } 16 | 17 | # --- parse flags --- 18 | while [[ $# -gt 0 ]]; do 19 | case $1 in 20 | --timeout) 21 | [[ -n "${2-}" ]] || { echo "Error: --timeout needs an argument" >&2; usage; } 22 | TIMEOUT=$2 23 | shift 2 24 | ;; 25 | -h|--help) 26 | usage 27 | ;; 28 | *) 29 | break 30 | ;; 31 | esac 32 | done 33 | 34 | # --- positional args --- 35 | (( $# >= 1 && $# <= 2 )) || usage 36 | HOST=$1 37 | PORT=${2-7687} 38 | 39 | # --- locate mgconsole --- 40 | if [[ -z "$MEMGRAPH_CONSOLE_BINARY" ]]; then 41 | if command -v mgconsole &>/dev/null; then 42 | MEMGRAPH_CONSOLE_BINARY=$(command -v mgconsole) 43 | HAVE_MGCONSOLE=1 44 | else 45 | HAVE_MGCONSOLE=0 46 | fi 47 | else 48 | if [[ ! -x "$MEMGRAPH_CONSOLE_BINARY" ]]; then 49 | echo "Error: \$MEMGRAPH_CONSOLE_BINARY set to '$MEMGRAPH_CONSOLE_BINARY', but not executable." >&2 50 | exit 1 51 | fi 52 | HAVE_MGCONSOLE=1 53 | fi 54 | 55 | # --- wait for a port on memgraph host with timeout --- 56 | wait_port() { 57 | local host=$1 port=$2 timeout=$3 start now 58 | start=$(date +%s) 59 | while true; do 60 | if timeout 1 bash -c "(exec 3<>'/dev/tcp/${host}/${port}')" 2>/dev/null; then 61 | return 0 62 | fi 63 | now=$(date +%s) 64 | (( now - start >= timeout )) && { 65 | echo "Timeout ($timeout s) waiting for $host:$port" >&2 66 | return 1 67 | } 68 | done 69 | } 70 | 71 | # --- wait for memgraph console to respond with timeout --- 72 | wait_for_memgraph() { 73 | local host=$1 port=$2 timeout=$3 start now 74 | start=$(date +%s) 75 | while true; do 76 | if timeout 1 bash -c "echo 'RETURN 1;' | \"$MEMGRAPH_CONSOLE_BINARY\" --host \"$host\" --port \"$port\" >/dev/null 2>&1"; then 77 | return 0 78 | fi 79 | now=$(date +%s) 80 | (( now - start >= timeout )) && { 81 | echo "Timeout ($timeout s) waiting for memgraph at $host:$port" >&2 82 | return 1 83 | } 84 | sleep 0.1 85 | done 86 | } 87 | 88 | # --- run checks --- 89 | echo "Waiting for TCP port $HOST:$PORT (timeout ${TIMEOUT}s)..." 90 | if wait_port "$HOST" "$PORT" "$TIMEOUT"; then 91 | timed_out=0 92 | else 93 | timed_out=1 94 | fi 95 | 96 | if (( HAVE_MGCONSOLE )); then 97 | echo "Waiting for memgraph console on $HOST:$PORT (timeout ${TIMEOUT}s)..." 98 | wait_for_memgraph "$HOST" "$PORT" "$TIMEOUT" 99 | else 100 | if [[ $timed_out == 1 ]]; then 101 | echo "mgconsole not found" 102 | exit 1 103 | fi 104 | echo "Note: mgconsole not found; skipping memgraph-console check." 105 | fi 106 | 107 | echo "Memgraph Started." 108 | -------------------------------------------------------------------------------- /test/common.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import subprocess 17 | import sys 18 | import time 19 | import tempfile 20 | import pytest 21 | import mgclient 22 | 23 | MEMGRAPH_PATH = os.getenv("MEMGRAPH_PATH", "/usr/lib/memgraph/memgraph") 24 | MEMGRAPH_PORT = int(os.getenv("MEMGRAPH_PORT", 7687)) 25 | MEMGRAPH_HOST = os.getenv("MEMGRAPH_HOST", None) 26 | MEMGRAPH_STARTED_WITH_SSL = os.getenv("MEMGRAPH_STARTED_WITH_SSL", None) 27 | DURABILITY_DIR = tempfile.TemporaryDirectory() 28 | 29 | 30 | def wait_for_server(port): 31 | cmd = ["nc", "-z", "-w", "1", "127.0.0.1", str(port)] 32 | count = 0 33 | while subprocess.call(cmd) != 0: 34 | time.sleep(0.1) 35 | if count > 100: 36 | raise RuntimeError("Could not wait for server on port", port, "to startup!") 37 | sys.exit(1) 38 | count += 1 39 | 40 | 41 | requires_ssl_enabled = pytest.mark.skipif( 42 | MEMGRAPH_HOST is not None and MEMGRAPH_STARTED_WITH_SSL is None, 43 | reason="requires secure connection", 44 | ) 45 | 46 | requires_ssl_disabled = pytest.mark.skipif( 47 | MEMGRAPH_HOST is not None and MEMGRAPH_STARTED_WITH_SSL is not None, 48 | reason="requires insecure connection", 49 | ) 50 | 51 | 52 | class Memgraph: 53 | def __init__(self, host, port, use_ssl, process): 54 | self.host = host 55 | self.port = port 56 | self.use_ssl = use_ssl 57 | self.process = process 58 | 59 | def is_long_running(self): 60 | return self.process is None 61 | 62 | def sslmode(self): 63 | return ( 64 | mgclient.MG_SSLMODE_REQUIRE if self.use_ssl else mgclient.MG_SSLMODE_DISABLE 65 | ) 66 | 67 | def terminate(self): 68 | if self.process: 69 | self.process.terminate() 70 | self.process.wait() 71 | 72 | 73 | def start_memgraph(cert_file="", key_file=""): 74 | if MEMGRAPH_HOST: 75 | use_ssl = MEMGRAPH_STARTED_WITH_SSL is not None 76 | return Memgraph(MEMGRAPH_HOST, MEMGRAPH_PORT, use_ssl, None) 77 | 78 | cmd = [ 79 | MEMGRAPH_PATH, 80 | "--bolt-port", 81 | str(MEMGRAPH_PORT), 82 | "--bolt-cert-file", 83 | cert_file, 84 | "--bolt-key-file", 85 | key_file, 86 | "--data-directory", 87 | DURABILITY_DIR.name, 88 | "--storage-properties-on-edges=true", 89 | "--storage-snapshot-interval-sec=0", 90 | "--storage-wal-enabled=false", 91 | "--storage-snapshot-on-exit=false", 92 | "--telemetry-enabled=false", 93 | "--log-file", 94 | "--timezone", 95 | "UTC", 96 | ] 97 | memgraph_process = subprocess.Popen(cmd) 98 | wait_for_server(MEMGRAPH_PORT) 99 | use_ssl = True if key_file.strip() else False 100 | return Memgraph("localhost", MEMGRAPH_PORT, use_ssl, memgraph_process) 101 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | ###### 6 | 1.5.1 7 | ###### 8 | 9 | 10 | ********* 11 | Bug Fixes 12 | ********* 13 | 14 | * Replace `PyDateTime_DATE_GET_TZINFO` with Python 3.9 compatible function 15 | 16 | 17 | ###### 18 | 1.5.0 19 | ###### 20 | 21 | 22 | ****************************** 23 | Major Feature and Improvements 24 | ****************************** 25 | 26 | * mgclient has been updated to 1.5.0 27 | * Update minimum Python requirement to 3.9 28 | * Support for zoned datetime objects with named IANA timezones or offsets 29 | * Remove deprecated `datetime.utcfromtimestamp()` usage in C extension 30 | 31 | 32 | ###### 33 | 1.4.0 34 | ###### 35 | 36 | 37 | ****************************** 38 | Major Feature and Improvements 39 | ****************************** 40 | 41 | * Update CI to use newer Python versions 42 | * Update release workflow and CI infrastructure 43 | 44 | ###### 45 | 1.3.1 46 | ###### 47 | 48 | 49 | ****************************** 50 | Major Feature and Improvements 51 | ****************************** 52 | 53 | * Use OpenSSL 1.1.1q and 3.0.5 versions for binary packages 54 | 55 | ********* 56 | Bug Fixes 57 | ********* 58 | 59 | * Fixed import path of errors from `distutils` 60 | 61 | ###### 62 | 1.3.0 63 | ###### 64 | 65 | 66 | ****************************** 67 | Major Feature and Improvements 68 | ****************************** 69 | 70 | * mgclient has been updated to 1.4.0 71 | * Support for OpenSSL 3 72 | * Use OpenSSL 1.1.1o and 3.0.3 versions for binary packages 73 | 74 | ###### 75 | 1.2.1 76 | ###### 77 | 78 | 79 | ****************************** 80 | Major Feature and Improvements 81 | ****************************** 82 | 83 | * Use OpenSSL 1.1.1n for binary packages 84 | 85 | ###### 86 | 1.2.0 87 | ###### 88 | 89 | 90 | ****************************** 91 | Major Feature and Improvements 92 | ****************************** 93 | 94 | * Add suport for arm64 macOS machines. 95 | * Link OpenSSL statically by default. 96 | * Add support for Python 3.10. 97 | 98 | ###### 99 | 1.1.0 100 | ###### 101 | 102 | 103 | **************** 104 | Breaking Changes 105 | **************** 106 | 107 | * `pymgclient` is supported only for >3.7 python versions on Windows. 108 | 109 | ****************************** 110 | Major Feature and Improvements 111 | ****************************** 112 | 113 | * Add support for temporal types. 114 | 115 | ********* 116 | Bug Fixes 117 | ********* 118 | 119 | ###### 120 | 1.0.0 121 | ###### 122 | 123 | 124 | **************** 125 | Breaking Changes 126 | **************** 127 | 128 | ****************************** 129 | Major Feature and Improvements 130 | ****************************** 131 | 132 | * Include `mgclient` to decouple pymgclient from the installed version of 133 | `mgclient`, thus make the building and usage easier. 134 | * Add support for macOS and Windows. 135 | 136 | ********* 137 | Bug Fixes 138 | ********* 139 | 140 | * Fix various memory leaks. 141 | * Fix transaction handling when an error happens in explicit transactional 142 | mode. The running transaction is reset and a new one is started with the 143 | next command. 144 | 145 | ###### 146 | 0.1.0 147 | ###### 148 | 149 | 150 | **************** 151 | Breaking Changes 152 | **************** 153 | 154 | ****************************** 155 | Major Feature and Improvements 156 | ****************************** 157 | 158 | * Initial implementation of DB-API 2.0 specification described by :pep:`249`. 159 | -------------------------------------------------------------------------------- /src/column.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #include "column.h" 16 | 17 | #include 18 | 19 | static void column_dealloc(ColumnObject *column) { 20 | Py_XDECREF(column->name); 21 | Py_XDECREF(column->type_code); 22 | Py_XDECREF(column->display_size); 23 | Py_XDECREF(column->internal_size); 24 | Py_XDECREF(column->precision); 25 | Py_XDECREF(column->scale); 26 | Py_XDECREF(column->null_ok); 27 | Py_TYPE(column)->tp_free(column); 28 | } 29 | 30 | static PyObject *column_repr(ColumnObject *column) { 31 | return PyUnicode_FromFormat( 32 | "<%s(name=%R, type_code=%R, display_size=%R, internal_size=%R, " 33 | "precision=%R, scale=%R, null_ok=%R) at %p>", 34 | Py_TYPE(column)->tp_name, column->name, column->type_code, 35 | column->display_size, column->internal_size, column->precision, 36 | column->scale, column->null_ok, column); 37 | } 38 | 39 | int column_init(ColumnObject *column, PyObject *args, PyObject *kwargs) { 40 | PyObject *name; 41 | static char *kwlist[] = {"", NULL}; 42 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O", kwlist, &name)) { 43 | return -1; 44 | } 45 | 46 | if (!PyUnicode_Check(name)) { 47 | PyErr_SetString(PyExc_TypeError, "__init__ argument 1 must be a string"); 48 | return -1; 49 | } 50 | 51 | PyObject *tmp_name = column->name; 52 | Py_INCREF(name); 53 | column->name = name; 54 | Py_XDECREF(tmp_name); 55 | 56 | PyObject *tmp_type_code = column->type_code; 57 | Py_INCREF(Py_None); 58 | column->type_code = Py_None; 59 | Py_XDECREF(tmp_type_code); 60 | 61 | PyObject *tmp_display_size = column->display_size; 62 | Py_INCREF(Py_None); 63 | column->display_size = Py_None; 64 | Py_XDECREF(tmp_display_size); 65 | 66 | PyObject *tmp_internal_size = column->internal_size; 67 | Py_INCREF(Py_None); 68 | column->internal_size = Py_None; 69 | Py_XDECREF(tmp_internal_size); 70 | 71 | PyObject *tmp_precision = column->precision; 72 | Py_INCREF(Py_None); 73 | column->precision = Py_None; 74 | Py_XDECREF(tmp_precision); 75 | 76 | PyObject *tmp_scale = column->scale; 77 | Py_INCREF(Py_None); 78 | column->scale = Py_None; 79 | Py_XDECREF(tmp_scale); 80 | 81 | PyObject *tmp_null_ok = column->null_ok; 82 | Py_INCREF(Py_None); 83 | column->null_ok = Py_None; 84 | Py_XDECREF(tmp_null_ok); 85 | 86 | return 0; 87 | } 88 | 89 | PyDoc_STRVAR(ColumnType_name_doc, "name of the returned column"); 90 | PyDoc_STRVAR( 91 | ColumnType_unsupported_doc, 92 | "always set to ``None`` (required by DB-API 2.0 spec, but not supported)"); 93 | 94 | static PyMemberDef column_members[] = { 95 | {"name", T_OBJECT, offsetof(ColumnObject, name), READONLY, 96 | ColumnType_name_doc}, 97 | {"type_code", T_OBJECT, offsetof(ColumnObject, type_code), READONLY, 98 | ColumnType_unsupported_doc}, 99 | {"display_size", T_OBJECT, offsetof(ColumnObject, display_size), READONLY, 100 | ColumnType_unsupported_doc}, 101 | {"internal_size", T_OBJECT, offsetof(ColumnObject, internal_size), READONLY, 102 | ColumnType_unsupported_doc}, 103 | {"precision", T_OBJECT, offsetof(ColumnObject, precision), READONLY, 104 | ColumnType_unsupported_doc}, 105 | {"scale", T_OBJECT, offsetof(ColumnObject, scale), READONLY, 106 | ColumnType_unsupported_doc}, 107 | {"null_ok", T_OBJECT, offsetof(ColumnObject, null_ok), READONLY, 108 | ColumnType_unsupported_doc}, 109 | {NULL}}; 110 | 111 | PyDoc_STRVAR(ColumnType_doc, "Description of a column returned by the query."); 112 | 113 | // clang-format off 114 | PyTypeObject ColumnType = { 115 | PyVarObject_HEAD_INIT(NULL, 0) 116 | .tp_name = "mgclient.Column", 117 | .tp_basicsize = sizeof(ColumnObject), 118 | .tp_itemsize = 0, 119 | .tp_dealloc = (destructor)column_dealloc, 120 | .tp_repr = (reprfunc)column_repr, 121 | .tp_flags = Py_TPFLAGS_DEFAULT, 122 | .tp_doc = ColumnType_doc, 123 | .tp_members = column_members, 124 | .tp_init = (initproc)column_init, 125 | .tp_new = PyType_GenericNew 126 | }; 127 | // clang-format on 128 | -------------------------------------------------------------------------------- /docs/source/usage.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Basic module usage 3 | ================== 4 | 5 | .. py:module:: mgclient 6 | 7 | Basic :mod:`mgclient` module usage is similar to that of all the database 8 | adapters compliant the DB-API 2.0. To use the module, you must: 9 | 10 | 1. Create a :class:`Connection` object, using the :func:`.connect` 11 | function. 12 | 13 | 2. Create a :class:`Cursor` object by calling :meth:`.cursor` on 14 | the :class:`Connection` object. 15 | 16 | 3. Call cursor's :meth:`.execute` method to perform openCypher queries. 17 | 18 | 4. Make database changes persistent by calling :meth:`.commit`, or drop them 19 | by calling :meth:`.rollback`. 20 | 21 | Here is an example of an interactive session 22 | showing some of the basic commands:: 23 | 24 | >>> import mgclient 25 | 26 | # Make a connection to the database 27 | >>> conn = mgclient.connect(host='127.0.0.1', port=7687) 28 | 29 | # Create a cursor for query execution 30 | >>> cursor = conn.cursor() 31 | 32 | # Execute a query 33 | >>> cursor.execute(""" 34 | CREATE (n:Person {name: 'John'})-[e:KNOWS]-> 35 | (m:Person {name: 'Steve'}) 36 | RETURN n, e, m 37 | """) 38 | 39 | # Fetch one row of query results 40 | >>> row = cursor.fetchone() 41 | 42 | >>> print(row[0]) 43 | (:Person {'name': 'John'}) 44 | 45 | >>> print(row[1]) 46 | [:KNOWS] 47 | 48 | >>> print(row[2]) 49 | (:Person {'name': 'Steve'}) 50 | 51 | # Make database changes persistent 52 | >>> conn.commit() 53 | 54 | ######################################## 55 | Passing parameters to openCypher queries 56 | ######################################## 57 | 58 | Usually, your openCypher queries will need to use the values from Python 59 | variables. You shouldn't assemble your query using Python's string operators 60 | because doing so is insecure. 61 | 62 | Instead, you should use the parameter substitution mechanism built into 63 | Memgraph. Put ``$name`` as a placeholder wherever you want to use a value, and 64 | the provide a dictionary mapping names to values as the second argument to the 65 | cursor's :meth:`.execute`. For example:: 66 | 67 | # Don't do this! 68 | server_id = 'srvr-38219012-sw' 69 | c.execute("MATCH (s:Server {id: '%s'}) SET s.hits = s.hits + 1" 70 | % server_id) 71 | 72 | # Instead, do this 73 | c.execute("MATCH (s:Server {id: $sid}) SET s.hits = s.htis + 1", 74 | {'sid': server_id}) 75 | 76 | 77 | ############################################# 78 | Adaptation of Memgraph values to Python types 79 | ############################################# 80 | 81 | The following table shows the mapping between Python and Memgraph types: 82 | 83 | ============= =============================== 84 | Memgraph Python 85 | ============= =============================== 86 | Null :const:`None` 87 | Boolean :class:`bool` 88 | Integer :class:`int` 89 | Float :class:`float` 90 | String :const:`str` 91 | Date :class:`datetime.date` 92 | LocalTime :class:`datetime.time` 93 | LocalDateTime :class:`datetime.datetime` 94 | Duration :class:`datetime.timedelta` 95 | List :class:`list` 96 | Map :class:`dict` 97 | Node :class:`mgclient.Node` 98 | Relationship :class:`mgclient.Relationship` 99 | Path :class:`mgclient.Path` 100 | ============= =============================== 101 | 102 | Note that in Bolt protocol, all string data is represented as UTF-8 encoded 103 | binary data. 104 | 105 | #################### 106 | Transactions control 107 | #################### 108 | 109 | In :mod:`mgclient` transactions are handled by the :class:`Connection` class. 110 | By default, the first time a command is sent to the database using one of the 111 | :class:`Cursor` objects created by it, a new transaction is started (by sending 112 | ``BEGIN`` command to Memgraph). All following commands (issued by any of the 113 | cursors) will be executed in the context of the same transaction. If any of the 114 | commands fails, the transaction will not be able to commit and no further 115 | command will successfuly execute until :meth:`.rollback` is called. 116 | 117 | The connection is responsible for terming its transaction, either by calling 118 | :meth:`.commit` or :meth:`.rollback`. Closing the connection using 119 | :meth:`Connection.close` or destroying the connection object results 120 | in an implicit rollback. 121 | 122 | You can set the connection in *autocommit* mode: that way all commands executed 123 | will be immediately committed and no rollback is possible. A few commands 124 | (``CREATE INDEX``, ``CREATE USER`` and similar) require to be run outside any 125 | transaction. To set the connection in *autocommit* mode, set 126 | :attr:`.autocommit` property of the connection to ``True``. 127 | -------------------------------------------------------------------------------- /tools/install_linux_deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script is for installing the dependencies required for building, 4 | # installing and testing pymgclient 5 | 6 | #!/usr/bin/env bash 7 | set -euo pipefail 8 | 9 | # defaults 10 | python_version="" 11 | distro="" 12 | force_update=false 13 | 14 | usage() { 15 | cat <] [--python-version X.Y] 17 | Optional distro tag, e.g. ubuntu-24.04, fedora-42 18 | --python-version X.Y Optional Python MAJOR.MINOR (e.g. 3.12). 19 | Defaults to system python3's MAJOR.MINOR. 20 | --force-update Force python packages to be updated 21 | EOF 22 | exit 1 23 | } 24 | 25 | 26 | # parse flags + optional positional distro 27 | while [[ $# -gt 0 ]]; do 28 | case "$1" in 29 | --python-version) 30 | if [[ -z "${2-}" ]]; then 31 | echo "Error: --python-version needs an argument" >&2 32 | usage 33 | fi 34 | python_version="$2" 35 | shift 2 36 | ;; 37 | --force-update) 38 | force_update=true 39 | shift 1 40 | ;; 41 | -h|--help) 42 | usage 43 | ;; 44 | --*) # any other flag 45 | echo "Unknown option: $1" >&2 46 | usage 47 | ;; 48 | *) # first non-flag is our distro 49 | if [[ -z "$distro" ]]; then 50 | distro="$1" 51 | shift 52 | else 53 | echo "Unexpected argument: $1" >&2 54 | usage 55 | fi 56 | ;; 57 | esac 58 | done 59 | 60 | 61 | # detect python_version if not given 62 | if [[ -z "$python_version" ]]; then 63 | python_version="$(python3 --version | grep -Eo '[0-9]+\.[0-9]+' )" 64 | fi 65 | python_binary="python${python_version}" 66 | 67 | # Detect distro from /etc/os-release 68 | detect_distro() { 69 | if [[ -r /etc/os-release ]]; then 70 | . /etc/os-release 71 | # Normalize common IDs 72 | case "$ID" in 73 | ubuntu|debian|linuxmint|fedora|centos|rhel|rocky) 74 | # version might be "24.04" or "9" or "42" 75 | # some versions include quotes 76 | ver="${VERSION_ID//\"/}" 77 | echo "${ID} ${ver}" 78 | ;; 79 | *) 80 | echo "unknown-$(uname -s | tr '[:upper:]' '[:lower:]') $(uname -r)" ;; 81 | esac 82 | else 83 | echo "unknown-$(uname -s | tr '[:upper:]' '[:lower:]') $(uname -r)" 84 | fi 85 | } 86 | 87 | # Ensure at least one arg 88 | if [[ $# -gt 1 ]]; then 89 | usage 90 | fi 91 | 92 | # If distro not provided, detect it 93 | if [[ -z "$distro" ]]; then 94 | read distro version < <(detect_distro) 95 | if [[ "$distro" == unknown* ]]; then 96 | echo "Unknown distro detected" 97 | exit 1 98 | fi 99 | else 100 | # split version from distro, e.g. `ubuntu-24.04` -> `ubuntu` `24.04` 101 | version="${distro#*-}" 102 | distro="${distro%%-*}" 103 | fi 104 | echo "Linux Distro: $distro $version" 105 | 106 | # detect if we need sudo or not 107 | SUDO=() 108 | if (( EUID != 0 )); then 109 | if ! command -v sudo &>/dev/null; then 110 | echo "Error: root privileges or sudo required." >&2 111 | exit 1 112 | fi 113 | SUDO=(sudo) 114 | fi 115 | 116 | DEB_DEPS=( 117 | python${python_version} 118 | python${python_version}-dev 119 | python3-pip 120 | python3-setuptools 121 | python3-wheel 122 | libpython${python_version} 123 | cmake 124 | g++ 125 | libssl-dev 126 | netcat-traditional 127 | patchelf 128 | libatomic1 129 | ) 130 | 131 | RPM_DEPS=( 132 | python${python_version} 133 | python${python_version}-devel 134 | python3-pip 135 | python3-setuptools 136 | python3-wheel 137 | cmake 138 | g++ 139 | openssl-devel 140 | nmap-ncat 141 | libatomic 142 | ) 143 | 144 | install_deb() { 145 | echo "Installing DEB dependencies..." 146 | installed_python_version="$(( python3 --version 2>&1 || echo ) | grep -Po '(?<=Python )\d+\.\d+' || true)" 147 | if [[ "$python_version" != "$installed_python_version" ]]; then 148 | echo "Installed Python version ${installed_python_version} does not match requested version ${python_version}" 149 | if [[ "$distro" == "debian" ]]; then 150 | exit 1 151 | else 152 | echo "Adding deadsnakes PPA" 153 | "${SUDO[@]}" apt-get update 154 | "${SUDO[@]}" apt-get install -y software-properties-common 155 | "${SUDO[@]}" add-apt-repository -y ppa:deadsnakes/ppa 156 | fi 157 | fi 158 | if [[ ("$distro" == "ubuntu" && ${version#*.} -ge 24) \ 159 | || ("$distro" == "linuxmint" && ${version#*.} -ge 22) ]]; then 160 | DEB_DEPS+=( libcurl4t64 ) 161 | else 162 | DEB_DEPS+=( libcurl4 ) 163 | fi 164 | "${SUDO[@]}" apt-get update 165 | "${SUDO[@]}" apt-get install -y ${DEB_DEPS[*]} 166 | } 167 | 168 | install_rpm() { 169 | echo "Installing RPM dependencies..." 170 | "${SUDO[@]}" dnf install -y ${RPM_DEPS[*]} 171 | } 172 | 173 | case "$distro" in 174 | debian|ubuntu|linuxmint) 175 | install_deb 176 | ;; 177 | centos|fedora|rocky|rhel) 178 | install_rpm 179 | ;; 180 | *) 181 | echo "Unsupported Distro: $distro" >&2 182 | exit 1 183 | ;; 184 | esac 185 | 186 | # install python dependencies 187 | export PIP_BREAK_SYSTEM_PACKAGES=1 188 | pkgs=(networkx pytest pyopenssl sphinx setuptools wheel auditwheel tzdata) 189 | if [[ $force_update == true ]]; then 190 | "$python_binary" -m pip install --upgrade --ignore-installed ${pkgs[@]} 191 | else 192 | for pkg in "${pkgs[@]}"; do 193 | echo "Installing/upgrading $pkg..." 194 | if ! "$python_binary" -m pip install --upgrade "$pkg"; then 195 | echo "Warning: pip failed on $pkg, continuing…" >&2 196 | fi 197 | done 198 | fi -------------------------------------------------------------------------------- /src/connection-int.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #include "connection.h" 16 | #include "exceptions.h" 17 | #include "glue.h" 18 | 19 | int connection_raise_if_bad_status(const ConnectionObject *conn) { 20 | if (conn->status == CONN_STATUS_BAD) { 21 | PyErr_SetString(InterfaceError, "bad session"); 22 | return -1; 23 | } 24 | if (conn->status == CONN_STATUS_CLOSED) { 25 | PyErr_SetString(InterfaceError, "session closed"); 26 | return -1; 27 | } 28 | return 0; 29 | } 30 | 31 | void connection_handle_error(ConnectionObject *conn, int error) { 32 | if (mg_session_status(conn->session) == MG_SESSION_BAD) { 33 | conn->status = CONN_STATUS_BAD; 34 | } else if (error == MG_ERROR_TRANSIENT_ERROR || 35 | error == MG_ERROR_DATABASE_ERROR || 36 | error == MG_ERROR_CLIENT_ERROR) { 37 | conn->status = CONN_STATUS_READY; 38 | } 39 | PyErr_SetString(DatabaseError, mg_session_error(conn->session)); 40 | } 41 | 42 | int connection_run_without_results(ConnectionObject *conn, const char *query) { 43 | int status = mg_session_run(conn->session, query, NULL, NULL, NULL, NULL); 44 | if (status != 0) { 45 | connection_handle_error(conn, status); 46 | return -1; 47 | } 48 | 49 | status = mg_session_pull(conn->session, NULL); 50 | if (status != 0) { 51 | connection_handle_error(conn, status); 52 | return -1; 53 | } 54 | 55 | while (1) { 56 | mg_result *result; 57 | int status = mg_session_fetch(conn->session, &result); 58 | if (status == 0) { 59 | break; 60 | } 61 | if (status == 1) { 62 | if (PyErr_WarnFormat(Warning, 2, 63 | "unexpected rows received after executing '%s'", 64 | query) < 0) { 65 | return -1; 66 | } 67 | } 68 | if (status < 0) { 69 | connection_handle_error(conn, status); 70 | return -1; 71 | } 72 | } 73 | 74 | return 0; 75 | } 76 | 77 | int connection_run(ConnectionObject *conn, const char *query, PyObject *params, 78 | PyObject **columns) { 79 | // This should be used to start the execution of a query, so we validate 80 | // we're in a valid state for query execution. 81 | assert((conn->autocommit && conn->status == CONN_STATUS_READY) || 82 | (!conn->autocommit && conn->status == CONN_STATUS_IN_TRANSACTION)); 83 | 84 | mg_map *mg_params = NULL; 85 | if (params) { 86 | mg_params = py_dict_to_mg_map(params); 87 | if (!mg_params) { 88 | return -1; 89 | } 90 | } 91 | 92 | const mg_list *mg_columns; 93 | int status = 94 | mg_session_run(conn->session, query, mg_params, NULL, &mg_columns, NULL); 95 | mg_map_destroy(mg_params); 96 | 97 | if (status != 0) { 98 | connection_handle_error(conn, status); 99 | return -1; 100 | } 101 | 102 | if (columns) { 103 | *columns = mg_list_to_py_list(mg_columns); 104 | } 105 | 106 | conn->status = CONN_STATUS_EXECUTING; 107 | return 0; 108 | } 109 | 110 | int connection_pull(ConnectionObject *conn, long n) { 111 | assert(conn->status == CONN_STATUS_EXECUTING); 112 | 113 | int status; 114 | if (n == 0) { // PULL_ALL 115 | status = mg_session_pull(conn->session, NULL); 116 | } else { // PULL_N 117 | mg_map *pull_information = mg_map_make_empty(1); 118 | mg_value *pull_info_n = mg_value_make_integer(n); 119 | mg_map_insert(pull_information, "n", pull_info_n); 120 | status = mg_session_pull(conn->session, pull_information); 121 | } 122 | if (status == 0) { 123 | conn->status = CONN_STATUS_FETCHING; 124 | return 0; 125 | } else { 126 | connection_handle_error(conn, status); 127 | return -1; 128 | } 129 | } 130 | 131 | int connection_fetch(ConnectionObject *conn, PyObject **row, 132 | int *has_more_out) { 133 | assert(conn->status == CONN_STATUS_FETCHING); 134 | 135 | mg_result *result; 136 | int status = mg_session_fetch(conn->session, &result); 137 | if (status == 0) { 138 | const mg_map *mg_summary = mg_result_summary(result); 139 | const mg_value *mg_has_more = mg_map_at(mg_summary, "has_more"); 140 | const int has_more = mg_value_bool(mg_has_more); 141 | if (!has_more) { 142 | conn->status = 143 | conn->autocommit ? CONN_STATUS_READY : CONN_STATUS_IN_TRANSACTION; 144 | } else { 145 | conn->status = CONN_STATUS_EXECUTING; 146 | } 147 | if (has_more_out) { 148 | *has_more_out = has_more; 149 | } 150 | } 151 | 152 | if (status < 0) { 153 | // TODO (gitbuda): Define new CUSOR_STATUS to handle query error. 154 | // Since database has to pull data ahead of time because of has_more info, 155 | // by saving the status here, pymgclient would be able to "simulate" the 156 | // right behaviour and raise error at the right time. Cursor::fetchone has 157 | // the most questionable behaviour because it returns error one step 158 | // earlier. 159 | connection_handle_error(conn, status); 160 | return -1; 161 | } 162 | if (status == 1 && row) { 163 | PyObject *pyresult = mg_list_to_py_tuple(mg_result_row(result)); 164 | if (!pyresult) { 165 | connection_discard_all(conn); 166 | // the connection_handle_error mustn't be called here, as the error 167 | // doesn't affect the status of the connection 168 | return -1; 169 | } 170 | *row = pyresult; 171 | } 172 | assert(status == 0 || status == 1); 173 | return status; 174 | } 175 | 176 | int connection_begin(ConnectionObject *conn) { 177 | assert(!conn->lazy && conn->status == CONN_STATUS_READY); 178 | 179 | // send BEGIN command and expect no results 180 | if (connection_run_without_results(conn, "BEGIN") < 0) { 181 | return -1; 182 | } 183 | 184 | conn->status = CONN_STATUS_IN_TRANSACTION; 185 | return 0; 186 | } 187 | 188 | void connection_discard_all(ConnectionObject *conn) { 189 | assert(conn->status == CONN_STATUS_EXECUTING); 190 | assert(PyErr_Occurred()); 191 | 192 | PyObject *prev_exc; 193 | { 194 | PyObject *type, *traceback; 195 | PyErr_Fetch(&type, &prev_exc, &traceback); 196 | PyErr_NormalizeException(&type, &prev_exc, &traceback); 197 | Py_XDECREF(type); 198 | Py_XDECREF(traceback); 199 | } 200 | 201 | int status = mg_session_pull(conn->session, NULL); 202 | if (status == 0) { 203 | mg_result *result; 204 | while ((status = mg_session_fetch(conn->session, &result)) == 1) 205 | ; 206 | } 207 | 208 | if (status == 0) { 209 | // We successfuly discarded all of the results. 210 | PyErr_SetString(InterfaceError, 211 | "There was an error fetching query results. The query has " 212 | "executed successfully but the results were discarded."); 213 | PyObject *type, *curr_exc, *traceback; 214 | PyErr_Fetch(&type, &curr_exc, &traceback); 215 | PyErr_NormalizeException(&type, &curr_exc, &traceback); 216 | PyException_SetCause(curr_exc, prev_exc); 217 | PyErr_Restore(type, curr_exc, traceback); 218 | } else { 219 | // There was a database error while pulling the rest of the results. 220 | connection_handle_error(conn, status); 221 | PyObject *pulling_exc; 222 | { 223 | PyObject *type, *traceback; 224 | PyErr_Fetch(&type, &pulling_exc, &traceback); 225 | PyErr_NormalizeException(&type, &pulling_exc, &traceback); 226 | Py_XDECREF(type); 227 | Py_XDECREF(traceback); 228 | } 229 | 230 | PyErr_SetString( 231 | InterfaceError, 232 | "There was an error fetching query results. While pulling the rest of " 233 | "the results from server to discard them, another exception occurred. " 234 | "It is not certain whether the query executed successfuly."); 235 | PyObject *type, *curr_exc, *traceback; 236 | PyErr_Fetch(&type, &curr_exc, &traceback); 237 | PyErr_NormalizeException(&type, &curr_exc, &traceback); 238 | PyException_SetCause(pulling_exc, prev_exc); 239 | PyException_SetCause(curr_exc, pulling_exc); 240 | PyErr_Restore(type, curr_exc, traceback); 241 | } 242 | 243 | if (conn->status != CONN_STATUS_BAD) { 244 | conn->status = 245 | conn->autocommit ? CONN_STATUS_READY : CONN_STATUS_IN_TRANSACTION; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /docs/source/introduction.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Introduction 3 | ============ 4 | 5 | pymgclient is a `Memgraph `_ database adapter for Python 6 | language compliant with the DB-API 2.0 specification described by :pep:`249`. 7 | 8 | :py:mod:`mgclient` module is the current implementation of the adapter. It is 9 | implemented in C as a wrapper around `mgclient`_, the official Memgraph client 10 | library. As a C extension, it is only compatible with CPython implementation of 11 | the Python programming language. 12 | 13 | :py:mod:`mgclient` only works with Python 3. 14 | 15 | 16 | ############# 17 | Installation 18 | ############# 19 | 20 | pymgclient has prebuilt binary packages for `Python 21 | `_ 3.10 - 3.13 on 22 | 23 | * Linux amd64 24 | 25 | * macOS 14 (Sonoma) and 15 (Sequoia) on arm64 26 | 27 | * Windows x86_64 28 | 29 | To install pymgclient binaries on these platforms see `Install binaries`_ section. 30 | A source distribution is also provided for other distributions and can be installed 31 | using ``pip`` after installing `Build prerequisites`_; alternatively - 32 | see `Install from source`_. 33 | 34 | Install binaries 35 | ################ 36 | 37 | .. warning:: 38 | All of the binary packages are statically linked against OpenSSL, that means the 39 | version of OpenSSL they are using is fixed. If security is important for you, 40 | you should check how to build pymgclient with dynamically linked OpenSSL, so 41 | pymgclient can use the latest version of OpenSSL that is installed on your 42 | machine. The source distribution may be built with the flags to dynamically 43 | link to the host machine's OpenSSL library. 44 | 45 | ``--no-build-isolation --global-option=build_ext --global-option "--static-openssl=False"`` 46 | 47 | On Linux and macOS run:: 48 | 49 | $ pip3 install --user pymgclient 50 | 51 | On Windows run:: 52 | 53 | $ py -3 -m pip install --user pymgclient 54 | 55 | Alternatively, on Windows, if the launcher is not installed, just run:: 56 | 57 | $ pip install --user pymgclient 58 | 59 | .. note:: 60 | Some platforms may require using the ``--break-system-packages`` flag. 61 | 62 | 63 | Install from source 64 | ################### 65 | 66 | pymgclient can be installed from source on: 67 | 68 | * all platforms that have prebuilt binaries 69 | * on various Linux distributions, including: 70 | 71 | * Ubuntu 22.04+ 72 | * Debian 11+ 73 | * Centos 9+ 74 | * Fedora 41+ 75 | 76 | ******************* 77 | Build prerequisites 78 | ******************* 79 | 80 | pymgclient is a C wrapper around the `mgclient`_ Memgraph client library. To 81 | build it from you will need: 82 | 83 | * Python 3.7 (3.9 for Mac OS) or newer 84 | * Python 3.7 (3.9 for Mac OS) or newer header files 85 | * A C compiler supporting C11 standard 86 | * A C++ compiler (it is not used directly, but necessary for CMake to work) 87 | * Preqrequisites of `mgclient`_: 88 | 89 | * CMake 3.8 or newer 90 | * OpenSSL 1.0.2 or newer (including OpenSSL 3.0.0+) and its header files 91 | 92 | Building on Linux 93 | ***************** 94 | 95 | First install the prerequisites: 96 | 97 | * On Debian/Ubuntu:: 98 | 99 | $ sudo apt install python3-dev cmake make gcc g++ libssl-dev 100 | * On CentOS/Fedora:: 101 | 102 | $ sudo dnf install -y python3-devel cmake3 make gcc gcc-c++ openssl-devel 103 | 104 | After the prerequisites are installed pymgclient can be installed via pip:: 105 | 106 | $ pip3 install --user pymgclient 107 | 108 | If you want to dynamically link OpenSSL for better security, you can use the 109 | following command:: 110 | 111 | $ pip3 install --user \ 112 | --global-option=build_ext \ 113 | --global-option="--static-openssl=false" \ 114 | pymgclient 115 | 116 | This will download the source package of pymgclient and build the binary package 117 | before installing it. Alternatively, pymgclient can be installed by using 118 | :file:`setup.py`:: 119 | 120 | $ python3 setup.py install 121 | 122 | Building on macOS 123 | ***************** 124 | 125 | To install the C/C++ compiler, run:: 126 | 127 | $ xcode-select --install 128 | 129 | The rest of the build prerequisites can be installed easily via `brew`_:: 130 | 131 | $ brew install python3 openssl cmake 132 | 133 | It is important to mention that on M1/ARM machines pymgclient cannot be built 134 | with the default installed Python version, thus Python needs to be installed via 135 | brew. If you are interested in the technical details, you can find more details 136 | in the technical notes below. 137 | 138 | After the prerequisites are installed pymgclient can be installed via pip:: 139 | 140 | $ pip3 install --user pymgclient --no-binary :all: 141 | 142 | This will download the source package of pymgclient and build the binary package 143 | before installing it. If you want to dynamically link OpenSSL for better 144 | security, you can use the following command:: 145 | 146 | $ pip3 install --user \ 147 | --global-option=build_ext \ 148 | --global-option="--static-openssl=false" \ 149 | pymgclient \ 150 | --no-binary :all: 151 | 152 | Alternatively, pymgclient can be installed by using :file:`setup.py`:: 153 | 154 | $ python3 setup.py install 155 | 156 | Technical note for arm64 machines 157 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 158 | 159 | The default installed Python is in the so called `Universal Binary 2 160 | `_ format. That 161 | means all of the packages that are built with this Python version have to be 162 | built also as a universal binary. Though pymgclient builds on both x86_64 and 163 | arm64 architectures, the brew installed OpenSSL version only contains the arm64 164 | binaries. As a consequence, during building the x86_64 part of the universal 165 | binary of pymgclient, the linker fails, because it cannot find the OpenSSL 166 | binaries in x86_64 binary format. 167 | 168 | Building on Windows 169 | ******************* 170 | 171 | Building pymgclient on Windows is only advised for advanced users, therefore the 172 | following description assumes technical knowledge about Windows, compiling C/C++ 173 | applications and Python package. 174 | 175 | To build pymgclient on Windows, the `MSYS2 `_ 176 | environment is needed. Once it is installed, run "MSYS2 MSYS" from Start menu 177 | and install the necessary packages:: 178 | 179 | $ pacman -Su 180 | $ pacman -S --needed base-devel mingw-w64-x86_64-toolchain \ 181 | mingw-w64-x86_64-cmake mingw-w64-x86_64-openssl 182 | 183 | After installation, add the :file:`/mingw64/bin` (by default this 184 | is :file:`C:/msys64/mingw64/bin`) to the :envvar:`PATH` environment variable to 185 | make the installed applications accessible from the default Windows command 186 | prompt. Once it is done, start the Windows command prompt and make sure the 187 | applications are available, e.g. checking the version of gcc:: 188 | 189 | $ gcc --version 190 | 191 | When the environment is done, start the Windows command prompt and install 192 | pymgclient can be installed via pip:: 193 | 194 | $ pip install --user pymgclient --no-binary :all: 195 | 196 | If you want to dynamically link OpenSSL for better security, you can use the 197 | following command:: 198 | 199 | $ pip install --user \ 200 | --global-option=build_ext \ 201 | --global-option="--static-openssl=false" \ 202 | pymgclient \ 203 | --no-binary :all: 204 | 205 | Alternatively, pymgclient can be installed by using :file:`setup.py`:: 206 | 207 | $ python setup.py install 208 | 209 | ###################### 210 | Running the test suite 211 | ###################### 212 | 213 | If pymgclient is installed from downloaded source, you can run the test suite to 214 | verify it is working correctly. From the source directory, you can run:: 215 | 216 | $ python3 -m pytest 217 | 218 | To run the tests, you will need to have Memgraph, pytest and pyopenssl installed 219 | on your machine. The tests will try to start the Memgraph binary from the 220 | standard installation path (usually :file:`/usr/lib/memgraph/memgraph`) 221 | listening on port 7687. You can configure a different path or port by setting 222 | the following environment variables: 223 | 224 | * :envvar:`MEMGRAPH_PATH` 225 | * :envvar:`MEMGRAPH_PORT` 226 | 227 | Alternatively you can also run the tests with an already running Memgraph by 228 | configuring the host and port by setting the following environment variables: 229 | 230 | * :envvar:`MEMGRAPH_HOST` 231 | * :envvar:`MEMGRAPH_PORT` 232 | 233 | When an already running Memgraph is used, then some of the tests might get 234 | skipped if Memgraph hasn't been started with a suitable configuration. The 235 | :envvar:`MEMGRAPH_STARTED_WITH_SSL` environment variable can be used to indicate 236 | whether Memgraph is started using SSL or not. If the environment variable is 237 | defined (regardless of its value), then the tests connect via secure Bolt 238 | connection, otherwise they connect with regular Bolt connection. 239 | 240 | The **tests insert data into Memgraph**, so they shouldn't be used with a 241 | Memgraph running in "production" environment. 242 | 243 | .. _mgclient: https://github.com/memgraph/mgclient 244 | .. _brew: https://brew.sh 245 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /test/test_connection.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import mgclient 16 | import pytest 17 | import tempfile 18 | 19 | from common import start_memgraph, Memgraph, requires_ssl_enabled, requires_ssl_disabled 20 | from OpenSSL import crypto 21 | 22 | 23 | @pytest.fixture(scope="function") 24 | def memgraph_server(): 25 | memgraph = start_memgraph() 26 | yield memgraph.host, memgraph.port, memgraph.sslmode(), memgraph.is_long_running() 27 | 28 | memgraph.terminate() 29 | 30 | 31 | def generate_key_and_cert(key_file, cert_file): 32 | k = crypto.PKey() 33 | k.generate_key(crypto.TYPE_RSA, 4096) 34 | 35 | cert = crypto.X509() 36 | cert.get_subject().C = "CA" 37 | cert.get_subject().O = "server" 38 | cert.get_subject().CN = "localhost" 39 | cert.set_serial_number(1) 40 | cert.gmtime_adj_notBefore(0) 41 | cert.gmtime_adj_notAfter(86400) 42 | cert.set_issuer(cert.get_subject()) 43 | cert.set_pubkey(k) 44 | cert.sign(k, "sha512") 45 | 46 | cert_file.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) 47 | cert_file.flush() 48 | key_file.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k)) 49 | key_file.flush() 50 | 51 | 52 | @pytest.fixture(scope="function") 53 | def secure_memgraph_server(): 54 | # we need public/private key pair to run Memgraph with SSL 55 | with tempfile.NamedTemporaryFile() as key_file, tempfile.NamedTemporaryFile() as cert_file: 56 | generate_key_and_cert(key_file.file, cert_file.file) 57 | 58 | memgraph = start_memgraph(key_file=key_file.name, cert_file=cert_file.name) 59 | assert memgraph.use_ssl 60 | assert memgraph.sslmode() == mgclient.MG_SSLMODE_REQUIRE 61 | yield memgraph.host, memgraph.port, memgraph.is_long_running() 62 | 63 | memgraph.terminate() 64 | 65 | 66 | def test_connect_args_validation(): 67 | # bad port 68 | with pytest.raises(ValueError): 69 | mgclient.connect(host="127.0.0.1", port=12344567) 70 | 71 | # bad SSL mode 72 | with pytest.raises(ValueError): 73 | mgclient.connect(host="127.0.0.1", port=7687, sslmode=55) 74 | 75 | # trust_callback not callable 76 | with pytest.raises(TypeError): 77 | mgclient.connect( 78 | host="127.0.0.1", 79 | port=7687, 80 | sslmode=mgclient.MG_SSLMODE_REQUIRE, 81 | trust_callback="not callable", 82 | ) 83 | 84 | 85 | @requires_ssl_disabled 86 | def test_connect_insecure_success(memgraph_server): 87 | host, port, sslmode, _ = memgraph_server 88 | assert sslmode == mgclient.MG_SSLMODE_DISABLE 89 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 90 | 91 | assert conn.status == mgclient.CONN_STATUS_READY 92 | 93 | 94 | @requires_ssl_disabled 95 | def test_connection_secure_fail(memgraph_server): 96 | # server doesn't use SSL 97 | host, port, sslmode, _ = memgraph_server 98 | with pytest.raises(mgclient.OperationalError): 99 | mgclient.connect(host=host, port=port, sslmode=mgclient.MG_SSLMODE_REQUIRE) 100 | 101 | 102 | @requires_ssl_enabled 103 | def test_connection_secure_success(secure_memgraph_server): 104 | host, port, is_long_running = secure_memgraph_server 105 | 106 | with pytest.raises(mgclient.OperationalError): 107 | conn = mgclient.connect(host=host, port=port) 108 | 109 | def good_trust_callback(hostname, ip_address, key_type, fingerprint): 110 | if not is_long_running: 111 | assert hostname == "localhost" 112 | assert ip_address == "127.0.0.1" 113 | return True 114 | 115 | def bad_trust_callback(hostname, ip_address, key_type, fingerprint): 116 | if not is_long_running: 117 | assert hostname == "localhost" 118 | assert ip_address == "127.0.0.1" 119 | return False 120 | 121 | with pytest.raises(mgclient.OperationalError): 122 | conn = mgclient.connect( 123 | host=host, 124 | port=port, 125 | sslmode=mgclient.MG_SSLMODE_REQUIRE, 126 | trust_callback=bad_trust_callback, 127 | ) 128 | 129 | conn = mgclient.connect( 130 | host=host, 131 | port=port, 132 | sslmode=mgclient.MG_SSLMODE_REQUIRE, 133 | trust_callback=good_trust_callback, 134 | ) 135 | 136 | assert conn.status == mgclient.CONN_STATUS_READY 137 | 138 | 139 | def test_connection_close(memgraph_server): 140 | host, port, sslmode, _ = memgraph_server 141 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 142 | 143 | assert conn.status == mgclient.CONN_STATUS_READY 144 | 145 | conn.close() 146 | assert conn.status == mgclient.CONN_STATUS_CLOSED 147 | 148 | # closing twice doesn't do anything 149 | conn.close() 150 | assert conn.status == mgclient.CONN_STATUS_CLOSED 151 | 152 | with pytest.raises(mgclient.InterfaceError): 153 | conn.commit() 154 | 155 | with pytest.raises(mgclient.InterfaceError): 156 | conn.rollback() 157 | 158 | with pytest.raises(mgclient.InterfaceError): 159 | conn.cursor() 160 | 161 | 162 | def test_connection_close_lazy(memgraph_server): 163 | host, port, sslmode, _ = memgraph_server 164 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 165 | cursor = conn.cursor() 166 | 167 | assert conn.status == mgclient.CONN_STATUS_READY 168 | 169 | cursor.execute("RETURN 100") 170 | assert conn.status == mgclient.CONN_STATUS_EXECUTING 171 | 172 | with pytest.raises(mgclient.InterfaceError): 173 | conn.close() 174 | 175 | cursor.fetchall() 176 | 177 | conn.close() 178 | assert conn.status == mgclient.CONN_STATUS_CLOSED 179 | 180 | 181 | def test_autocommit_regular(memgraph_server): 182 | host, port, sslmode, _ = memgraph_server 183 | 184 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 185 | 186 | # autocommit should be turned off by default 187 | assert not conn.autocommit 188 | 189 | cursor = conn.cursor() 190 | cursor.execute("RETURN 5") 191 | assert conn.status == mgclient.CONN_STATUS_IN_TRANSACTION 192 | 193 | # can't update autocommit while in transaction 194 | with pytest.raises(mgclient.InterfaceError): 195 | conn.autocommit = True 196 | 197 | conn.rollback() 198 | conn.autocommit = True 199 | 200 | assert conn.autocommit 201 | 202 | assert conn.status == mgclient.CONN_STATUS_READY 203 | cursor.execute("RETURN 5") 204 | assert conn.status == mgclient.CONN_STATUS_READY 205 | 206 | with pytest.raises(mgclient.InterfaceError): 207 | del conn.autocommit 208 | 209 | 210 | def test_autocommit_lazy(memgraph_server): 211 | host, port, sslmode, _ = memgraph_server 212 | 213 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 214 | 215 | # autocommit is always true for lazy connections 216 | assert conn.autocommit 217 | 218 | with pytest.raises(mgclient.InterfaceError): 219 | conn.autocommit = False 220 | 221 | 222 | def test_commit(memgraph_server): 223 | host, port, sslmode, is_long_running = memgraph_server 224 | 225 | conn1 = mgclient.connect(host=host, port=port, sslmode=sslmode) 226 | conn2 = mgclient.connect(host=host, port=port, sslmode=sslmode) 227 | conn2.autocommit = True 228 | 229 | cursor1 = conn1.cursor() 230 | cursor1.execute("MATCH (n) RETURN count(n)") 231 | original_count = cursor1.fetchall()[0][0] 232 | assert is_long_running or original_count == 0 233 | 234 | cursor1.execute("CREATE (:Node)") 235 | 236 | cursor2 = conn2.cursor() 237 | cursor2.execute("MATCH (n) RETURN count(n)") 238 | assert cursor2.fetchall() == [(original_count,)] 239 | 240 | conn1.commit() 241 | 242 | cursor2.execute("MATCH (n) RETURN count(n)") 243 | assert cursor2.fetchall() == [(original_count + 1,)] 244 | 245 | 246 | def test_rollback(memgraph_server): 247 | host, port, sslmode, is_long_running = memgraph_server 248 | 249 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 250 | 251 | cursor = conn.cursor() 252 | 253 | cursor.execute("MATCH (n) RETURN count(n)") 254 | original_count = cursor.fetchall()[0][0] 255 | assert is_long_running or original_count == 0 256 | 257 | cursor.execute("CREATE (:Node)") 258 | cursor.fetchall() 259 | 260 | cursor.execute("MATCH (n) RETURN count(n)") 261 | assert cursor.fetchall() == [(original_count + 1,)] 262 | 263 | conn.rollback() 264 | cursor.execute("MATCH (n) RETURN count(n)") 265 | assert cursor.fetchall() == [(original_count,)] 266 | 267 | 268 | def test_close_doesnt_commit(memgraph_server): 269 | host, port, sslmode, is_long_running = memgraph_server 270 | 271 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 272 | 273 | cursor = conn.cursor() 274 | cursor.execute("MATCH (n) RETURN count(n)") 275 | original_count = cursor.fetchall()[0][0] 276 | assert is_long_running or original_count == 0 277 | 278 | cursor.execute("CREATE (:Node)") 279 | 280 | conn.close() 281 | 282 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 283 | cursor = conn.cursor() 284 | cursor.execute("MATCH (n) RETURN count(n)") 285 | 286 | assert cursor.fetchall() == [(original_count,)] 287 | 288 | 289 | def test_commit_rollback_lazy(memgraph_server): 290 | host, port, sslmode, _ = memgraph_server 291 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 292 | cursor = conn.cursor() 293 | cursor.execute("CREATE (:Node) RETURN 1") 294 | 295 | conn.rollback() 296 | assert conn.status == mgclient.CONN_STATUS_EXECUTING 297 | 298 | conn.commit() 299 | assert conn.status == mgclient.CONN_STATUS_EXECUTING 300 | 301 | assert cursor.fetchall() == [(1,)] 302 | assert conn.status == mgclient.CONN_STATUS_READY 303 | 304 | 305 | def test_autocommit_failure(memgraph_server): 306 | host, port, sslmode, _ = memgraph_server 307 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 308 | conn.autocommit = False 309 | 310 | assert conn.status == mgclient.CONN_STATUS_READY 311 | cursor = conn.cursor() 312 | cursor.execute("RETURN 5") 313 | assert conn.status == mgclient.CONN_STATUS_IN_TRANSACTION 314 | 315 | with pytest.raises(mgclient.DatabaseError): 316 | cursor.execute("SHOW INDEX INFO") 317 | 318 | assert conn.status == mgclient.CONN_STATUS_READY 319 | cursor.execute("RETURN 5") 320 | assert conn.status == mgclient.CONN_STATUS_IN_TRANSACTION 321 | -------------------------------------------------------------------------------- /src/mgclientmodule.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #define PY_SSIZE_T_CLEAN 16 | #include 17 | 18 | #define APILEVEL "2.0" 19 | #define THREADSAFETY 1 20 | // For simplicity, here we deviate from the DB-API spec. 21 | #define PARAMSTYLE "cypher" 22 | 23 | #include 24 | 25 | #include "column.h" 26 | #include "connection.h" 27 | #include "cursor.h" 28 | #include "glue.h" 29 | #include "types.h" 30 | 31 | PyObject *Warning; 32 | PyObject *Error; 33 | PyObject *InterfaceError; 34 | PyObject *DatabaseError; 35 | PyObject *DataError; 36 | PyObject *OperationalError; 37 | PyObject *IntegrityError; 38 | PyObject *InternalError; 39 | PyObject *ProgrammingError; 40 | PyObject *NotSupportedError; 41 | 42 | PyDoc_STRVAR(Warning_doc, "Exception raised for important warnings."); 43 | PyDoc_STRVAR(Error_doc, "Base class of all other error exceptions."); 44 | PyDoc_STRVAR( 45 | InterfaceError_doc, 46 | "Exception raised for errors related to the database interface rather than " 47 | "the database itself."); 48 | PyDoc_STRVAR(DatabaseError_doc, 49 | "Exception raised for errors related to the database."); 50 | PyDoc_STRVAR( 51 | DataError_doc, 52 | "Exception raised for errors that are due to problems with the processed " 53 | "data."); 54 | PyDoc_STRVAR( 55 | OperationalError_doc, 56 | "Exception raised for errors related to the database's operation, not " 57 | "necessarily under the control of the programmer (e.g. unexpected " 58 | "disconnect, failed allocation)."); 59 | PyDoc_STRVAR( 60 | IntegrityError_doc, 61 | "Exception raised when the relational integrity of the database is " 62 | "affected."); 63 | PyDoc_STRVAR( 64 | InternalError_doc, 65 | "Exception raised when the database encounters an internal error."); 66 | PyDoc_STRVAR( 67 | ProgrammingError_doc, 68 | "Exception raised for programming errors (e.g. syntax error, invalid " 69 | "parameters)"); 70 | PyDoc_STRVAR( 71 | NotSupportedError_doc, 72 | "Exception raised in a case a method or database API was used which is not " 73 | "supported by the database."); 74 | 75 | int add_module_exceptions(PyObject *module) { 76 | struct { 77 | const char *name; 78 | PyObject **exc; 79 | PyObject **base; 80 | const char *docstring; 81 | } module_exceptions[] = { 82 | {"mgclient.Warning", &Warning, &PyExc_Exception, Warning_doc}, 83 | {"mgclient.Error", &Error, &PyExc_Exception, Error_doc}, 84 | {"mgclient.InterfaceError", &InterfaceError, &Error, InterfaceError_doc}, 85 | {"mgclient.DatabaseError", &DatabaseError, &Error, DatabaseError_doc}, 86 | {"mgclient.DataError", &DataError, &DatabaseError, DataError_doc}, 87 | {"mgclient.OperationalError", &OperationalError, &DatabaseError, 88 | OperationalError_doc}, 89 | {"mgclient.IntegrityError", &IntegrityError, &DatabaseError, 90 | IntegrityError_doc}, 91 | {"mgclient.InternalError", &InternalError, &DatabaseError, 92 | InternalError_doc}, 93 | {"mgclient.ProgrammingError", &ProgrammingError, &DatabaseError, 94 | ProgrammingError_doc}, 95 | {"mgclient.NotSupportedError", &NotSupportedError, &DatabaseError, 96 | NotSupportedError_doc}, 97 | {NULL, NULL, NULL, NULL}}; 98 | 99 | for (size_t i = 0; module_exceptions[i].name; ++i) { 100 | *module_exceptions[i].exc = NULL; 101 | } 102 | for (size_t i = 0; module_exceptions[i].name; ++i) { 103 | PyObject *exc = PyErr_NewExceptionWithDoc(module_exceptions[i].name, 104 | module_exceptions[i].docstring, 105 | *module_exceptions[i].base, NULL); 106 | if (!exc) { 107 | goto cleanup; 108 | } 109 | *module_exceptions[i].exc = exc; 110 | } 111 | for (size_t i = 0; module_exceptions[i].name; ++i) { 112 | const char *name = strrchr(module_exceptions[i].name, '.'); 113 | name = name ? name + 1 : module_exceptions[i].name; 114 | if (PyModule_AddObject(module, name, *module_exceptions[i].exc) < 0) { 115 | goto cleanup; 116 | } 117 | } 118 | 119 | return 0; 120 | 121 | cleanup: 122 | for (size_t i = 0; module_exceptions[i].name; ++i) { 123 | Py_XDECREF(*module_exceptions[i].exc); 124 | } 125 | return -1; 126 | } 127 | 128 | int add_module_constants(PyObject *module) { 129 | if (PyModule_AddStringConstant(module, "apilevel", APILEVEL) < 0) { 130 | return -1; 131 | } 132 | if (PyModule_AddIntConstant(module, "threadsafety", THREADSAFETY) < 0) { 133 | return -1; 134 | } 135 | if (PyModule_AddStringConstant(module, "paramstyle", PARAMSTYLE) < 0) { 136 | return -1; 137 | } 138 | if (PyModule_AddIntMacro(module, MG_SSLMODE_REQUIRE) < 0) { 139 | return -1; 140 | } 141 | if (PyModule_AddIntMacro(module, MG_SSLMODE_DISABLE) < 0) { 142 | return -1; 143 | } 144 | 145 | // Connection status constants. 146 | if (PyModule_AddIntMacro(module, CONN_STATUS_READY) < 0) { 147 | return -1; 148 | } 149 | if (PyModule_AddIntMacro(module, CONN_STATUS_BAD) < 0) { 150 | return -1; 151 | } 152 | if (PyModule_AddIntMacro(module, CONN_STATUS_CLOSED) < 0) { 153 | return -1; 154 | } 155 | if (PyModule_AddIntMacro(module, CONN_STATUS_IN_TRANSACTION) < 0) { 156 | return -1; 157 | } 158 | if (PyModule_AddIntMacro(module, CONN_STATUS_EXECUTING) < 0) { 159 | return -1; 160 | } 161 | 162 | return 0; 163 | } 164 | 165 | static struct { 166 | char *name; 167 | PyTypeObject *type; 168 | } type_table[] = {{"Connection", &ConnectionType}, 169 | {"Cursor", &CursorType}, 170 | {"Column", &ColumnType}, 171 | {"Node", &NodeType}, 172 | {"Relationship", &RelationshipType}, 173 | {"Path", &PathType}, 174 | {NULL, NULL}}; 175 | 176 | static int add_module_types(PyObject *module) { 177 | for (size_t i = 0; type_table[i].name; ++i) { 178 | if (PyType_Ready(type_table[i].type) < 0) { 179 | return -1; 180 | } 181 | if (PyModule_AddObject(module, type_table[i].name, 182 | (PyObject *)type_table[i].type) < 0) { 183 | return -1; 184 | } 185 | } 186 | return 0; 187 | } 188 | 189 | static PyObject *mgclient_connect(PyObject *self, PyObject *args, 190 | PyObject *kwargs) { 191 | // Unused parameter. 192 | (void)self; 193 | 194 | return PyObject_Call((PyObject *)&ConnectionType, args, kwargs); 195 | } 196 | 197 | // clang-format off 198 | PyDoc_STRVAR(mgclient_connect_doc, 199 | "connect(host=None, address=None, port=None, username=None, password=None,\n\ 200 | client_name=None, sslmode=mgclient.MG_SSLMODE_DISABLE,\n\ 201 | sslcert=None, sslkey=None, trust_callback=None, lazy=False)\n\ 202 | --\n\ 203 | \n\ 204 | Makes a new connection to the database server and returns a\n\ 205 | :class:`Connection` object.\n\ 206 | \n\ 207 | Currently recognized parameters are:\n\ 208 | \n\ 209 | * :obj:`host`\n\ 210 | \n\ 211 | DNS resolvable name of host to connect to. Exactly one of host and\n\ 212 | address parameters must be specified.\n\ 213 | \n\ 214 | * :obj:`address`\n\ 215 | \n\ 216 | Numeric IP address of host to connect to. This should be in the\n\ 217 | standard IPv4 address format. You can also use IPv6 if your machine\n\ 218 | supports it. Exactly one of host and address parameters must be\n\ 219 | specified.\n\ 220 | \n\ 221 | * :obj:`port`\n\ 222 | \n\ 223 | Port number to connect to at the server host.\n\ 224 | \n\ 225 | * :obj:`username`\n\ 226 | \n\ 227 | Username to connect as.\n\ 228 | \n\ 229 | * :obj:`password`\n\ 230 | \n\ 231 | Password to be used if the server demands password authentication.\n\ 232 | \n\ 233 | * :obj:`client_name`\n\ 234 | \n\ 235 | Alternate name and version of the client to send to server. Default is\n\ 236 | set by the underlying mgclient library.\n\ 237 | \n\ 238 | * :obj:`sslmode`\n\ 239 | \n\ 240 | This option determines whether a secure connection will be negotiated\n\ 241 | with the server. There are 2 possible values:\n\ 242 | \n\ 243 | * :const:`mgclient.MG_SSLMODE_DISABLE`\n\ 244 | \n\ 245 | Only try a non-SSL connection (default).\n\ 246 | \n\ 247 | * :const:`mgclient.MG_SSLMODE_REQUIRE`\n\ 248 | \n\ 249 | Only try an SSL connection.\n\ 250 | \n\ 251 | * :obj:`sslcert`\n\ 252 | \n\ 253 | This parameter specifies the file name of the client SSL certificate.\n\ 254 | It is ignored in case an SSL connection is not made.\n\ 255 | \n\ 256 | * :obj:`sslkey`\n\ 257 | \n\ 258 | This parameter specifies the location of the secret key used for the\n\ 259 | client certificate. This parameter is ignored in case an SSL connection\n\ 260 | is not made.\n\ 261 | \n\ 262 | * :obj:`trust_callback`\n\ 263 | \n\ 264 | A callable taking four arguments.\n\ 265 | \n\ 266 | After performing the SSL handshake, :meth:`connect` will call this\n\ 267 | callable providing the hostname, IP address, public key type and\n\ 268 | fingerprint. If the function returns ``False`` SSL connection will\n\ 269 | immediately be terminated.\n\ 270 | \n\ 271 | This can be used to implement TOFU (trust on first use) mechanism.\n\ 272 | \n\ 273 | * :obj:`lazy`\n\ 274 | \n\ 275 | If this is set to ``True``, a lazy connection is made. Default is ``False``."); 276 | // clang-format on 277 | 278 | static PyMethodDef mgclient_methods[] = { 279 | {"connect", (PyCFunction)mgclient_connect, METH_VARARGS | METH_KEYWORDS, 280 | mgclient_connect_doc}, 281 | {NULL, NULL, 0, NULL}}; 282 | 283 | static struct PyModuleDef mgclient_module = {.m_base = PyModuleDef_HEAD_INIT, 284 | .m_name = "mgclient", 285 | .m_doc = NULL, 286 | .m_size = -1, 287 | .m_methods = mgclient_methods, 288 | .m_slots = NULL}; 289 | 290 | PyMODINIT_FUNC PyInit_mgclient(void) { 291 | PyObject *m; 292 | if (!(m = PyModule_Create(&mgclient_module))) { 293 | return NULL; 294 | } 295 | if (add_module_exceptions(m) < 0) { 296 | return NULL; 297 | } 298 | if (add_module_constants(m) < 0) { 299 | return NULL; 300 | } 301 | if (add_module_types(m) < 0) { 302 | return NULL; 303 | } 304 | if (mg_init() != MG_SUCCESS) { 305 | return NULL; 306 | } 307 | 308 | py_datetime_import_init(); 309 | return m; 310 | } 311 | -------------------------------------------------------------------------------- /test/test_glue.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | import datetime 19 | import platform 20 | import sys 21 | 22 | import mgclient 23 | import pytest 24 | from zoneinfo import ZoneInfo 25 | 26 | from common import Memgraph, start_memgraph 27 | 28 | # TODO(colinbarry) The Fedora docker image seems to have flaky timezone support. 29 | # For the time being, we will force Fedora-based tests to use UTC only, and 30 | # for other OSs to test more diverse timezones. 31 | def is_fedora(): 32 | """Check if running on Fedora platform""" 33 | try: 34 | with open('/etc/os-release', 'r') as f: 35 | content = f.read() 36 | return 'ID=fedora' in content 37 | except (FileNotFoundError, IOError): 38 | return False 39 | 40 | 41 | @pytest.fixture(scope="function") 42 | def memgraph_connection(): 43 | memgraph = start_memgraph() 44 | conn = mgclient.connect(host=memgraph.host, port=memgraph.port, sslmode=memgraph.sslmode()) 45 | conn.autocommit = True 46 | yield conn 47 | 48 | memgraph.terminate() 49 | conn.close() 50 | 51 | 52 | def test_none(memgraph_connection): 53 | conn = memgraph_connection 54 | cursor = conn.cursor() 55 | cursor.execute("RETURN $value", {"value": None}) 56 | assert cursor.fetchall() == [(None,)] 57 | 58 | 59 | def test_bool(memgraph_connection): 60 | conn = memgraph_connection 61 | cursor = conn.cursor() 62 | 63 | cursor.execute("RETURN $value", {"value": True}) 64 | assert cursor.fetchall() == [(True,)] 65 | 66 | cursor.execute("RETURN $value", {"value": False}) 67 | assert cursor.fetchall() == [(False,)] 68 | 69 | 70 | def test_integer(memgraph_connection): 71 | conn = memgraph_connection 72 | cursor = conn.cursor() 73 | 74 | cursor.execute("RETURN $value", {"value": 42}) 75 | assert cursor.fetchall() == [(42,)] 76 | 77 | cursor.execute("RETURN $value", {"value": -1}) 78 | assert cursor.fetchall() == [(-1,)] 79 | 80 | cursor.execute("RETURN $value", {"value": 3289302198}) 81 | assert cursor.fetchall() == [(3289302198,)] 82 | 83 | with pytest.raises(OverflowError): 84 | cursor.execute("RETURN $value", {"value": 10 ** 100}) 85 | 86 | 87 | def test_float(memgraph_connection): 88 | conn = memgraph_connection 89 | cursor = conn.cursor() 90 | cursor.execute("RETURN $value", {"value": 42.0}) 91 | assert cursor.fetchall() == [(42.0,)] 92 | 93 | cursor.execute("RETURN $value", {"value": 3.1415962}) 94 | assert cursor.fetchall() == [(3.1415962,)] 95 | 96 | 97 | def test_string(memgraph_connection): 98 | conn = memgraph_connection 99 | cursor = conn.cursor() 100 | 101 | cursor.execute("RETURN $value", {"value": "the best test"}) 102 | assert cursor.fetchall() == [("the best test",)] 103 | 104 | cursor.execute("RETURN '\u010C\u017D\u0160'") 105 | assert cursor.fetchall() == [("ČŽŠ",)] 106 | 107 | cursor.execute("RETURN $value", {"value": b"\x01\x0C\x01\x7D\x01\x60".decode("utf-16be")}) 108 | assert cursor.fetchall() == [("ČŽŠ",)] 109 | 110 | 111 | def test_list(memgraph_connection): 112 | conn = memgraph_connection 113 | cursor = conn.cursor() 114 | 115 | cursor.execute("RETURN $value", {"value": [1, 2, None, True, False, "abc", []]}) 116 | result = cursor.fetchall() 117 | assert result == [([1, 2, None, True, False, "abc", []],)] 118 | value_from_result = result[0][0][6] 119 | # This checks the reference number of the values in a mg_list are correct. 120 | # Ref count should be 3, because: 121 | # * one reference because the list is referenced in result 122 | # * one reference because of value_from_result 123 | # * one temporary reference because sys.getrefcount increases the ref 124 | # count 125 | assert sys.getrefcount(value_from_result) == 3 126 | 127 | 128 | def test_map(memgraph_connection): 129 | conn = memgraph_connection 130 | cursor = conn.cursor() 131 | key_in_a_map = """ 132 | A long name because refs to strings are globally counted 133 | """ 134 | value_in_a_map = [1, 2, 3] 135 | 136 | cursor.execute( 137 | "RETURN $value", 138 | { 139 | "value": { 140 | "x": 1, 141 | "y": 2, 142 | "map": {key_in_a_map: "value"}, 143 | "list": value_in_a_map, 144 | } 145 | }, 146 | ) 147 | 148 | result = cursor.fetchall() 149 | assert result == [({"x": 1, "y": 2, "map": {key_in_a_map: "value"}, "list": value_in_a_map},)] 150 | 151 | value_in_a_map_from_result = result[0][0]["list"] 152 | # This checks if the reference number of the values in a mg_map are 153 | # correct. 154 | # Ref count should be 3, because: 155 | # * one reference because the list is referenced in result 156 | # * one reference because of value_in_a_map_from_result 157 | # * one temporary reference because sys.getrefcount increases the ref 158 | # count 159 | assert sys.getrefcount(value_in_a_map_from_result) == 3 160 | # This checks if the reference number of the keys in a mg_map are correct. 161 | # Ref count should be 3, because: 162 | # * one reference because the string is referenced in result 163 | # * one reference because of key_in_the_map (refs to the same strings are 164 | # globally counted) 165 | # * one temporary reference because sys.getrefcount increases the ref 166 | # count 167 | assert sys.getrefcount(key_in_a_map) == 3 168 | 169 | 170 | def test_node(memgraph_connection): 171 | conn = memgraph_connection 172 | cursor = conn.cursor() 173 | cursor.execute("CREATE (n:Label1:Label2 {prop1 : 1, prop2 : 'prop2'}) " "RETURN id(n), n") 174 | rows = cursor.fetchall() 175 | node_id, _ = rows[0] 176 | assert rows == [ 177 | ( 178 | node_id, 179 | mgclient.Node(node_id, set(["Label1", "Label2"]), {"prop1": 1, "prop2": "prop2"}), 180 | ) 181 | ] 182 | 183 | with pytest.raises(ValueError): 184 | cursor.execute("RETURN $node", {"node": rows[0][1]}) 185 | 186 | 187 | def test_relationship(memgraph_connection): 188 | conn = memgraph_connection 189 | cursor = conn.cursor() 190 | cursor.execute("CREATE (n)-[e:Type {prop1 : 1, prop2 : 'prop2'}]->(m) " "RETURN id(n), id(m), id(e), e") 191 | rows = cursor.fetchall() 192 | start_id, end_id, edge_id, _ = rows[0] 193 | assert rows == [ 194 | ( 195 | start_id, 196 | end_id, 197 | edge_id, 198 | mgclient.Relationship(edge_id, start_id, end_id, "Type", {"prop1": 1, "prop2": "prop2"}), 199 | ) 200 | ] 201 | 202 | with pytest.raises(ValueError): 203 | cursor.execute("RETURN $rel", {"rel": rows[0][3]}) 204 | 205 | 206 | def test_path(memgraph_connection): 207 | conn = memgraph_connection 208 | cursor = conn.cursor() 209 | cursor.execute( 210 | "CREATE p = (n1:Node1)<-[e1:Edge1]-(n2:Node2)-[e2:Edge2]->(n3:Node3) " 211 | "RETURN id(n1), id(n2), id(n3), id(e1), id(e2), p" 212 | ) 213 | rows = cursor.fetchall() 214 | n1_id, n2_id, n3_id, e1_id, e2_id, _ = rows[0] 215 | n1 = mgclient.Node(n1_id, set(["Node1"]), {}) 216 | n2 = mgclient.Node(n2_id, set(["Node2"]), {}) 217 | n3 = mgclient.Node(n3_id, set(["Node3"]), {}) 218 | e1 = mgclient.Relationship(e1_id, n2_id, n1_id, "Edge1", {}) 219 | e2 = mgclient.Relationship(e2_id, n2_id, n3_id, "Edge2", {}) 220 | 221 | assert rows == [(n1_id, n2_id, n3_id, e1_id, e2_id, mgclient.Path([n1, n2, n3], [e1, e2]))] 222 | 223 | with pytest.raises(ValueError): 224 | cursor.execute("RETURN $path", {"path": rows[0][5]}) 225 | 226 | 227 | def test_tuple(memgraph_connection): 228 | conn = memgraph_connection 229 | cursor = conn.cursor() 230 | 231 | cursor.execute("RETURN $value1, $value2", {"value1": [], "value2": []}) 232 | result = cursor.fetchall() 233 | assert result == [([], [])] 234 | for i in [0, 1]: 235 | value_in_tuple = result[0][i] 236 | # This checks the reference number of the values in a tuple created 237 | # from an mg_list are correct. 238 | # Ref count should be 3, because: 239 | # * one reference because the list is referenced in result 240 | # * one reference because of value_in_tuple 241 | # * one temporary reference because sys.getrefcount increases the ref 242 | # count 243 | assert sys.getrefcount(value_in_tuple) == 3 244 | 245 | 246 | @pytest.mark.temporal 247 | def test_time(memgraph_connection): 248 | conn = memgraph_connection 249 | cursor = conn.cursor() 250 | cursor.execute("RETURN $value", {"value": datetime.time(1, 2, 3, 40)}) 251 | result = cursor.fetchall() 252 | assert result == [(datetime.time(1, 2, 3, 40),)] 253 | 254 | 255 | @pytest.mark.temporal 256 | def test_date(memgraph_connection): 257 | conn = memgraph_connection 258 | cursor = conn.cursor() 259 | cursor.execute("RETURN $value", {"value": datetime.date(1994, 7, 12)}) 260 | result = cursor.fetchall() 261 | assert result == [(datetime.date(1994, 7, 12),)] 262 | 263 | 264 | @pytest.mark.temporal 265 | def test_datetime(memgraph_connection): 266 | conn = memgraph_connection 267 | cursor = conn.cursor() 268 | cursor.execute("RETURN $value", {"value": datetime.datetime(2004, 7, 11, 12, 13, 14, 15)}) 269 | result = cursor.fetchall() 270 | assert result == [(datetime.datetime(2004, 7, 11, 12, 13, 14, 15),)] 271 | 272 | 273 | @pytest.mark.temporal 274 | def test_datetime_with_offset_timezone(memgraph_connection): 275 | conn = memgraph_connection 276 | cursor = conn.cursor() 277 | cursor.execute("RETURN $value", {"value": datetime.datetime(2004, 7, 11, 12, 13, 14, 15, tzinfo=datetime.timezone(datetime.timedelta(hours=3)))}) 278 | result = cursor.fetchall() 279 | assert result == [(datetime.datetime(2004, 7, 11, 12, 13, 14, 15, tzinfo=datetime.timezone(datetime.timedelta(hours=3))),)] 280 | 281 | @pytest.mark.temporal 282 | def test_datetime_with_named_timezone(memgraph_connection): 283 | conn = memgraph_connection 284 | cursor = conn.cursor() 285 | 286 | # See comment at top of file about Fedora's timezone support. 287 | if is_fedora(): 288 | tz_name = "UTC" 289 | expected_tz_names = ["UTC", "Etc/UTC"] 290 | else: 291 | tz_name = "Pacific/Pitcairn" 292 | expected_tz_names = ["Pacific/Pitcairn"] 293 | 294 | test_dt = datetime.datetime(2024, 8, 12, 10, 15, 42, 123, tzinfo=ZoneInfo(tz_name)) 295 | cursor.execute("RETURN $value", {"value": test_dt}) 296 | result = cursor.fetchall() 297 | assert len(result) == 1 298 | dt = result[0][0] 299 | assert isinstance(dt, datetime.datetime) 300 | assert dt.year == 2024 301 | assert dt.month == 8 302 | assert dt.day == 12 303 | assert dt.hour == 10 304 | assert dt.minute == 15 305 | assert dt.second == 42 306 | assert dt.microsecond == 123 307 | assert str(dt.tzinfo) in expected_tz_names 308 | 309 | 310 | @pytest.mark.temporal 311 | def test_duration(memgraph_connection): 312 | conn = memgraph_connection 313 | cursor = conn.cursor() 314 | cursor.execute("RETURN $value", {"value": datetime.timedelta(64, 7, 11, 1)}) 315 | result = cursor.fetchall() 316 | assert result == [(datetime.timedelta(64, 7, 1011),)] 317 | 318 | @pytest.mark.temporal 319 | def test_zoneddatetime(memgraph_connection): 320 | conn = memgraph_connection 321 | cursor = conn.cursor() 322 | cursor.execute("RETURN $value", {"value": datetime.datetime(2025, 8, 12, 10, 15, 42, 123, tzinfo=datetime.timezone(datetime.timedelta(hours=7, minutes=30)))}) 323 | result = cursor.fetchall() 324 | assert result == [(datetime.datetime(2025, 8, 12, 10, 15, 42, 123, tzinfo=datetime.timezone(datetime.timedelta(hours=7, minutes=30))),)] 325 | 326 | 327 | @pytest.mark.temporal 328 | def test_zoneddatetime_with_iana_timezone(memgraph_connection): 329 | conn = memgraph_connection 330 | cursor = conn.cursor() 331 | 332 | # See comment at top of file about Fedora's timezone support. 333 | if is_fedora(): 334 | tz_name = "UTC" 335 | expected_tz_names = ["UTC", "Etc/UTC"] 336 | else: 337 | tz_name = "America/New_York" 338 | expected_tz_names = ["America/New_York"] 339 | 340 | cursor.execute(f"RETURN datetime({{year: 2025, month: 8, day: 13, hour: 14, minute: 30, second: 45, timezone: '{tz_name}'}})") 341 | result = cursor.fetchall() 342 | 343 | assert len(result) == 1 344 | 345 | dt = result[0][0] 346 | assert isinstance(dt, datetime.datetime) 347 | assert dt.year == 2025 348 | assert dt.month == 8 349 | assert dt.day == 13 350 | assert dt.hour == 14 351 | assert dt.minute == 30 352 | assert dt.second == 45 353 | assert str(dt.tzinfo) in expected_tz_names 354 | 355 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import platform 17 | import shutil 18 | import sys 19 | import configparser 20 | from distutils import log 21 | from distutils.errors import DistutilsExecError, DistutilsPlatformError 22 | from pathlib import Path 23 | from typing import List 24 | 25 | from setuptools import Extension, setup 26 | from setuptools.command.build_ext import build_ext 27 | 28 | IS_WINDOWS = sys.platform == "win32" 29 | IS_APPLE = sys.platform == "darwin" 30 | IS_X64 = platform.architecture()[0] == "64bit" 31 | 32 | if IS_WINDOWS: 33 | # https://stackoverflow.com/a/57109148/6639989 34 | import distutils.cygwinccompiler 35 | 36 | distutils.cygwinccompiler.get_msvcr = lambda: [] 37 | 38 | with open("README.md", "r") as fh: 39 | readme = fh.read() 40 | long_description = "\n".join(readme.split("\n")[2:]).lstrip() 41 | 42 | # Throughout this file "mgclient" can mean two different things: 43 | # 1. The mgclient library which is the official Memgraph client library. 44 | # 2. The mgclient python extension module which is a wrapper around the 45 | # client library. 46 | EXTENSION_NAME = "mgclient" 47 | 48 | sources = [str(path) for path in Path("src").glob("*.c")] 49 | 50 | headers = [str(path) for path in Path("src").glob("*.h")] 51 | 52 | parser = configparser.ConfigParser() 53 | parser.read("setup.cfg") 54 | 55 | static_openssl = parser.getboolean("build_ext", "static_openssl", fallback=False) 56 | 57 | version = os.getenv("PYMGCLIENT_OVERRIDE_VERSION", "1.5.1") 58 | 59 | 60 | def list_all_files_in_dir(path): 61 | result = [] 62 | for root, _dirs, files in os.walk(path): 63 | result.extend(os.path.join(root, f) for f in files) 64 | 65 | return result 66 | 67 | 68 | class BuildMgclientExt(build_ext): 69 | """ 70 | Builds using cmake instead of the python setuptools implicit build 71 | """ 72 | 73 | user_options = build_ext.user_options[:] 74 | user_options.append(("static-openssl=", None, "Compile with statically linked OpenSSL.")) 75 | 76 | boolean_options = build_ext.boolean_options[:] 77 | boolean_options.append(("static-openssl")) 78 | 79 | def initialize_options(self): 80 | build_ext.initialize_options(self) 81 | self.static_openssl = static_openssl 82 | 83 | def run(self): 84 | """ 85 | Perform build_cmake before doing the 'normal' stuff 86 | """ 87 | self.announce(f"Using static OpenSSL: {bool(self.static_openssl)}", level=log.INFO) 88 | 89 | for extension in self.extensions: 90 | if extension.name == EXTENSION_NAME: 91 | self.build_mgclient_for(extension) 92 | 93 | if IS_WINDOWS: 94 | if self.compiler is None: 95 | self.compiler = "mingw32" 96 | elif self.compiler != "mingw32": 97 | raise DistutilsPlatformError( 98 | f"The specified compiler {self.compiler} is not supported on windows, only mingw32 is supported." 99 | ) 100 | 101 | super().run() 102 | 103 | def get_cmake_binary(self): 104 | cmake_env_var_name = "PYMGCLIENT_CMAKE" 105 | custom_cmake = os.getenv(cmake_env_var_name) 106 | if custom_cmake is None: 107 | # cmake3 is checked before cmake, because on CentOS cmake refers 108 | # to CMake 2.* 109 | for possible_cmake in ["cmake3", "cmake"]: 110 | self.announce(f"Checking if {possible_cmake} can be used", level=log.INFO) 111 | 112 | which_cmake = shutil.which(possible_cmake) 113 | if which_cmake is not None: 114 | self.announce(f"Using {which_cmake}", level=log.INFO) 115 | return os.path.abspath(which_cmake) 116 | 117 | self.announce(f"{possible_cmake} is not accesible", level=log.INFO) 118 | raise DistutilsExecError("Cannot found suitable cmake") 119 | else: 120 | self.announce( 121 | f"Using the value of {cmake_env_var_name} for CMake, which is" f"{custom_cmake}", level=log.INFO 122 | ) 123 | return custom_cmake 124 | 125 | def get_openssl_root_dir(self): 126 | if not IS_APPLE: 127 | return None 128 | 129 | openssl_root_dir_env_var = "OPENSSL_ROOT_DIR" 130 | openssl_root_dir = os.getenv(openssl_root_dir_env_var) 131 | 132 | if openssl_root_dir: 133 | self.announce( 134 | f"Using the value of {openssl_root_dir_env_var} for OpenSSL," f"which is {openssl_root_dir}", 135 | level=log.INFO, 136 | ) 137 | return openssl_root_dir 138 | 139 | # The order is the following: 140 | # 1. OpenSSL 3 on Apple Silicon 141 | # 2. OpenSSL 1 on Apple Silicon 142 | # 3. OpenSSL 3 on Intel 143 | # 4. OpenSSL 1 on Intel 144 | # Prefer Apple Silicon over Intel and prefer OpenSSL 3 over 1. 145 | possible_openssl_root_dirs = [ 146 | "/opt/homebrew/opt/openssl@3", 147 | "/opt/homebrew/opt/openssl@1.1", 148 | "/usr/local/opt/openssl@3", 149 | "/usr/local/opt/openssl@1.1", 150 | ] 151 | 152 | for dir in possible_openssl_root_dirs: 153 | if os.path.isdir(dir): 154 | return dir 155 | 156 | return None 157 | 158 | def finalize_cmake_config_command_darwin(self, cmake_config_command: List[str]): 159 | openssl_root_dir = self.get_openssl_root_dir() 160 | if openssl_root_dir is not None: 161 | cmake_config_command.append(f"-DOPENSSL_ROOT_DIR={openssl_root_dir}") 162 | # otherwise trust CMake to find OpenSSL 163 | 164 | def finalize_cmake_config_command_win32(self, cmake_config_command: List[str]): 165 | cmake_config_command.append("-GMinGW Makefiles") 166 | 167 | def get_extra_link_args(self, libs: List[str]): 168 | # https://stackoverflow.com/a/45335363/6639989 169 | return [f"-l:lib{name}.a" for name in libs] 170 | 171 | def finalize_darwin(self, extension: Extension): 172 | libs = ["ssl", "crypto"] 173 | openssl_root_dir = self.get_openssl_root_dir() 174 | if self.static_openssl: 175 | extension.extra_link_args.extend([f"{openssl_root_dir}/lib/lib{lib}.a" for lib in libs]) 176 | else: 177 | extension.extra_link_args.extend([f"{openssl_root_dir}/lib/lib{lib}.dylib" for lib in libs]) 178 | 179 | def finalize_linux_like(self, extension: Extension, libs: List[str]): 180 | if self.static_openssl: 181 | extension.extra_link_args.extend(self.get_extra_link_args(libs)) 182 | else: 183 | extension.libraries.extend(libs) 184 | 185 | def finalize_win32(self, extension: Extension): 186 | self.finalize_linux_like(extension, ["ssl", "crypto", "ws2_32"]) 187 | 188 | def finalize_linux(self, extension: Extension): 189 | self.finalize_linux_like(extension, ["ssl", "crypto"]) 190 | 191 | def get_cflags(self): 192 | return "{0} -Werror=all".format(os.getenv("CFLAGS", "")).strip() 193 | 194 | def build_mgclient_for(self, extension: Extension): 195 | """ 196 | Builds mgclient library and configures the extension to be able to use 197 | the mgclient library as a static library. 198 | 199 | In this function all usage of mgclient refers to the client library 200 | and not the python extension module. 201 | """ 202 | cmake_binary = self.get_cmake_binary() 203 | 204 | self.announce("Preparing the build environment for mgclient", level=log.INFO) 205 | 206 | extension_build_dir = Path(self.build_temp).absolute() 207 | mgclient_build_path = os.path.join(extension_build_dir, "mgclient_build") 208 | mgclient_install_path = os.path.join(extension_build_dir, "mgclient_install") 209 | 210 | self.announce(f"Using {mgclient_build_path} as build directory for mgclient", level=log.INFO) 211 | self.announce(f"Using {mgclient_install_path} as install directory for mgclient", level=log.INFO) 212 | 213 | os.makedirs(mgclient_build_path, exist_ok=True) 214 | mgclient_source_path = os.path.join(Path(__file__).absolute().parent, "mgclient") 215 | 216 | # CMake <3.13 versions don't support explicit build directory 217 | prev_working_dir = os.getcwd() 218 | os.chdir(mgclient_build_path) 219 | 220 | self.announce("Configuring mgclient", level=log.INFO) 221 | 222 | build_type = "Debug" if self.debug else "Release" 223 | install_libdir = "lib" 224 | install_includedir = "include" 225 | cmake_config_command = [ 226 | cmake_binary, 227 | mgclient_source_path, 228 | f"-DCMAKE_INSTALL_LIBDIR={install_libdir}", 229 | f"-DCMAKE_INSTALL_INCLUDEDIR={install_includedir}", 230 | f"-DCMAKE_BUILD_TYPE={build_type}", 231 | f"-DCMAKE_INSTALL_PREFIX={mgclient_install_path}", 232 | "-DBUILD_TESTING=OFF", 233 | "-DCMAKE_POSITION_INDEPENDENT_CODE=ON", 234 | f'-DCMAKE_C_FLAGS="{self.get_cflags()}"', 235 | f"-DOPENSSL_USE_STATIC_LIBS={'ON' if self.static_openssl else 'OFF'}", 236 | "-DPKG_CONFIG_USE_STATIC_LIBS=ON" 237 | ] 238 | 239 | finalize_cmake_config_command = getattr(self, "finalize_cmake_config_command_" + sys.platform, None) 240 | if finalize_cmake_config_command is not None: 241 | finalize_cmake_config_command(cmake_config_command) 242 | 243 | try: 244 | self.spawn(cmake_config_command) 245 | except DistutilsExecError as dee: 246 | self.announce("Error happened during configuring mgclient! Is OpenSSL installed correctly?") 247 | raise dee 248 | 249 | self.announce("Building mgclient binaries", level=log.INFO) 250 | 251 | try: 252 | self.spawn([cmake_binary, "--build", mgclient_build_path, "--config", build_type, "--target", "install"]) 253 | except DistutilsExecError as dee: 254 | self.announce("Error happened during building mgclient binaries!", level=log.FATAL) 255 | raise dee 256 | 257 | os.chdir(prev_working_dir) 258 | 259 | mgclient_sources = [os.path.join(mgclient_source_path, "CMakeLists.txt")] 260 | 261 | for subdir in ["src", "include", "cmake"]: 262 | mgclient_sources.extend(list_all_files_in_dir(os.path.join(mgclient_source_path, subdir))) 263 | 264 | extension.include_dirs.append(os.path.join(mgclient_install_path, install_includedir)) 265 | extension.extra_objects.append(os.path.join(mgclient_install_path, install_libdir, "libmgclient.a")) 266 | extension.depends.extend(mgclient_sources) 267 | extension.define_macros.append(("MGCLIENT_STATIC_DEFINE", "")) 268 | 269 | finalize = getattr(self, "finalize_" + sys.platform, None) 270 | if finalize is not None: 271 | finalize(extension) 272 | 273 | if sys.platform == "win32": 274 | extra_link_args = [ 275 | "-l:libssl.a", 276 | "-l:libcrypto.a", 277 | "-lcrypt32", 278 | "-lws2_32" 279 | ] 280 | else: 281 | extra_link_args = None 282 | 283 | setup( 284 | name="pymgclient", 285 | version=version, 286 | maintainer="Matt James", 287 | maintainer_email="matthew.james@memgraph.io", 288 | author="Colin Barry", 289 | author_email="colin.barry@memgraph.io", 290 | license="Apache2", 291 | python_requires=">=3.9", 292 | description="Memgraph database adapter for Python language", 293 | long_description=long_description, 294 | long_description_content_type="text/markdown", 295 | url="https://github.com/memgraph/pymgclient", 296 | classifiers=[ 297 | "Development Status :: 3 - Alpha", 298 | "Intended Audience :: Developers", 299 | "License :: OSI Approved :: Apache Software License", 300 | "Programming Language :: Python :: 3.9", 301 | "Programming Language :: Python :: 3.10", 302 | "Programming Language :: Python :: 3.11", 303 | "Programming Language :: Python :: 3.12", 304 | "Programming Language :: Python :: 3.13", 305 | "Programming Language :: Python :: Implementation :: CPython", 306 | "Topic :: Database", 307 | "Topic :: Database :: Front-Ends", 308 | "Topic :: Software Development", 309 | "Topic :: Software Development :: Libraries :: Python Modules", 310 | "Operating System :: POSIX :: Linux", 311 | "Operating System :: MacOS :: MacOS X", 312 | "Operating System :: Microsoft :: Windows", 313 | ], 314 | ext_modules=[ 315 | Extension(EXTENSION_NAME, sources=sources, depends=headers, extra_link_args=extra_link_args) 316 | ], 317 | project_urls={ 318 | "Source": "https://github.com/memgraph/pymgclient", 319 | "Documentation": "https://memgraph.github.io/pymgclient", 320 | }, 321 | cmdclass={"build_ext": BuildMgclientExt}, 322 | install_requires=[ 323 | "pyopenssl", 324 | "networkx", 325 | "tzdata" 326 | ] 327 | ) 328 | -------------------------------------------------------------------------------- /src/connection.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #include "connection.h" 16 | 17 | #include 18 | 19 | #include "cursor.h" 20 | #include "exceptions.h" 21 | 22 | static void connection_dealloc(ConnectionObject *conn) { 23 | mg_session_destroy(conn->session); 24 | Py_TYPE(conn)->tp_free(conn); 25 | } 26 | 27 | static int execute_trust_callback(const char *hostname, const char *ip_address, 28 | const char *key_type, const char *fingerprint, 29 | PyObject *pycallback) { 30 | PyObject *result = PyObject_CallFunction(pycallback, "ssss", hostname, 31 | ip_address, key_type, fingerprint); 32 | if (!result) { 33 | return -1; 34 | } 35 | int status = PyObject_IsTrue(result); 36 | Py_DECREF(result); 37 | return !status; 38 | } 39 | 40 | static int connection_init(ConnectionObject *conn, PyObject *args, 41 | PyObject *kwargs) { 42 | static char *kwlist[] = {"host", "address", "port", "username", 43 | "password", "client_name", "sslmode", "sslcert", 44 | "sslkey", "trust_callback", "lazy", NULL}; 45 | 46 | const char *host = NULL; 47 | const char *address = NULL; 48 | int port = -1; 49 | const char *username = NULL; 50 | const char *password = NULL; 51 | const char *client_name = NULL; 52 | int sslmode_int = MG_SSLMODE_DISABLE; 53 | const char *sslcert = NULL; 54 | const char *sslkey = NULL; 55 | PyObject *trust_callback = NULL; 56 | int lazy = 0; 57 | 58 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|$ssisssissOp", kwlist, &host, 59 | &address, &port, &username, &password, 60 | &client_name, &sslmode_int, &sslcert, 61 | &sslkey, &trust_callback, &lazy)) { 62 | return -1; 63 | } 64 | 65 | if (port < 0 || port > 65535) { 66 | PyErr_SetString(PyExc_ValueError, "port out of range"); 67 | return -1; 68 | } 69 | 70 | enum mg_sslmode sslmode; 71 | switch (sslmode_int) { 72 | case MG_SSLMODE_DISABLE: 73 | case MG_SSLMODE_REQUIRE: 74 | sslmode = sslmode_int; 75 | break; 76 | default: 77 | PyErr_SetString(PyExc_ValueError, "invalid sslmode"); 78 | return -1; 79 | } 80 | 81 | if (trust_callback && !PyCallable_Check(trust_callback)) { 82 | PyErr_SetString(PyExc_TypeError, 83 | "trust_callback argument must be callable"); 84 | return -1; 85 | } 86 | 87 | mg_session_params *params = mg_session_params_make(); 88 | if (!params) { 89 | PyErr_SetString(PyExc_RuntimeError, 90 | "couldn't allocate session parameters object"); 91 | return -1; 92 | } 93 | mg_session_params_set_host(params, host); 94 | mg_session_params_set_port(params, (uint16_t)port); 95 | mg_session_params_set_address(params, address); 96 | mg_session_params_set_username(params, username); 97 | mg_session_params_set_password(params, password); 98 | if (client_name) { 99 | mg_session_params_set_user_agent(params, client_name); 100 | } 101 | mg_session_params_set_sslmode(params, sslmode); 102 | mg_session_params_set_sslcert(params, sslcert); 103 | mg_session_params_set_sslkey(params, sslkey); 104 | if (trust_callback) { 105 | mg_session_params_set_trust_callback( 106 | params, (mg_trust_callback_type)execute_trust_callback); 107 | mg_session_params_set_trust_data(params, (void *)trust_callback); 108 | } 109 | 110 | mg_session *session; 111 | { 112 | int status = mg_connect(params, &session); 113 | mg_session_params_destroy(params); 114 | if (status != 0) { 115 | // TODO(mtomic): maybe convert MG_ERROR_* codes to different kinds of 116 | // Python exceptions 117 | PyErr_SetString(OperationalError, mg_session_error(session)); 118 | mg_session_destroy(session); 119 | return -1; 120 | } 121 | } 122 | 123 | conn->session = session; 124 | conn->status = CONN_STATUS_READY; 125 | conn->lazy = 0; 126 | conn->autocommit = 0; 127 | 128 | if (lazy) { 129 | conn->lazy = 1; 130 | conn->autocommit = 1; 131 | } 132 | 133 | return 0; 134 | } 135 | 136 | static PyObject *connection_new(PyTypeObject *subtype, PyObject *args, 137 | PyObject *kwargs) { 138 | // Unused args. 139 | (void)args; 140 | (void)kwargs; 141 | 142 | PyObject *conn = subtype->tp_alloc(subtype, 0); 143 | if (!conn) { 144 | return NULL; 145 | } 146 | ((ConnectionObject *)conn)->status = CONN_STATUS_BAD; 147 | return conn; 148 | } 149 | 150 | // clang-format off 151 | PyDoc_STRVAR(connection_close_doc, 152 | "close()\n\ 153 | --\n\ 154 | \n\ 155 | Close the connection now.\n\ 156 | \n\ 157 | The connection will be unusable from this point forward; an :exc:`InterfaceError`\n\ 158 | will be raised if any operation is attempted with the connection. The same applies\n\ 159 | to all :class:`.Cursor` objects trying to use the connection.\n\ 160 | \n\ 161 | Note that closing a connection without committing the changes will cause an implicit\n\ 162 | rollback to be performed."); 163 | // clang-format on 164 | 165 | static PyObject *connection_close(ConnectionObject *conn, PyObject *args) { 166 | // Unused args. 167 | (void)args; 168 | 169 | assert(!args); 170 | 171 | if (conn->status == CONN_STATUS_EXECUTING) { 172 | // This can only happen if connection is in lazy execution mode. 173 | assert(conn->lazy); 174 | PyErr_SetString(InterfaceError, 175 | "cannot close connection during execution of a query"); 176 | return NULL; 177 | } 178 | 179 | // No need to rollback, closing the connection will automatically 180 | // rollback any open transactions. 181 | mg_session_destroy(conn->session); 182 | conn->session = NULL; 183 | conn->status = CONN_STATUS_CLOSED; 184 | 185 | Py_RETURN_NONE; 186 | } 187 | 188 | // clang-format off 189 | PyDoc_STRVAR(connection_commit_doc, 190 | "commit()\n\ 191 | --\n\ 192 | \n\ 193 | Commit any pending transaction to the database.\n\ 194 | \n\ 195 | If auto-commit is turned on, this method does nothing."); 196 | // clang-format on 197 | 198 | static PyObject *connection_commit(ConnectionObject *conn, PyObject *args) { 199 | // Unused args. 200 | (void)args; 201 | 202 | assert(!args); 203 | 204 | if (connection_raise_if_bad_status(conn) < 0) { 205 | return NULL; 206 | } 207 | 208 | if (conn->status == CONN_STATUS_EXECUTING) { 209 | // This can only happen if connection is in lazy execution mode. In 210 | // that case, autocommit must be enabled and this method does nothing. 211 | assert(conn->lazy && conn->autocommit); 212 | Py_RETURN_NONE; 213 | } 214 | 215 | if (conn->autocommit || conn->status == CONN_STATUS_READY) { 216 | Py_RETURN_NONE; 217 | } 218 | 219 | assert(conn->status == CONN_STATUS_IN_TRANSACTION); 220 | 221 | // send COMMIT command and expect no results 222 | if (connection_run_without_results(conn, "COMMIT") < 0) { 223 | return NULL; 224 | } 225 | 226 | conn->status = CONN_STATUS_READY; 227 | Py_RETURN_NONE; 228 | } 229 | 230 | // clang-format off 231 | PyDoc_STRVAR(connection_rollback_doc, 232 | "rollback()\n\ 233 | --\n\ 234 | \n\ 235 | Roll back to the start of any pending transaction.\n\ 236 | \n\ 237 | If auto-commit is turned on, this method does nothing."); 238 | // clang-format on 239 | 240 | static PyObject *connection_rollback(ConnectionObject *conn, PyObject *args) { 241 | // Unused args. 242 | (void)args; 243 | 244 | assert(!args); 245 | if (connection_raise_if_bad_status(conn) < 0) { 246 | return NULL; 247 | } 248 | 249 | if (conn->status == CONN_STATUS_EXECUTING) { 250 | // This can only happen if connection is in lazy execution mode. In 251 | // that case, autocommit must be enabled and this method does nothing. 252 | assert(conn->lazy && conn->autocommit); 253 | Py_RETURN_NONE; 254 | } 255 | 256 | if (conn->autocommit || conn->status == CONN_STATUS_READY) { 257 | Py_RETURN_NONE; 258 | } 259 | 260 | assert(conn->status == CONN_STATUS_IN_TRANSACTION); 261 | 262 | // send ROLLBACK command and expect no results 263 | if (connection_run_without_results(conn, "ROLLBACK") < 0) { 264 | return NULL; 265 | } 266 | 267 | conn->status = CONN_STATUS_READY; 268 | Py_RETURN_NONE; 269 | } 270 | 271 | // clang-format off 272 | PyDoc_STRVAR(connection_cursor_doc, 273 | "cursor()\n\ 274 | --\n\ 275 | \n\ 276 | Return a new :class:`Cursor` object using the connection."); 277 | // clang-format on 278 | 279 | static PyObject *connection_cursor(ConnectionObject *conn, PyObject *args) { 280 | // Unused args. 281 | (void)args; 282 | 283 | assert(!args); 284 | 285 | if (connection_raise_if_bad_status(conn) < 0) { 286 | return NULL; 287 | } 288 | 289 | return PyObject_CallFunctionObjArgs((PyObject *)&CursorType, conn, NULL); 290 | } 291 | 292 | // clang-format off 293 | PyDoc_STRVAR( 294 | ConnectionType_autocommit_doc, 295 | "This read/write attribute specifies whether executed statements\n\ 296 | have immediate effect in the database.\n\ 297 | \n\ 298 | If ``True``, every executed statement has immediate effect.\n\ 299 | \n\ 300 | If ``False``, a new transaction is started at the execution of the first\n\ 301 | command. Transactions must be manually terminated using :meth:`commit` or\n\ 302 | :meth:`rollback` methods."); 303 | // clang-format on 304 | 305 | PyObject *connection_autocommit_get(ConnectionObject *conn, void *data) { 306 | (void)data; 307 | if (conn->autocommit) { 308 | Py_RETURN_TRUE; 309 | } else { 310 | Py_RETURN_FALSE; 311 | } 312 | } 313 | 314 | int connection_autocommit_set(ConnectionObject *conn, PyObject *value, 315 | void *data) { 316 | (void)data; 317 | if (!value) { 318 | PyErr_SetString(InterfaceError, "cannot delete autocommit property"); 319 | return -1; 320 | } 321 | if (conn->lazy) { 322 | PyErr_SetString(InterfaceError, 323 | "autocommit is always enabled in lazy mode"); 324 | return -1; 325 | } 326 | if (conn->status == CONN_STATUS_EXECUTING || 327 | conn->status == CONN_STATUS_IN_TRANSACTION) { 328 | PyErr_SetString(InterfaceError, 329 | "cannot change autocommit property while in a transaction"); 330 | return -1; 331 | } 332 | int tf = PyObject_IsTrue(value); 333 | if (tf < 0) { 334 | return -1; 335 | } 336 | conn->autocommit = tf ? 1 : 0; 337 | 338 | return 0; 339 | } 340 | 341 | static PyMethodDef connection_methods[] = { 342 | {"close", (PyCFunction)connection_close, METH_NOARGS, connection_close_doc}, 343 | {"commit", (PyCFunction)connection_commit, METH_NOARGS, 344 | connection_commit_doc}, 345 | {"rollback", (PyCFunction)connection_rollback, METH_NOARGS, 346 | connection_rollback_doc}, 347 | {"cursor", (PyCFunction)connection_cursor, METH_NOARGS, 348 | connection_cursor_doc}, 349 | {NULL, NULL, 0, NULL}}; 350 | 351 | // clang-format off 352 | PyDoc_STRVAR(ConnectionType_status_doc, 353 | "Status of the connection.\n\ 354 | \n\ 355 | It's value can be one of the following macros:\n\ 356 | * :data:`mgclient.CONN_STATUS_READY`\n\ 357 | The connection is currently not in a transaction and\n\ 358 | it is ready to start executing the next command.\n\ 359 | \n\ 360 | * :data:`mgclient.CONN_STATUS_BAD`\n\ 361 | Something went wrong with the connection, it cannot be\n\ 362 | used for command execution anymore.\n\ 363 | \n\ 364 | * :data:`mgclient.CONN_STATUS_CLOSED`\n\ 365 | The connection was closed by user, it cannot be\n\ 366 | used for command execution anymore.\n\ 367 | \n\ 368 | * :data:`mgclient.CONN_STATUS_IN_TRANSACTION`\n\ 369 | The connection is currently in an implicitly started\n\ 370 | transaction.\n\ 371 | \n\ 372 | * :data:`mgclient.CONN_STATUS_EXECUTING`\n\ 373 | The connection is currently executing a query. This status\n\ 374 | can only be seen for lazy connections."); 375 | // clang-format on 376 | 377 | static PyMemberDef connection_members[] = { 378 | {"status", T_INT, offsetof(ConnectionObject, status), READONLY, 379 | ConnectionType_status_doc}, 380 | {NULL}}; 381 | 382 | static PyGetSetDef connection_getset[] = { 383 | {"autocommit", (getter)connection_autocommit_get, 384 | (setter)connection_autocommit_set, ConnectionType_autocommit_doc, NULL}, 385 | {NULL}}; 386 | 387 | // clang-format off 388 | PyDoc_STRVAR(ConnectionType_doc, 389 | "Encapsulates a database connection.\n\ 390 | \n\ 391 | New instances are created using the factory function :func:`connect`.\n\ 392 | \n\ 393 | Connections are not thread-safe."); 394 | // clang-format on 395 | 396 | // clang-format off 397 | PyTypeObject ConnectionType = { 398 | PyVarObject_HEAD_INIT(NULL, 0) 399 | .tp_name = "mgclient.Connection", 400 | .tp_doc = ConnectionType_doc, 401 | .tp_basicsize = sizeof(ConnectionObject), 402 | .tp_itemsize = 0, 403 | .tp_flags = Py_TPFLAGS_DEFAULT, 404 | .tp_dealloc = (destructor)connection_dealloc, 405 | .tp_methods = connection_methods, 406 | .tp_members = connection_members, 407 | .tp_getset = connection_getset, 408 | .tp_init = (initproc)connection_init, 409 | .tp_new = (newfunc)connection_new}; 410 | // clang-format on 411 | -------------------------------------------------------------------------------- /.github/workflows/reusable_buildtest.yml: -------------------------------------------------------------------------------- 1 | name: Reusable Build and Test 2 | 3 | 4 | on: 5 | workflow_call: 6 | inputs: 7 | test_linux: 8 | type: boolean 9 | default: true 10 | description: "Run Linux Build and Test" 11 | test_windows: 12 | type: boolean 13 | default: true 14 | description: "Run Windows Build and Test" 15 | test_macintosh: 16 | type: boolean 17 | default: true 18 | description: "Run Mac OS Build" 19 | build_source_dist: 20 | type: boolean 21 | default: true 22 | description: "Build Source Distribution" 23 | upload_artifacts: 24 | type: boolean 25 | default: true 26 | description: "Upload Artifacts" 27 | version: 28 | required: false 29 | type: string 30 | 31 | jobs: 32 | build_and_test_linux: 33 | if: ${{ inputs.test_linux }} 34 | name: "Build and test on Linux 👍" 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | include: 39 | - {platform: 'ubuntu-24.04', python_version: '3.10', mgversion: 'latest', arch: 'x86_64'} 40 | - {platform: 'ubuntu-24.04', python_version: '3.11', mgversion: 'latest', arch: 'x86_64'} 41 | - {platform: 'ubuntu-24.04', python_version: '3.12', mgversion: 'latest', arch: 'x86_64'} 42 | - {platform: 'ubuntu-24.04', python_version: '3.13', mgversion: 'latest', arch: 'x86_64'} 43 | - {platform: 'fedora-41', python_version: '3.13', mgversion: 'latest', arch: 'x86_64'} 44 | - {platform: 'ubuntu-24.04', python_version: '3.12', mgversion: 'latest', arch: 'aarch64'} 45 | runs-on: [self-hosted, "${{ matrix.arch == 'x86_64' && 'X64' || 'ARM64' }}"] 46 | steps: 47 | - name: Checkout repository and submodules 48 | uses: actions/checkout@v4 49 | with: 50 | fetch-depth: 0 51 | submodules: recursive 52 | 53 | - name: Set Memgraph Version 54 | run: | 55 | if [[ "${{ matrix.mgversion }}" == "latest" ]]; then 56 | mgversion=$(./tools/get_memgraph_version.sh) 57 | else 58 | mgversion="${{ matrix.mgversion }}" 59 | fi 60 | echo "MGVERSION=$mgversion" >> $GITHUB_ENV 61 | 62 | - name: Download Memgraph 63 | run: | 64 | if [[ "${{ matrix.platform }}" == "fedora-41" ]]; then 65 | MEMGRAPH_PACKAGE_NAME="memgraph-${{ env.MGVERSION }}_1-1.${{ matrix.arch }}.rpm" 66 | LOCAL_PACKAGE_NAME=memgraph.rpm 67 | else 68 | MEMGRAPH_PACKAGE_NAME="memgraph_${{ env.MGVERSION }}-1_${{ matrix.arch == 'x86_64' && 'amd64' || 'arm64' }}.deb" 69 | LOCAL_PACKAGE_NAME=memgraph.deb 70 | fi 71 | curl -L "https://download.memgraph.com/memgraph/v${{ env.MGVERSION }}/${{ matrix.platform }}${{ matrix.arch == 'aarch64' && '-aarch64' || '' }}/${MEMGRAPH_PACKAGE_NAME}" > "${LOCAL_PACKAGE_NAME}" 72 | echo "LOCAL_PACKAGE_NAME=$LOCAL_PACKAGE_NAME" >> $GITHUB_ENV 73 | 74 | - name: Set up Docker Buildx 75 | id: buildx 76 | uses: docker/setup-buildx-action@v3 77 | 78 | - name: Log in to Docker Hub 79 | uses: docker/login-action@v3 80 | with: 81 | username: ${{ secrets.DOCKERHUB_USERNAME }} 82 | password: ${{ secrets.DOCKERHUB_TOKEN }} 83 | 84 | - name: Launch Docker Container 85 | run: | 86 | platform="${{ matrix.platform }}" 87 | tag=${platform//-/:} 88 | if [[ "${{ inputs.version }}" != "" ]]; then 89 | echo "Building version ${{ inputs.version }}" 90 | docker run -d --rm \ 91 | -e PYMGCLIENT_OVERRIDE_VERSION=${{ inputs.version }} \ 92 | --name testcontainer "$tag" sleep infinity 93 | else 94 | docker run -d --rm --name testcontainer "$tag" sleep infinity 95 | fi 96 | 97 | - name: Set Environment Variables 98 | run: | 99 | break_packages="" 100 | static_ssl="" 101 | if [[ "${{ matrix.platform }}" == "ubuntu-24.04" ]]; then 102 | # this option is specific to ubuntu, not fedora 103 | break_packages="--break-system-packages" 104 | elif [[ "${{ matrix.platform }}" == fedora* ]]; then 105 | # rpm distros do not ship with static lib for openssl 106 | static_ssl="build_ext --static-openssl=False" 107 | fi 108 | echo "BREAK_PACKAGES=$break_packages" >> $GITHUB_ENV 109 | echo "STATIC_SSL=$static_ssl" >> $GITHUB_ENV 110 | 111 | - name: Copy Repo Into Container 112 | run: | 113 | docker cp . testcontainer:/pymgclient 114 | 115 | - name: Install system dependencies 116 | run: | 117 | docker cp $LOCAL_PACKAGE_NAME testcontainer:/$LOCAL_PACKAGE_NAME 118 | 119 | # Prevents Memgraph from starting. 120 | docker exec -i testcontainer \ 121 | bash -c "mkdir -p /etc/systemd/system && ln -s /dev/null /etc/systemd/system/memgraph.service" 122 | 123 | # Install dependencies 124 | docker exec -i testcontainer \ 125 | bash -c "cd /pymgclient && ./tools/install_linux_deps.sh ${{ matrix.platform }} --python-version ${{ matrix.python_version }} --force-update" 126 | 127 | # Install Memgraph package 128 | if [[ "${{ matrix.platform }}" == "fedora-41" ]]; then 129 | docker exec -i testcontainer \ 130 | bash -c "dnf install -y /$LOCAL_PACKAGE_NAME" 131 | else 132 | docker exec -i testcontainer \ 133 | bash -c "dpkg -i /$LOCAL_PACKAGE_NAME" 134 | fi 135 | rm -v $LOCAL_PACKAGE_NAME 136 | 137 | 138 | - name: Build Python Wheel 139 | run: | 140 | docker exec -i testcontainer \ 141 | bash -c "cd /pymgclient && python${{ matrix.python_version }} setup.py ${{ env.STATIC_SSL }} bdist_wheel" 142 | 143 | - name: Audit Wheel 144 | if: ${{ matrix.platform == 'ubuntu-24.04' }} 145 | run: | 146 | docker exec -i testcontainer \ 147 | bash -c "cd /pymgclient && auditwheel repair dist/*.whl --plat manylinux_2_39_${{ matrix.arch == 'x86_64' && 'x86_64' || 'aarch64' }} -w dist/ && rm dist/*linux_${{ matrix.arch == 'x86_64' && 'x86_64' || 'aarch64' }}.whl" 148 | 149 | 150 | - name: Install pymgclient 151 | run: | 152 | docker exec -i testcontainer \ 153 | bash -c "python${{ matrix.python_version }} -m pip install ./pymgclient/dist/pymgclient-* ${{ env.BREAK_PACKAGES }}" 154 | 155 | - name: Import mgclient to validate installation 156 | run: | 157 | docker exec -i testcontainer \ 158 | bash -c "python${{ matrix.python_version }} -c 'import mgclient'" 159 | 160 | - name: Run tests 161 | run: | 162 | MEMGRAPH_PORT=10000 # what's this for? 163 | 164 | docker exec -i testcontainer \ 165 | bash -c "cd /pymgclient && python${{ matrix.python_version }} -m pytest -v" 166 | 167 | - name: Build docs 168 | run: | 169 | docker exec -i testcontainer \ 170 | bash -c "cd /pymgclient/docs && make html" 171 | 172 | - name: Copy Package 173 | run: | 174 | docker cp testcontainer:/pymgclient/dist . 175 | 176 | - name: Save source distribution package 177 | if: ${{ inputs.upload_artifacts && matrix.platform == 'ubuntu-24.04' }} 178 | uses: actions/upload-artifact@v4 179 | with: 180 | name: pymgclient-linux-${{ matrix.python_version }} 181 | path: dist/ 182 | 183 | - name: Cleanup 184 | if: always() 185 | run: | 186 | docker stop testcontainer || echo "Container does not exist" 187 | docker wait testcontainer || echo "Container does not exist" 188 | docker rmi ${{ env.MGVERSION }} || echo "Image does not exist" 189 | 190 | build_source_dist: 191 | if: ${{ inputs.build_source_dist }} 192 | name: Build Source Distribution 193 | runs-on: [self-hosted, X64, Ubuntu24.04] 194 | steps: 195 | - name: Checkout repository and submodules 196 | uses: actions/checkout@v4 197 | with: 198 | fetch-depth: 0 199 | submodules: recursive 200 | 201 | - name: Set override version if provided 202 | if: ${{ inputs.version != '' }} 203 | run: | 204 | echo "Building version ${{ inputs.version }}" 205 | echo "PYMGCLIENT_OVERRIDE_VERSION=${{ inputs.version }}" >> $GITHUB_ENV 206 | 207 | - name: Create Python Virtual Environment 208 | run: | 209 | python3 -m venv env 210 | source env/bin/activate 211 | pip install setuptools wheel 212 | 213 | - name: Build Python Source Distribution 214 | run: | 215 | source env/bin/activate 216 | python setup.py sdist 217 | 218 | - name: Save source distribution package 219 | if: ${{ inputs.upload_artifacts }} 220 | uses: actions/upload-artifact@v4 221 | with: 222 | name: pymgclient-linux-sdist 223 | path: dist 224 | 225 | - name: Cleanup 226 | if: always() 227 | run: | 228 | rm -fr env || true 229 | rm -fr dist || true 230 | 231 | 232 | 233 | build_and_test_windows: 234 | if: ${{ inputs.test_windows }} 235 | name: Build and Test on Windows 236 | runs-on: ${{ matrix.os }} 237 | strategy: 238 | fail-fast: false 239 | matrix: 240 | os: [windows-2022, windows-2025] 241 | python-version: ["3.10", "3.11", "3.12", "3.13"] 242 | 243 | steps: 244 | - name: Checkout code 245 | uses: actions/checkout@v4 246 | with: 247 | submodules: true 248 | 249 | - name: Set override version if provided 250 | if: ${{ inputs.version != '' }} 251 | shell: bash 252 | run: | 253 | echo "Building version ${{ inputs.version }}" 254 | echo "PYMGCLIENT_OVERRIDE_VERSION=${{ inputs.version }}" >> $GITHUB_ENV 255 | 256 | - name: Set up MSYS2 257 | uses: msys2/setup-msys2@v2 258 | with: 259 | msystem: MINGW64 260 | install: > 261 | git 262 | mingw-w64-x86_64-gcc 263 | mingw-w64-x86_64-cmake 264 | mingw-w64-x86_64-make 265 | mingw-w64-x86_64-openssl 266 | 267 | - name: Add MSYS2 mingw64/bin to PATH 268 | shell: msys2 {0} 269 | run: | 270 | echo "/mingw64/bin" >> $GITHUB_PATH 271 | 272 | 273 | - name: Set up Windows Python 274 | uses: actions/setup-python@v5 275 | with: 276 | python-version: ${{ matrix.python-version }} 277 | architecture: x64 278 | 279 | - name: Add Windows Python to PATH 280 | shell: msys2 {0} 281 | run: | 282 | echo "$pythonLocation" >> $GITHUB_PATH 283 | env: 284 | pythonLocation: ${{ env.pythonLocation }} 285 | 286 | 287 | - name: Install Python build tools 288 | shell: msys2 {0} 289 | run: | 290 | export PATH="$(cygpath -u "$pythonLocation"):$PATH" 291 | python -m pip install --upgrade pip setuptools wheel pyopenssl pytest tzdata 292 | env: 293 | pythonLocation: ${{ env.pythonLocation }} 294 | 295 | - name: Build pymgclient Wheel 296 | shell: msys2 {0} 297 | run: | 298 | export PATH="$(cygpath -u "$pythonLocation"):$PATH" 299 | python setup.py bdist_wheel 300 | env: 301 | pythonLocation: ${{ env.pythonLocation }} 302 | 303 | - name: Install built wheel 304 | shell: msys2 {0} 305 | run: | 306 | export PATH="$(cygpath -u "$pythonLocation"):$PATH" 307 | python -m pip install dist/*.whl 308 | env: 309 | pythonLocation: ${{ env.pythonLocation }} 310 | 311 | - name: Setup WSL Ubuntu 312 | uses: Vampire/setup-wsl@v5 313 | with: 314 | distribution: Ubuntu-24.04 315 | 316 | - name: Set Memgraph Version 317 | shell: bash -l {0} 318 | run: | 319 | mgversion=$(./tools/get_memgraph_version.sh) 320 | echo "MGVERSION=$mgversion" >> $GITHUB_ENV 321 | 322 | - name: Install and Run Memgraph in WSL 323 | shell: wsl-bash {0} 324 | run: | 325 | mkdir -p $HOME/memgraph/data 326 | sudo apt update 327 | sudo apt install -y curl 328 | curl -L https://download.memgraph.com/memgraph/v${{ env.MGVERSION }}/ubuntu-24.04/memgraph_${{ env.MGVERSION }}-1_amd64.deb -o memgraph.deb 329 | sudo mkdir -p /etc/systemd/system && sudo ln -s /dev/null /etc/systemd/system/memgraph.service # Prevents Memgraph from starting. 330 | sudo apt install -y ./memgraph.deb 331 | openssl req -x509 -newkey rsa:4096 -days 3650 -nodes -keyout key.pem -out cert.pem -subj "/C=GB/ST=London/L=London/O=Testing Corp./CN=PymgclientTest" 332 | nohup /usr/lib/memgraph/memgraph --bolt-port 7687 --bolt-cert-file="cert.pem" --bolt-key-file="key.pem" --data-directory="~/memgraph/data" --storage-properties-on-edges=true --storage-snapshot-interval-sec=0 --storage-wal-enabled=false --storage-snapshot-on-exit=false --telemetry-enabled=false --log-file='' & 333 | 334 | # sleep here instead of using script because it just doesn't work in Windows 335 | sleep 3 336 | # sed $'s/\r$//' ./tools/wait_for_memgraph.sh > ./tools/wait_for_memgraph.unix.sh 337 | # bash ./tools/wait_for_memgraph.unix.sh localhost 338 | 339 | 340 | - name: Run Tests 341 | shell: msys2 {0} 342 | run: | 343 | export PATH="$(cygpath -u "$pythonLocation"):/mingw64/bin:$PATH" 344 | echo $PATH 345 | python -m pytest -v 346 | 347 | env: 348 | pythonLocation: ${{ env.pythonLocation }} 349 | MEMGRAPH_HOST: localhost 350 | MEMGRAPH_STARTED_WITH_SSL: 351 | 352 | 353 | - name: Upload Wheel Artifact 354 | if: ${{ inputs.upload_artifacts && matrix.os == 'windows-2025' }} 355 | uses: actions/upload-artifact@v4 356 | with: 357 | name: pymgclient-win-${{ matrix.python-version }} 358 | path: dist/ 359 | 360 | 361 | # NOTE: Cannot run tests on Mac OS runners because: 362 | # - GitHub hosted runners don't have docker 363 | # - self-hosted runner unable to log in to docker due to requiring a password to unlock the keychain 364 | # Also - github hosted macos runners after macos-13 are all arm64 365 | build_macos: 366 | if: ${{ inputs.test_macintosh }} 367 | name: Build and test on MacOS 368 | strategy: 369 | fail-fast: false 370 | matrix: 371 | platform: [macos-14, macos-15] 372 | python_version: 373 | - '3.10' 374 | - '3.11' 375 | - '3.12' 376 | - '3.13' 377 | runs-on: ${{ matrix.platform }} 378 | steps: 379 | - uses: actions/checkout@v2 380 | with: 381 | submodules: true 382 | 383 | - name: Set override version if provided 384 | if: ${{ inputs.version != '' }} 385 | run: | 386 | echo "Building version ${{ inputs.version }}" 387 | echo "PYMGCLIENT_OVERRIDE_VERSION=${{ inputs.version }}" >> $GITHUB_ENV 388 | 389 | - name: Install python and OpenSSL 390 | run: | 391 | brew install python@${{ matrix.python_version }} openssl@1.1 392 | brew link --force --overwrite openssl@1.1 393 | openssl version -a 394 | 395 | - name: Manage OpenSSL 3 on ARM machines 396 | if: ${{ contains(matrix.platform, 'ARM64') }} 397 | run: | 398 | brew install openssl@3 399 | brew link --force --overwrite openssl@3 400 | openssl version -a 401 | 402 | - name: Make used python version default 403 | run: | 404 | brew unlink python@3 && brew link --force python@${{ matrix.python_version }} 405 | python${{ matrix.python_version }} --version 406 | 407 | - name: Create Virtual Env 408 | run: | 409 | python${{ matrix.python_version }} -m venv env 410 | 411 | - name: Install pytest and pyopenssl 412 | run: | 413 | export PIP_BREAK_SYSTEM_PACKAGES=1 414 | source env/bin/activate 415 | python${{ matrix.python_version }} -m pip install pyopenssl pytest setuptools 416 | 417 | - name: Build pymgclient 418 | run: | 419 | source env/bin/activate 420 | python${{ matrix.python_version }} setup.py bdist_wheel 421 | 422 | - name: Install pymgclient 423 | run: | 424 | export PIP_BREAK_SYSTEM_PACKAGES=1 425 | source env/bin/activate 426 | python${{ matrix.python_version }} -m pip install dist/* 427 | 428 | - name: Import mgclient to validate installation 429 | run: | 430 | source env/bin/activate 431 | python${{ matrix.python_version }} -c "import mgclient" 432 | 433 | - name: Save wheel package 434 | if: ${{ inputs.upload_artifacts }} 435 | uses: actions/upload-artifact@v4 436 | with: 437 | name: pymgclient-${{ matrix.platform[0] || matrix.platform }}-${{ matrix.python_version }} 438 | path: dist/ 439 | 440 | -------------------------------------------------------------------------------- /src/types.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #include "types.h" 16 | 17 | #include 18 | 19 | PyTypeObject NodeType; 20 | PyTypeObject RelationshipType; 21 | PyTypeObject PathType; 22 | 23 | #define CHECK_ATTRIBUTE(obj, name) \ 24 | do { \ 25 | if (!obj->name) { \ 26 | PyErr_SetString(PyExc_AttributeError, "attribute '" #name "' is NULL"); \ 27 | return NULL; \ 28 | } \ 29 | } while (0) 30 | 31 | static void node_dealloc(NodeObject *node) { 32 | Py_CLEAR(node->labels); 33 | Py_CLEAR(node->properties); 34 | Py_TYPE(node)->tp_free(node); 35 | } 36 | 37 | static PyObject *node_repr(NodeObject *node) { 38 | return PyUnicode_FromFormat("<%s(id=%lld, labels=%R, properties=%R) at %p>", 39 | Py_TYPE(node)->tp_name, node->id, node->labels, 40 | node->properties, node); 41 | } 42 | 43 | static PyObject *node_str(NodeObject *node) { 44 | CHECK_ATTRIBUTE(node, labels); 45 | CHECK_ATTRIBUTE(node, properties); 46 | 47 | if (PySet_Size(node->labels)) { 48 | PyObject *colon = PyUnicode_FromString(":"); 49 | if (!colon) { 50 | return NULL; 51 | } 52 | PyObject *labels = PyUnicode_Join(colon, node->labels); 53 | Py_DECREF(colon); 54 | if (!labels) { 55 | return NULL; 56 | } 57 | PyObject *result = 58 | PyDict_Size(node->properties) 59 | ? PyUnicode_FromFormat("(:%S %S)", labels, node->properties) 60 | : PyUnicode_FromFormat("(:%S)", labels); 61 | Py_DECREF(labels); 62 | return result; 63 | } else { 64 | if (PyDict_Size(node->properties)) { 65 | return PyUnicode_FromFormat("(%S)", node->properties); 66 | } else { 67 | return PyUnicode_FromString("()"); 68 | } 69 | } 70 | } 71 | 72 | // Helper function for implementing richcompare. 73 | static PyObject *node_astuple(NodeObject *node) { 74 | CHECK_ATTRIBUTE(node, labels); 75 | CHECK_ATTRIBUTE(node, properties); 76 | 77 | PyObject *tuple = PyTuple_New(3); 78 | if (!tuple) { 79 | return NULL; 80 | } 81 | 82 | PyObject *id = PyLong_FromLongLong(node->id); 83 | if (!id) { 84 | Py_DECREF(tuple); 85 | return NULL; 86 | } 87 | Py_INCREF(node->labels); 88 | Py_INCREF(node->properties); 89 | 90 | PyTuple_SET_ITEM(tuple, 0, id); 91 | PyTuple_SET_ITEM(tuple, 1, node->labels); 92 | PyTuple_SET_ITEM(tuple, 2, node->properties); 93 | return tuple; 94 | } 95 | 96 | static PyObject *node_richcompare(NodeObject *lhs, PyObject *rhs, int op) { 97 | PyObject *tlhs = NULL; 98 | PyObject *trhs = NULL; 99 | PyObject *ret = NULL; 100 | 101 | if (Py_TYPE(rhs) == &NodeType) { 102 | if (!(tlhs = node_astuple(lhs))) { 103 | goto exit; 104 | } 105 | if (!(trhs = node_astuple((NodeObject *)rhs))) { 106 | goto exit; 107 | } 108 | ret = PyObject_RichCompare(tlhs, trhs, op); 109 | } else { 110 | Py_INCREF(Py_False); 111 | ret = Py_False; 112 | } 113 | 114 | exit: 115 | Py_XDECREF(tlhs); 116 | Py_XDECREF(trhs); 117 | return ret; 118 | } 119 | 120 | int node_init(NodeObject *node, PyObject *args, PyObject *kwargs) { 121 | int64_t id = -1; 122 | PyObject *labels; 123 | PyObject *properties; 124 | 125 | static char *kwlist[] = {"", "", "", NULL}; 126 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "LOO", kwlist, &id, &labels, 127 | &properties)) { 128 | return -1; 129 | } 130 | 131 | if (!PySet_Check(labels)) { 132 | PyErr_SetString(PyExc_TypeError, "__init__ argument 2 must be a set"); 133 | return -1; 134 | } 135 | if (!PyDict_Check(properties)) { 136 | PyErr_SetString(PyExc_TypeError, "__init__ argument 3 must be a dict"); 137 | return -1; 138 | } 139 | 140 | node->id = id; 141 | 142 | PyObject *tmp_labels = node->labels; 143 | Py_INCREF(labels); 144 | node->labels = labels; 145 | Py_XDECREF(tmp_labels); 146 | 147 | PyObject *tmp_properties = node->properties; 148 | Py_INCREF(properties); 149 | node->properties = properties; 150 | Py_XDECREF(tmp_properties); 151 | 152 | return 0; 153 | } 154 | 155 | PyDoc_STRVAR(NodeType_id_doc, 156 | "Unique node identifier (within the scope of its origin graph)."); 157 | 158 | PyDoc_STRVAR(NodeType_labels_doc, "A list of node labels."); 159 | 160 | PyDoc_STRVAR(NodeType_properties_doc, "A dictionary of node properties."); 161 | 162 | static PyMemberDef node_members[] = { 163 | {"id", T_LONGLONG, offsetof(NodeObject, id), READONLY, NodeType_id_doc}, 164 | {"labels", T_OBJECT_EX, offsetof(NodeObject, labels), READONLY, 165 | NodeType_labels_doc}, 166 | {"properties", T_OBJECT_EX, offsetof(NodeObject, properties), READONLY, 167 | NodeType_properties_doc}, 168 | {NULL}}; 169 | 170 | PyDoc_STRVAR(NodeType_doc, 171 | "A node in the graph with optional properties and labels."); 172 | 173 | // clang-format off 174 | PyTypeObject NodeType = { 175 | PyVarObject_HEAD_INIT(NULL, 0) 176 | .tp_name = "mgclient.Node", 177 | .tp_basicsize = sizeof(NodeObject), 178 | .tp_itemsize = 0, 179 | .tp_dealloc = (destructor)node_dealloc, 180 | .tp_repr = (reprfunc)node_repr, 181 | .tp_str = (reprfunc)node_str, 182 | .tp_flags = Py_TPFLAGS_DEFAULT, 183 | .tp_doc = NodeType_doc, 184 | .tp_richcompare = (richcmpfunc)node_richcompare, 185 | .tp_members = node_members, 186 | .tp_init = (initproc)node_init, 187 | .tp_new = PyType_GenericNew 188 | }; 189 | // clang-format on 190 | 191 | static void relationship_dealloc(RelationshipObject *rel) { 192 | Py_CLEAR(rel->type); 193 | Py_CLEAR(rel->properties); 194 | Py_TYPE(rel)->tp_free(rel); 195 | } 196 | 197 | static PyObject *relationship_repr(RelationshipObject *rel) { 198 | return PyUnicode_FromFormat( 199 | "<%s(start_id=%lld, end_id=%lld, type=%R, properties=%R) at %p>", 200 | Py_TYPE(rel)->tp_name, rel->start_id, rel->end_id, rel->type, 201 | rel->properties, rel); 202 | } 203 | 204 | static PyObject *relationship_str(RelationshipObject *rel) { 205 | CHECK_ATTRIBUTE(rel, type); 206 | CHECK_ATTRIBUTE(rel, properties); 207 | 208 | if (PyDict_Size(rel->properties)) { 209 | return PyUnicode_FromFormat("[:%S %S]", rel->type, rel->properties); 210 | } else { 211 | return PyUnicode_FromFormat("[:%S]", rel->type); 212 | } 213 | } 214 | 215 | // Helper function for implementing richcompare. 216 | static PyObject *relationship_astuple(RelationshipObject *rel) { 217 | CHECK_ATTRIBUTE(rel, type); 218 | CHECK_ATTRIBUTE(rel, properties); 219 | 220 | PyObject *id = NULL; 221 | PyObject *start_id = NULL; 222 | PyObject *end_id = NULL; 223 | PyObject *tuple = NULL; 224 | 225 | if (!(id = PyLong_FromLongLong(rel->id))) { 226 | goto cleanup; 227 | } 228 | if (!(start_id = PyLong_FromLongLong(rel->start_id))) { 229 | goto cleanup; 230 | } 231 | if (!(end_id = PyLong_FromLongLong(rel->end_id))) { 232 | goto cleanup; 233 | } 234 | if (!(tuple = PyTuple_New(5))) { 235 | goto cleanup; 236 | } 237 | 238 | PyTuple_SET_ITEM(tuple, 0, id); 239 | PyTuple_SET_ITEM(tuple, 1, start_id); 240 | PyTuple_SET_ITEM(tuple, 2, end_id); 241 | Py_INCREF(rel->type); 242 | PyTuple_SET_ITEM(tuple, 3, rel->type); 243 | Py_INCREF(rel->properties); 244 | PyTuple_SET_ITEM(tuple, 4, rel->properties); 245 | 246 | return tuple; 247 | 248 | cleanup: 249 | Py_XDECREF(id); 250 | Py_XDECREF(start_id); 251 | Py_XDECREF(end_id); 252 | Py_XDECREF(tuple); 253 | return NULL; 254 | } 255 | 256 | static PyObject *relationship_richcompare(RelationshipObject *lhs, 257 | PyObject *rhs, int op) { 258 | PyObject *tlhs = NULL; 259 | PyObject *trhs = NULL; 260 | PyObject *ret = NULL; 261 | 262 | if (Py_TYPE(rhs) == &RelationshipType) { 263 | if (!(tlhs = relationship_astuple(lhs))) { 264 | goto exit; 265 | } 266 | if (!(trhs = relationship_astuple((RelationshipObject *)rhs))) { 267 | goto exit; 268 | } 269 | ret = PyObject_RichCompare(tlhs, trhs, op); 270 | } else { 271 | Py_INCREF(Py_False); 272 | ret = Py_False; 273 | } 274 | 275 | exit: 276 | Py_XDECREF(tlhs); 277 | Py_XDECREF(trhs); 278 | return ret; 279 | } 280 | 281 | int relationship_init(RelationshipObject *rel, PyObject *args, 282 | PyObject *kwargs) { 283 | int64_t id; 284 | int64_t start_id = -1; 285 | int64_t end_id = -1; 286 | PyObject *type; 287 | PyObject *properties; 288 | 289 | static char *kwlist[] = {"", "", "", "", "", NULL}; 290 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "LLLOO", kwlist, &id, 291 | &start_id, &end_id, &type, &properties)) { 292 | return -1; 293 | } 294 | 295 | if (!PyUnicode_Check(type)) { 296 | PyErr_SetString(PyExc_TypeError, "__init__ argument 4 must be a string"); 297 | return -1; 298 | } 299 | if (!PyDict_Check(properties)) { 300 | PyErr_SetString(PyExc_TypeError, "__init__ argument 5 must be a dict"); 301 | return -1; 302 | } 303 | 304 | rel->id = id; 305 | rel->start_id = start_id; 306 | rel->end_id = end_id; 307 | 308 | PyObject *tmp_type = rel->type; 309 | Py_INCREF(type); 310 | rel->type = type; 311 | Py_XDECREF(tmp_type); 312 | 313 | PyObject *tmp_properties = rel->properties; 314 | Py_INCREF(properties); 315 | rel->properties = properties; 316 | Py_XDECREF(tmp_properties); 317 | 318 | return 0; 319 | } 320 | 321 | PyDoc_STRVAR( 322 | RelationshipType_id_doc, 323 | "Unique relationship identifier (within the scope of its origin graph)."); 324 | 325 | PyDoc_STRVAR(RelationshipType_start_id_doc, 326 | "Identifier of relationship start node (or -1 if it was not " 327 | "supplied by the database)."); 328 | 329 | PyDoc_STRVAR(RelationshipType_end_id_doc, 330 | "Identifier of relationship end node (or -1 if it was not " 331 | "supplied by the database)."); 332 | 333 | PyDoc_STRVAR(RelationshipType_type_doc, "Relationship type."); 334 | 335 | PyDoc_STRVAR(RelationshipType_properties_doc, 336 | "A dictionary of relationship properties."); 337 | 338 | static PyMemberDef relationship_members[] = { 339 | {"id", T_LONGLONG, offsetof(RelationshipObject, id), READONLY, 340 | RelationshipType_id_doc}, 341 | {"start_id", T_LONGLONG, offsetof(RelationshipObject, start_id), READONLY, 342 | RelationshipType_start_id_doc}, 343 | {"end_id", T_LONGLONG, offsetof(RelationshipObject, end_id), READONLY, 344 | RelationshipType_end_id_doc}, 345 | {"type", T_OBJECT_EX, offsetof(RelationshipObject, type), READONLY, 346 | RelationshipType_type_doc}, 347 | {"properties", T_OBJECT_EX, offsetof(RelationshipObject, properties), 348 | READONLY, RelationshipType_properties_doc}, 349 | {NULL}}; 350 | 351 | PyDoc_STRVAR( 352 | RelationshipType_doc, 353 | "A directed, typed connection between two nodes with optional properties."); 354 | 355 | // clang-format off 356 | PyTypeObject RelationshipType = { 357 | PyVarObject_HEAD_INIT(NULL, 0) 358 | .tp_name = "mgclient.Relationship", 359 | .tp_basicsize = sizeof(RelationshipObject), 360 | .tp_itemsize = 0, 361 | .tp_dealloc = (destructor)relationship_dealloc, 362 | .tp_repr = (reprfunc)relationship_repr, 363 | .tp_str = (reprfunc)relationship_str, 364 | .tp_flags = Py_TPFLAGS_DEFAULT, 365 | .tp_doc = RelationshipType_doc, 366 | .tp_richcompare = (richcmpfunc)relationship_richcompare, 367 | .tp_members = relationship_members, 368 | .tp_init = (initproc)relationship_init, 369 | .tp_new = PyType_GenericNew 370 | }; 371 | // clang-format on 372 | 373 | static void path_dealloc(PathObject *path) { 374 | Py_CLEAR(path->nodes); 375 | Py_CLEAR(path->relationships); 376 | Py_TYPE(path)->tp_free(path); 377 | } 378 | 379 | static PyObject *path_repr(PathObject *path) { 380 | return PyUnicode_FromFormat("<%s(nodes=%R, relationships=%R) at %p>", 381 | Py_TYPE(path)->tp_name, path->nodes, 382 | path->relationships, path); 383 | } 384 | 385 | static PyObject *path_str(PathObject *path) { 386 | CHECK_ATTRIBUTE(path, nodes); 387 | CHECK_ATTRIBUTE(path, relationships); 388 | 389 | PyObject *result = NULL; 390 | 391 | Py_ssize_t length = PyList_Size(path->relationships); 392 | PyObject *elements = PyList_New(2 * length + 1); 393 | if (!elements) { 394 | goto end; 395 | } 396 | 397 | for (Py_ssize_t i = 0; i <= length; ++i) { 398 | NodeObject *node = (NodeObject *)PyList_GetItem(path->nodes, i); 399 | if (!node) { 400 | goto end; 401 | } 402 | PyObject *node_s = node_str(node); 403 | if (!node_s) { 404 | goto end; 405 | } 406 | PyList_SET_ITEM(elements, 2 * i, node_s); 407 | if (i < length) { 408 | RelationshipObject *rel = 409 | (RelationshipObject *)PyList_GetItem(path->relationships, i); 410 | PyObject *rel_s; 411 | if (rel->start_id == node->id) { 412 | rel_s = PyUnicode_FromFormat("-%S->", rel); 413 | } else { 414 | rel_s = PyUnicode_FromFormat("<-%S-", rel); 415 | } 416 | if (!rel_s) { 417 | goto end; 418 | } 419 | PyList_SET_ITEM(elements, 2 * i + 1, rel_s); 420 | } 421 | } 422 | 423 | PyObject *sep = PyUnicode_FromString(""); 424 | if (!sep) { 425 | goto end; 426 | } 427 | result = PyUnicode_Join(sep, elements); 428 | Py_DECREF(sep); 429 | 430 | end: 431 | Py_XDECREF(elements); 432 | return result; 433 | } 434 | 435 | // Helper function for implementing richcompare. 436 | static PyObject *path_astuple(PathObject *path) { 437 | CHECK_ATTRIBUTE(path, nodes); 438 | CHECK_ATTRIBUTE(path, relationships); 439 | 440 | PyObject *tuple = PyTuple_New(2); 441 | if (!tuple) { 442 | return NULL; 443 | } 444 | Py_INCREF(path->nodes); 445 | Py_INCREF(path->relationships); 446 | PyTuple_SET_ITEM(tuple, 0, path->nodes); 447 | PyTuple_SET_ITEM(tuple, 1, path->relationships); 448 | return tuple; 449 | } 450 | 451 | static PyObject *path_richcompare(PathObject *lhs, PathObject *rhs, int op) { 452 | PyObject *tlhs = NULL; 453 | PyObject *trhs = NULL; 454 | PyObject *ret = NULL; 455 | if (Py_TYPE(rhs) == &PathType) { 456 | if (!(tlhs = path_astuple(lhs))) { 457 | goto exit; 458 | } 459 | if (!(trhs = path_astuple((PathObject *)rhs))) { 460 | goto exit; 461 | } 462 | ret = PyObject_RichCompare(tlhs, trhs, op); 463 | 464 | } else { 465 | Py_INCREF(Py_False); 466 | ret = Py_False; 467 | } 468 | 469 | exit: 470 | Py_XDECREF(tlhs); 471 | Py_XDECREF(trhs); 472 | return ret; 473 | } 474 | 475 | static int check_types_in_list(PyObject *list, PyTypeObject *expected_type, 476 | const char *function_name, int arg_index) { 477 | int ok = 1; 478 | if (PyList_Check(list)) { 479 | PyObject *iter = PyObject_GetIter(list); 480 | if (!iter) { 481 | return -1; 482 | } 483 | PyObject *elem; 484 | while ((elem = PyIter_Next(iter))) { 485 | PyTypeObject *t = Py_TYPE(elem); 486 | Py_DECREF(elem); 487 | if (t != expected_type) { 488 | ok = 0; 489 | break; 490 | } 491 | } 492 | if (PyErr_Occurred()) { 493 | return -1; 494 | } 495 | } else { 496 | ok = 0; 497 | } 498 | if (!ok) { 499 | PyErr_Format(PyExc_TypeError, "%s argument %d must be a list of '%s'", 500 | function_name, arg_index, expected_type->tp_name); 501 | return -1; 502 | } 503 | return 0; 504 | } 505 | 506 | static int path_init(PathObject *path, PyObject *args, PyObject *kwargs) { 507 | PyObject *nodes; 508 | PyObject *relationships; 509 | 510 | static char *kwlist[] = {"", "", NULL}; 511 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO", kwlist, &nodes, 512 | &relationships)) { 513 | return -1; 514 | } 515 | 516 | if (check_types_in_list(nodes, &NodeType, "__init__", 1) < 0 || 517 | check_types_in_list(relationships, &RelationshipType, "__init__", 2) < 518 | 0) { 519 | return -1; 520 | } 521 | 522 | PyObject *tmp_nodes = path->nodes; 523 | Py_INCREF(nodes); 524 | path->nodes = nodes; 525 | Py_XDECREF(tmp_nodes); 526 | 527 | PyObject *tmp_relationships = path->relationships; 528 | Py_INCREF(relationships); 529 | path->relationships = relationships; 530 | Py_XDECREF(tmp_relationships); 531 | 532 | return 0; 533 | } 534 | 535 | // clang-format off 536 | PyDoc_STRVAR(PathType_nodes_doc, 537 | "A list of nodes in the order they appear in the path. It has one element\n\ 538 | more than the :attr:`relationships` list."); 539 | 540 | PyDoc_STRVAR(PathType_relationships_doc, 541 | "A list of relationships in the order they appear in the path. It has one\n\ 542 | element less than the :attr:`nodes` list."); 543 | // clang-format on 544 | 545 | static PyMemberDef path_members[] = { 546 | {"nodes", T_OBJECT_EX, offsetof(PathObject, nodes), READONLY, 547 | PathType_nodes_doc}, 548 | {"relationships", T_OBJECT_EX, offsetof(PathObject, relationships), 549 | READONLY, PathType_relationships_doc}, 550 | {NULL}}; 551 | 552 | // clang-format off 553 | PyDoc_STRVAR(PathType_doc, 554 | "A sequence of alternating nodes and relationships corresponding to a walk\n\ 555 | in the graph."); 556 | // clang-format on 557 | 558 | // clang-format off 559 | PyTypeObject PathType = { 560 | PyVarObject_HEAD_INIT(NULL, 0) 561 | .tp_name = "mgclient.Path", 562 | .tp_basicsize = sizeof(PathObject), 563 | .tp_itemsize = 0, 564 | .tp_dealloc = (destructor)path_dealloc, 565 | .tp_repr = (reprfunc)path_repr, 566 | .tp_str = (reprfunc)path_str, 567 | .tp_flags = Py_TPFLAGS_DEFAULT, 568 | .tp_doc = PathType_doc, 569 | .tp_richcompare = (richcmpfunc)path_richcompare, 570 | .tp_members = path_members, 571 | .tp_init = (initproc)path_init, 572 | .tp_new = PyType_GenericNew 573 | }; 574 | 575 | #undef CHECK_ATTRIBUTE 576 | // clang-format on 577 | -------------------------------------------------------------------------------- /src/cursor.c: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | #include "cursor.h" 16 | 17 | #include 18 | 19 | #include "column.h" 20 | #include "connection.h" 21 | #include "exceptions.h" 22 | 23 | static void cursor_dealloc(CursorObject *cursor) { 24 | Py_CLEAR(cursor->conn); 25 | Py_CLEAR(cursor->rows); 26 | Py_CLEAR(cursor->description); 27 | Py_TYPE(cursor)->tp_free(cursor); 28 | } 29 | 30 | int cursor_init(CursorObject *cursor, PyObject *args, PyObject *kwargs) { 31 | ConnectionObject *conn = NULL; 32 | 33 | static char *kwlist[] = {"", NULL}; 34 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O", kwlist, &conn)) { 35 | return -1; 36 | } 37 | 38 | if (Py_TYPE(conn) != &ConnectionType) { 39 | PyErr_Format(PyExc_TypeError, "__init__ argument 1 must be of type '%s'", 40 | ConnectionType.tp_name); 41 | return -1; 42 | } 43 | 44 | Py_INCREF(conn); 45 | cursor->conn = conn; 46 | 47 | cursor->status = CURSOR_STATUS_READY; 48 | cursor->hasresults = 0; 49 | cursor->arraysize = 1; 50 | cursor->rows = NULL; 51 | cursor->description = NULL; 52 | return 0; 53 | } 54 | 55 | PyObject *cursor_new(PyTypeObject *subtype, PyObject *args, PyObject *kwargs) { 56 | // Unused args. 57 | (void)args; 58 | (void)kwargs; 59 | 60 | PyObject *cursor = subtype->tp_alloc(subtype, 0); 61 | if (!cursor) { 62 | return NULL; 63 | } 64 | ((CursorObject *)cursor)->status = CURSOR_STATUS_CLOSED; 65 | return cursor; 66 | } 67 | 68 | // Reset the cursor into state where it can be used for executing a new query, 69 | // but caling fetch* throws an exception. 70 | static void cursor_reset(CursorObject *cursor) { 71 | Py_CLEAR(cursor->rows); 72 | Py_CLEAR(cursor->description); 73 | cursor->hasresults = 0; 74 | cursor->rowcount = -1; 75 | cursor->status = CURSOR_STATUS_READY; 76 | } 77 | 78 | // clang-format off 79 | PyDoc_STRVAR(cursor_close_doc, 80 | "close()\n\ 81 | --\n\ 82 | \n\ 83 | Close the cursor now.\n\ 84 | \n\ 85 | The cursor will be unusable from this point forward; an :exc:`InterfaceError`\n\ 86 | will be raised if any operation is attempted with the cursor."); 87 | // clang-format on 88 | 89 | PyObject *cursor_close(CursorObject *cursor, PyObject *args) { 90 | // Unused args; 91 | (void)args; 92 | 93 | assert(!args); 94 | if (cursor->status == CURSOR_STATUS_EXECUTING) { 95 | assert(cursor->conn->status == CONN_STATUS_EXECUTING); 96 | assert(cursor->conn->lazy); 97 | 98 | // Cannot close cursor while executing a query because query execution might 99 | // raise an error. 100 | PyErr_SetString(InterfaceError, 101 | "cannot close cursor during execution of a query"); 102 | return NULL; 103 | } 104 | 105 | Py_CLEAR(cursor->conn); 106 | cursor_reset(cursor); 107 | cursor->status = CURSOR_STATUS_CLOSED; 108 | 109 | Py_RETURN_NONE; 110 | } 111 | 112 | static int cursor_set_description(CursorObject *cursor, PyObject *columns) { 113 | assert(PyList_Check(columns)); 114 | assert(cursor->description == NULL); 115 | if (!columns) { 116 | return 0; 117 | } 118 | PyObject *description = NULL; 119 | if (!(description = PyList_New(PyList_Size(columns)))) { 120 | goto failure; 121 | } 122 | for (Py_ssize_t i = 0; i < PyList_Size(columns); ++i) { 123 | PyObject *entry = PyObject_CallFunctionObjArgs( 124 | (PyObject *)&ColumnType, PyList_GetItem(columns, i), NULL); 125 | if (!entry) { 126 | goto failure; 127 | } 128 | PyList_SET_ITEM(description, i, entry); 129 | } 130 | 131 | cursor->description = description; 132 | return 0; 133 | 134 | failure: 135 | if (PyErr_WarnEx(Warning, "failed to obtain result column names", 2) < 0) { 136 | return -1; 137 | } 138 | Py_XDECREF(description); 139 | return 0; 140 | } 141 | 142 | // clang-format off 143 | PyDoc_STRVAR(cursor_execute_doc, 144 | "execute(query, params=None)\n\ 145 | --\n\ 146 | \n\ 147 | Execute a database operation.\n\ 148 | \n\ 149 | Parameters may be provided as a mapping and will be bound to variables in\n\ 150 | the operation. Variables are specified with named (``$name``)\n\ 151 | placeholders.\n\ 152 | \n\ 153 | This method always returns ``None``.\n"); 154 | // clang-format on 155 | 156 | PyObject *cursor_execute(CursorObject *cursor, PyObject *args) { 157 | const char *query = NULL; 158 | PyObject *pyparams = NULL; 159 | if (!PyArg_ParseTuple(args, "s|O", &query, &pyparams)) { 160 | return NULL; 161 | } 162 | 163 | if (cursor->status == CURSOR_STATUS_CLOSED) { 164 | PyErr_SetString(InterfaceError, "cursor closed"); 165 | return NULL; 166 | } 167 | 168 | if (connection_raise_if_bad_status(cursor->conn) < 0) { 169 | return NULL; 170 | } 171 | 172 | if (cursor->conn->status == CONN_STATUS_EXECUTING) { 173 | assert(cursor->conn->lazy); 174 | PyErr_SetString(InterfaceError, 175 | "cannot call execute during execution of a query"); 176 | return NULL; 177 | } 178 | 179 | assert(cursor->status == CURSOR_STATUS_READY); 180 | 181 | cursor_reset(cursor); 182 | 183 | if (!cursor->conn->autocommit && cursor->conn->status == CONN_STATUS_READY) { 184 | if (connection_begin(cursor->conn) < 0) { 185 | goto cleanup; 186 | } 187 | } 188 | 189 | PyObject *columns; 190 | if (connection_run(cursor->conn, query, pyparams, &columns) < 0) { 191 | goto cleanup; 192 | } 193 | 194 | if (cursor_set_description(cursor, columns) < 0) { 195 | Py_XDECREF(columns); 196 | goto cleanup; 197 | } 198 | Py_XDECREF(columns); 199 | 200 | // In lazy mode, results are pulled when fetch is called. 201 | if (cursor->conn->lazy) { 202 | cursor->status = CURSOR_STATUS_EXECUTING; 203 | cursor->hasresults = 1; 204 | cursor->rowcount = -1; 205 | Py_RETURN_NONE; 206 | } 207 | 208 | // Pull all results now. 209 | if (!(cursor->rows = PyList_New(0))) { 210 | goto discard_all; 211 | } 212 | 213 | int status; 214 | status = connection_pull(cursor->conn, 0); // PULL_ALL 215 | if (status != 0) { 216 | goto cleanup; 217 | } 218 | 219 | PyObject *row; 220 | while ((status = connection_fetch(cursor->conn, &row, NULL)) == 1) { 221 | int append_result = PyList_Append(cursor->rows, row); 222 | Py_DECREF(row); 223 | if (append_result < 0) { 224 | goto discard_all; 225 | } 226 | } 227 | if (status < 0) { 228 | goto cleanup; 229 | } 230 | 231 | cursor->hasresults = 1; 232 | cursor->rowindex = 0; 233 | cursor->rowcount = PyList_Size(cursor->rows); 234 | Py_RETURN_NONE; 235 | 236 | discard_all: 237 | connection_discard_all(cursor->conn); 238 | 239 | cleanup: 240 | cursor_reset(cursor); 241 | return NULL; 242 | } 243 | 244 | // clang-format off 245 | PyDoc_STRVAR(cursor_fetchone_doc, 246 | "fetchone()\n\ 247 | --\n\ 248 | \n\ 249 | Fetch the next row of query results, returning a single tuple, or ``None``\n\ 250 | when no more data is available.\n\ 251 | \n\ 252 | An :exc:`InterfaceError` is raised if the previous call to :meth:`.execute()`\n\ 253 | did not produce any results or no call was issued yet."); 254 | // clang-format on 255 | 256 | PyObject *cursor_fetchone(CursorObject *cursor, PyObject *args) { 257 | // Unused args. 258 | (void)args; 259 | 260 | assert(!args); 261 | 262 | if (!cursor->hasresults) { 263 | PyErr_SetString(InterfaceError, "no results available"); 264 | return NULL; 265 | } 266 | 267 | if (cursor->conn->lazy) { 268 | if (cursor->status == CURSOR_STATUS_READY) { 269 | // All rows are pulled so we have to return None. 270 | Py_RETURN_NONE; 271 | } 272 | 273 | if (cursor->status == CURSOR_STATUS_EXECUTING) { 274 | int pull_status = connection_pull(cursor->conn, 1); 275 | if (pull_status != 0) { 276 | cursor_reset(cursor); 277 | return NULL; 278 | } 279 | } 280 | 281 | PyObject *row = NULL; 282 | // fetchone returns an exact result, this method can't be called twice for 283 | // a single pull call. If called twice for one pull call the second call 284 | // has to return something which is not correct form the cursor interface 285 | // point of view. 286 | // 287 | // The problem is also if the second fetch call ends up with a database 288 | // error, from the user perspective that will look like an error related to 289 | // the first pull. 290 | int has_more_first = 0; 291 | int has_more_second = 0; 292 | int fetch_status_first = 293 | connection_fetch(cursor->conn, &row, &has_more_first); 294 | int fetch_status_second = 0; 295 | if (fetch_status_first == 1) { 296 | fetch_status_second = 297 | connection_fetch(cursor->conn, NULL, &has_more_second); 298 | } 299 | if (fetch_status_first == -1 || fetch_status_second == -1) { 300 | Py_XDECREF(row); 301 | cursor_reset(cursor); 302 | return NULL; 303 | } else if (fetch_status_first == 0) { 304 | Py_XDECREF(row); 305 | if (has_more_first) { 306 | cursor->status = CURSOR_STATUS_EXECUTING; 307 | } else { 308 | cursor->status = CURSOR_STATUS_READY; 309 | } 310 | Py_RETURN_NONE; 311 | } else if (fetch_status_first == 1) { 312 | if (has_more_second) { 313 | cursor->status = CURSOR_STATUS_EXECUTING; 314 | } else { 315 | cursor->status = CURSOR_STATUS_READY; 316 | } 317 | return row; 318 | } else { 319 | // This should never happen, if it happens, some case is not covered. 320 | assert(0); 321 | } 322 | } 323 | 324 | assert(cursor->rowcount >= 0); 325 | if (cursor->rowindex < cursor->rowcount) { 326 | PyObject *row = PyList_GET_ITEM(cursor->rows, cursor->rowindex++); 327 | Py_INCREF(row); 328 | return row; 329 | } 330 | 331 | Py_RETURN_NONE; 332 | } 333 | 334 | // clang-format off 335 | PyDoc_STRVAR( 336 | cursor_fetchmany_doc, 337 | "fetchmany(size=None)\n\ 338 | --\n\ 339 | \n\ 340 | Fetch the next set of rows of query results, returning a list of tuples.\n\ 341 | An empty list is returned when no more data is available.\n\ 342 | \n\ 343 | The number of rows to fetch per call is specified by the parameter. If it\n\ 344 | is not given the cursor's :attr:`arraysize` determines the number of rows\n\ 345 | to be fetched. Fewer rows may be returned in case there is less rows available\n\ 346 | than requested.\n\ 347 | \n\ 348 | An :exc:`InterfaceError` is raised if the previous call to :meth:`.execute()`\n\ 349 | did not produce any results or no call was issued yet."); 350 | // clang-format on 351 | 352 | PyObject *cursor_fetchmany(CursorObject *cursor, PyObject *args, 353 | PyObject *kwargs) { 354 | // TODO(gitbuda): Implement fetchmany by pulling the exact number of records. 355 | static char *kwlist[] = {"size", NULL}; 356 | PyObject *pysize = NULL; 357 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|O", kwlist, &pysize)) { 358 | return NULL; 359 | } 360 | 361 | if (!cursor->hasresults) { 362 | PyErr_SetString(InterfaceError, "no results available"); 363 | return NULL; 364 | } 365 | 366 | long size = cursor->arraysize; 367 | 368 | if (pysize && pysize != Py_None) { 369 | size = PyLong_AsLong(pysize); 370 | if (PyErr_Occurred()) { 371 | return NULL; 372 | } 373 | } 374 | 375 | if (cursor->conn->lazy) { 376 | PyObject *results; 377 | if (!(results = PyList_New(0))) { 378 | return NULL; 379 | } 380 | for (long i = 0; i < size; ++i) { 381 | PyObject *row; 382 | if (!(row = cursor_fetchone(cursor, NULL))) { 383 | Py_DECREF(results); 384 | return NULL; 385 | } 386 | if (row == Py_None) { 387 | break; 388 | } 389 | int append_result = PyList_Append(results, row); 390 | Py_DECREF(row); 391 | if (append_result < 0) { 392 | Py_DECREF(results); 393 | connection_discard_all(cursor->conn); 394 | cursor_reset(cursor); 395 | return NULL; 396 | } 397 | } 398 | return results; 399 | } 400 | 401 | assert(cursor->rowcount >= 0); 402 | 403 | PyObject *rows; 404 | Py_ssize_t new_rowindex = cursor->rowindex + size; 405 | new_rowindex = 406 | new_rowindex < cursor->rowcount ? new_rowindex : cursor->rowcount; 407 | if (!(rows = PyList_GetSlice(cursor->rows, cursor->rowindex, new_rowindex))) { 408 | return NULL; 409 | } 410 | cursor->rowindex = new_rowindex; 411 | return rows; 412 | } 413 | 414 | // clang-format off 415 | PyDoc_STRVAR(cursor_fetchall_doc, 416 | "fetchall()\n\ 417 | --\n\ 418 | \n\ 419 | Fetch all (remaining) rows of query results, returning them as a list of\n\ 420 | tuples.\n\ 421 | \n\ 422 | An :exc:`InterfaceError` is raised if the previous call to :meth:`.execute()`\n\ 423 | did not produce any results or no call was issued yet."); 424 | // clang-format on 425 | 426 | PyObject *cursor_fetchall(CursorObject *cursor, PyObject *args) { 427 | // Unused args. 428 | (void)args; 429 | 430 | assert(!args); 431 | 432 | if (!cursor->hasresults) { 433 | PyErr_SetString(InterfaceError, "no results available"); 434 | return NULL; 435 | } 436 | 437 | if (cursor->conn->lazy) { 438 | PyObject *results; 439 | if (!(results = PyList_New(0))) { 440 | return NULL; 441 | } 442 | 443 | if (cursor->status == CURSOR_STATUS_READY) { 444 | return results; 445 | } 446 | 447 | if (cursor->status == CURSOR_STATUS_EXECUTING) { 448 | int pull_status = 0; 449 | pull_status = connection_pull(cursor->conn, 0); 450 | if (pull_status != 0) { 451 | Py_DECREF(results); 452 | cursor_reset(cursor); 453 | return NULL; 454 | } 455 | } 456 | 457 | while (1) { 458 | PyObject *row = NULL; 459 | int fetch_status = connection_fetch(cursor->conn, &row, NULL); 460 | if (fetch_status == 0) { 461 | cursor->status = CURSOR_STATUS_READY; 462 | break; 463 | } else if (fetch_status == 1) { 464 | int append_result = PyList_Append(results, row); 465 | Py_DECREF(row); 466 | if (append_result < 0) { 467 | Py_DECREF(results); 468 | connection_discard_all(cursor->conn); 469 | cursor_reset(cursor); 470 | return NULL; 471 | } 472 | } else { 473 | Py_DECREF(results); 474 | cursor_reset(cursor); 475 | return NULL; 476 | } 477 | if (!row) { 478 | Py_DECREF(results); 479 | return NULL; 480 | } 481 | } 482 | 483 | return results; 484 | } 485 | 486 | assert(cursor->rowcount >= 0); 487 | 488 | PyObject *rows; 489 | if (!(rows = PyList_GetSlice(cursor->rows, cursor->rowindex, 490 | cursor->rowcount))) { 491 | return NULL; 492 | } 493 | cursor->rowindex = cursor->rowcount; 494 | return rows; 495 | } 496 | 497 | PyDoc_STRVAR( 498 | cursor_setinputsizes_doc, 499 | "This method does nothing, but it is required by the DB-API 2.0 spec."); 500 | 501 | PyObject *cursor_setinputsizes(CursorObject *cursor, PyObject *args) { 502 | PyObject *sizes; 503 | if (!PyArg_ParseTuple(args, "O", &sizes)) { 504 | return NULL; 505 | } 506 | if (cursor->status == CURSOR_STATUS_CLOSED) { 507 | PyErr_SetString(InterfaceError, "cursor closed"); 508 | return NULL; 509 | } 510 | Py_RETURN_NONE; 511 | } 512 | 513 | PyDoc_STRVAR( 514 | cursor_setoutputsizes_doc, 515 | "This method does nothing, but it is required by the DB-API 2.0 spec."); 516 | 517 | PyObject *cursor_setoutputsizes(CursorObject *cursor, PyObject *args) { 518 | long size; 519 | long column; 520 | if (!PyArg_ParseTuple(args, "l|l", &size, &column)) { 521 | return NULL; 522 | } 523 | if (cursor->status == CURSOR_STATUS_CLOSED) { 524 | PyErr_SetString(InterfaceError, "cursor closed"); 525 | return NULL; 526 | } 527 | Py_RETURN_NONE; 528 | } 529 | 530 | static PyMethodDef cursor_methods[] = { 531 | {"close", (PyCFunction)cursor_close, METH_NOARGS, cursor_close_doc}, 532 | {"execute", (PyCFunction)cursor_execute, METH_VARARGS, cursor_execute_doc}, 533 | {"fetchone", (PyCFunction)cursor_fetchone, METH_NOARGS, 534 | cursor_fetchone_doc}, 535 | {"fetchmany", (PyCFunction)cursor_fetchmany, METH_VARARGS | METH_KEYWORDS, 536 | cursor_fetchmany_doc}, 537 | {"fetchall", (PyCFunction)cursor_fetchall, METH_NOARGS, 538 | cursor_fetchall_doc}, 539 | {"setinputsizes", (PyCFunction)cursor_setinputsizes, METH_VARARGS, 540 | cursor_setinputsizes_doc}, 541 | {"setoutputsizes", (PyCFunction)cursor_setoutputsizes, METH_VARARGS, 542 | cursor_setoutputsizes_doc}, 543 | {NULL}}; 544 | 545 | // clang-format off 546 | PyDoc_STRVAR(CursorType_rowcount_doc, 547 | "This read-only attribute specifies the number of rows that the last\n\ 548 | :meth:`.execute()` produced.\n\ 549 | \n\ 550 | The attribute is -1 in case no :meth:`.execute()` has been performed or\n\ 551 | the rowcount of the last operation cannot be determined by the interface."); 552 | 553 | PyDoc_STRVAR(CursorType_arraysize_doc, 554 | "This read/write attribute specifies the number of rows to fetch at a time\n\ 555 | with :meth:`.fetchmany()`. It defaults to 1 meaning to fetch a single row at\n\ 556 | a time."); 557 | 558 | PyDoc_STRVAR(CursorType_description_doc, 559 | "This read-only attribute is a list of :class:`Column` objects.\n\ 560 | \n\ 561 | Each of those object has attributed describing one result column:\n\ 562 | \n\ 563 | - :attr:`.name`\n\ 564 | - :attr:`.type_code`\n\ 565 | - :attr:`.display_size`\n\ 566 | - :attr:`.internal_size`\n\ 567 | - :attr:`.precision`\n\ 568 | - :attr:`.scale`\n\ 569 | - :attr:`.null_ok`\n\ 570 | \n\ 571 | Only the name attribute is set to the name of column returned by the\n\ 572 | database. The rest are always set to ``None`` and are only here for\n\ 573 | compatibility with DB-API 2.0.\n\ 574 | \n\ 575 | This attribute will be ``None`` for operations that do not return rows\n\ 576 | or if the cursor has not had an operation invoked via the :meth:`.execute()`\n\ 577 | method yet."); 578 | // clang-format on 579 | 580 | static PyMemberDef cursor_members[] = { 581 | {"rowcount", T_PYSSIZET, offsetof(CursorObject, rowcount), READONLY, 582 | CursorType_rowcount_doc}, 583 | {"arraysize", T_LONG, offsetof(CursorObject, arraysize), 0, 584 | CursorType_arraysize_doc}, 585 | {"description", T_OBJECT, offsetof(CursorObject, description), READONLY, 586 | CursorType_description_doc}, 587 | {NULL}}; 588 | 589 | // clang-format off 590 | PyDoc_STRVAR(cursor_doc, 591 | "Allows execution of database commands.\n\ 592 | \n\ 593 | Cursors are created by the :meth:`Connection.cursor()` method and they are\n\ 594 | bound to the connection for the entire lifetime. Cursors created by the same\n\ 595 | connection are not isolated, any changes done to the database by one cursor\n\ 596 | are immediately visible by the other cursors.\n\ 597 | \n\ 598 | Cursor objects are not thread-safe."); 599 | // clang-format on 600 | 601 | // clang-format off 602 | PyTypeObject CursorType = { 603 | PyVarObject_HEAD_INIT(NULL, 0) 604 | .tp_name = "mgclient.Cursor", 605 | .tp_basicsize = sizeof(CursorObject), 606 | .tp_itemsize = 0, 607 | .tp_dealloc = (destructor)cursor_dealloc, 608 | .tp_flags = Py_TPFLAGS_DEFAULT, 609 | .tp_doc = cursor_doc, 610 | .tp_methods = cursor_methods, 611 | .tp_members = cursor_members, 612 | .tp_init = (initproc)cursor_init, 613 | .tp_new = (newfunc)cursor_new 614 | }; 615 | // clang-format on 616 | -------------------------------------------------------------------------------- /test/test_cursor.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2020 Memgraph Ltd. [https://memgraph.com] 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import sys 16 | import mgclient 17 | import pytest 18 | 19 | from common import start_memgraph, Memgraph 20 | 21 | 22 | @pytest.fixture(scope="function") 23 | def memgraph_server(): 24 | memgraph = start_memgraph() 25 | yield memgraph.host, memgraph.port, memgraph.sslmode(), memgraph.is_long_running 26 | 27 | memgraph.terminate() 28 | 29 | 30 | def test_cursor_visibility(memgraph_server): 31 | host, port, sslmode, is_long_running = memgraph_server 32 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 33 | 34 | cursor1 = conn.cursor() 35 | cursor1.execute("MATCH (n) RETURN count(n)") 36 | original_count = cursor1.fetchall()[0][0] 37 | assert is_long_running or original_count == 0 38 | 39 | cursor1.execute("CREATE (:Node)") 40 | 41 | cursor2 = conn.cursor() 42 | cursor2.execute("MATCH (n) RETURN count(n)") 43 | assert cursor2.fetchall() == [(original_count + 1,)] 44 | 45 | 46 | class TestCursorInRegularConnection: 47 | def test_execute_closed_connection(self, memgraph_server): 48 | host, port, sslmode, _ = memgraph_server 49 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 50 | 51 | cursor = conn.cursor() 52 | conn.close() 53 | 54 | with pytest.raises(mgclient.InterfaceError): 55 | cursor.execute("RETURN 100") 56 | 57 | def test_cursor_close(self, memgraph_server): 58 | host, port, sslmode, _ = memgraph_server 59 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 60 | 61 | cursor = conn.cursor() 62 | cursor.execute("UNWIND range(1, 10) AS n RETURN n") 63 | 64 | cursor.close() 65 | 66 | # closing again does nothing 67 | cursor.close() 68 | 69 | with pytest.raises(mgclient.InterfaceError): 70 | cursor.fetchone() 71 | 72 | with pytest.raises(mgclient.InterfaceError): 73 | cursor.execute("RETURN 100") 74 | 75 | with pytest.raises(mgclient.InterfaceError): 76 | cursor.fetchmany() 77 | 78 | with pytest.raises(mgclient.InterfaceError): 79 | cursor.fetchall() 80 | 81 | with pytest.raises(mgclient.InterfaceError): 82 | cursor.setinputsizes([]) 83 | 84 | with pytest.raises(mgclient.InterfaceError): 85 | cursor.setoutputsizes(100) 86 | 87 | def test_cursor_fetchone(self, memgraph_server): 88 | host, port, sslmode, _ = memgraph_server 89 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 90 | 91 | cursor = conn.cursor() 92 | 93 | with pytest.raises(mgclient.InterfaceError): 94 | cursor.fetchone() 95 | 96 | cursor.execute("UNWIND range(1, 10) AS n RETURN n") 97 | 98 | for n in range(1, 11): 99 | assert cursor.fetchone() == (n,) 100 | 101 | assert cursor.fetchone() is None 102 | assert cursor.fetchone() is None 103 | 104 | cursor.execute("RETURN 100") 105 | assert cursor.fetchone() == (100,) 106 | assert cursor.fetchone() is None 107 | 108 | def test_cursor_fetchmany(self, memgraph_server): 109 | host, port, sslmode, _ = memgraph_server 110 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 111 | 112 | cursor = conn.cursor() 113 | 114 | with pytest.raises(mgclient.InterfaceError): 115 | cursor.fetchmany() 116 | 117 | cursor.execute("UNWIND range(1, 10) AS n RETURN n") 118 | 119 | with pytest.raises(OverflowError): 120 | cursor.fetchmany(10 ** 100) 121 | 122 | assert cursor.fetchmany() == [(1,)] 123 | 124 | cursor.arraysize = 4 125 | 126 | assert cursor.fetchmany() == [(2,), (3,), (4,), (5,)] 127 | assert cursor.fetchmany() == [(6,), (7,), (8,), (9,)] 128 | assert cursor.fetchmany() == [(10,)] 129 | assert cursor.fetchmany() == [] 130 | assert cursor.fetchone() is None 131 | 132 | cursor.execute("RETURN 100") 133 | assert cursor.fetchmany() == [(100,)] 134 | assert cursor.fetchmany() == [] 135 | assert cursor.fetchone() is None 136 | 137 | def test_cursor_fetchall(self, memgraph_server): 138 | host, port, sslmode, _ = memgraph_server 139 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 140 | 141 | cursor = conn.cursor() 142 | 143 | with pytest.raises(mgclient.InterfaceError): 144 | cursor.fetchall() 145 | 146 | cursor.execute("UNWIND range(1, 10) AS n RETURN n") 147 | 148 | assert cursor.fetchall() == [(n,) for n in range(1, 11)] 149 | assert cursor.fetchall() == [] 150 | assert cursor.fetchone() is None 151 | 152 | cursor.execute("RETURN 100") 153 | 154 | assert cursor.fetchall() == [(100,)] 155 | assert cursor.fetchall() == [] 156 | assert cursor.fetchone() is None 157 | 158 | def test_cursor_multiple_queries(self, memgraph_server): 159 | host, port, sslmode, _ = memgraph_server 160 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 161 | 162 | cursor1 = conn.cursor() 163 | cursor2 = conn.cursor() 164 | 165 | cursor1.execute("UNWIND range(1, 10) AS n RETURN n") 166 | cursor2.execute("UNWIND range(1, 10) AS n RETURN n") 167 | 168 | for n in range(1, 11): 169 | assert cursor1.fetchone() == (n,) 170 | assert cursor2.fetchone() == (n,) 171 | 172 | def test_cursor_syntax_error(self, memgraph_server): 173 | host, port, sslmode, _ = memgraph_server 174 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 175 | cursor = conn.cursor() 176 | 177 | cursor.execute("RETURN 100") 178 | 179 | with pytest.raises(mgclient.DatabaseError): 180 | cursor.execute("fjdkalfjdsalfaj") 181 | 182 | with pytest.raises(mgclient.InterfaceError): 183 | cursor.fetchall() 184 | 185 | def test_cursor_runtime_error(self, memgraph_server): 186 | host, port, sslmode, _ = memgraph_server 187 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 188 | cursor = conn.cursor() 189 | 190 | cursor.execute("RETURN 100") 191 | 192 | with pytest.raises(mgclient.DatabaseError): 193 | cursor.execute("UNWIND [true, true, false] AS p RETURN assert(p)") 194 | cursor.fetchall() 195 | 196 | cursor.execute("RETURN 200") 197 | 198 | assert cursor.fetchall() == [(200,)] 199 | 200 | def test_cursor_description(self, memgraph_server): 201 | host, port, sslmode, _ = memgraph_server 202 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 203 | cursor = conn.cursor() 204 | 205 | cursor.execute("RETURN 5 AS x, 6 AS y") 206 | assert len(cursor.description) == 2 207 | assert cursor.description[0].name == "x" 208 | assert cursor.description[1].name == "y" 209 | 210 | with pytest.raises(mgclient.DatabaseError): 211 | cursor.execute("jdfklfjkdalfja") 212 | 213 | assert cursor.description is None 214 | 215 | def test_cursor_fetchone_without_result(self, memgraph_server): 216 | host, port, sslmode, _ = memgraph_server 217 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 218 | cursor = conn.cursor() 219 | 220 | cursor.execute("MATCH (n:NonExistingLabel) RETURN n") 221 | result = cursor.fetchone() 222 | assert result is None 223 | 224 | def test_cursor_fetchmany_without_result(self, memgraph_server): 225 | host, port, sslmode, _ = memgraph_server 226 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 227 | cursor = conn.cursor() 228 | 229 | cursor.execute("MATCH (n:NonExistingLabel) RETURN n") 230 | assert cursor.fetchmany() == [] 231 | 232 | def test_cursor_result_ref_counts(self, memgraph_server): 233 | host, port, sslmode, _ = memgraph_server 234 | conn = mgclient.connect(host=host, port=port, sslmode=sslmode) 235 | cursor = conn.cursor() 236 | 237 | cursor.execute("UNWIND [1, 2, 3, 4, 5] AS n RETURN n") 238 | 239 | fetchone_result = cursor.fetchone() 240 | # Refs are the following: 241 | # 1. fetchone_result 242 | # 2. temp reference in sys.getrefcount 243 | # 3. cursor->rows 244 | assert sys.getrefcount(fetchone_result) == 3 245 | 246 | fetchmany_result = cursor.fetchmany(2) 247 | # Refs are the following: 248 | # 1. fetchmany_result 249 | # 2. temp reference in sys.getrefcount 250 | assert sys.getrefcount(fetchmany_result) == 2 251 | row1 = fetchmany_result[0] 252 | row2 = fetchmany_result[1] 253 | del fetchmany_result 254 | # Refs are the following: 255 | # 1. row{1,2} 256 | # 2. temp reference in sys.getrefcount 257 | # 3. cursor->rows 258 | assert sys.getrefcount(row1) == 3 259 | assert sys.getrefcount(row2) == 3 260 | 261 | fetchall_result = cursor.fetchall() 262 | # Refs are the following: 263 | # 1. fetchall_result 264 | # 2. temp reference in sys.getrefcount 265 | assert sys.getrefcount(fetchall_result) == 2 266 | row1 = fetchall_result[0] 267 | row2 = fetchall_result[1] 268 | del fetchall_result 269 | # Refs are the following: 270 | # 1. row{1,2} 271 | # 2. temp reference in sys.getrefcount 272 | # 3. cursor->rows 273 | assert sys.getrefcount(row1) == 3 274 | assert sys.getrefcount(row2) == 3 275 | 276 | 277 | class TestCursorInAsyncConnection: 278 | def test_cursor_close(self, memgraph_server): 279 | host, port, sslmode, _ = memgraph_server 280 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 281 | 282 | cursor = conn.cursor() 283 | cursor.execute("UNWIND range(1, 10) AS n RETURN n") 284 | 285 | cursor2 = conn.cursor() 286 | 287 | with pytest.raises(mgclient.InterfaceError): 288 | cursor.close() 289 | 290 | cursor2.close() 291 | 292 | # NOTE: This here is a bit strange again because of double fetch / 293 | # server ahead of time pull because of the need for has_more info. As 294 | # soon as the last record is returned, the cursor will become 295 | # closeable. 296 | assert cursor.fetchmany(9) == [(n,) for n in range(1, 10)] 297 | with pytest.raises(mgclient.InterfaceError): 298 | cursor.close() 299 | assert cursor.fetchone() == (10,) 300 | assert cursor.fetchone() is None 301 | 302 | cursor.close() 303 | 304 | # closing again does nothing 305 | cursor.close() 306 | 307 | with pytest.raises(mgclient.InterfaceError): 308 | cursor.fetchone() 309 | 310 | with pytest.raises(mgclient.InterfaceError): 311 | cursor.execute("RETURN 100") 312 | 313 | with pytest.raises(mgclient.InterfaceError): 314 | cursor.fetchmany() 315 | 316 | with pytest.raises(mgclient.InterfaceError): 317 | cursor.fetchall() 318 | 319 | with pytest.raises(mgclient.InterfaceError): 320 | cursor.setinputsizes([]) 321 | 322 | with pytest.raises(mgclient.InterfaceError): 323 | cursor.setoutputsizes(100) 324 | 325 | def test_cursor_multiple_queries(self, memgraph_server): 326 | host, port, sslmode, _ = memgraph_server 327 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 328 | 329 | cursor1 = conn.cursor() 330 | cursor2 = conn.cursor() 331 | 332 | cursor1.execute("UNWIND range(1, 10) AS n RETURN n") 333 | 334 | with pytest.raises(mgclient.InterfaceError): 335 | cursor2.execute("UNWIND range(1, 10) AS n RETURN n") 336 | 337 | assert cursor1.fetchall() == [(n,) for n in range(1, 11)] 338 | 339 | with pytest.raises(mgclient.InterfaceError): 340 | cursor2.fetchall() 341 | 342 | def test_cursor_fetchone(self, memgraph_server): 343 | host, port, sslmode, _ = memgraph_server 344 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 345 | 346 | cursor = conn.cursor() 347 | 348 | with pytest.raises(mgclient.InterfaceError): 349 | cursor.fetchone() 350 | 351 | cursor.execute("UNWIND range(1, 10) AS n RETURN n") 352 | 353 | for n in range(1, 11): 354 | assert cursor.fetchone() == (n,) 355 | 356 | assert cursor.fetchone() is None 357 | assert cursor.fetchone() is None 358 | 359 | cursor.execute("RETURN 100") 360 | assert cursor.fetchone() == (100,) 361 | assert cursor.fetchone() is None 362 | 363 | def test_cursor_fetchmany(self, memgraph_server): 364 | host, port, sslmode, _ = memgraph_server 365 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 366 | 367 | cursor = conn.cursor() 368 | 369 | with pytest.raises(mgclient.InterfaceError): 370 | cursor.fetchmany() 371 | 372 | cursor.execute("UNWIND range(1, 10) AS n RETURN n") 373 | 374 | with pytest.raises(OverflowError): 375 | cursor.fetchmany(10 ** 100) 376 | 377 | assert cursor.fetchmany() == [(1,)] 378 | 379 | cursor.arraysize = 4 380 | 381 | assert cursor.fetchmany() == [(2,), (3,), (4,), (5,)] 382 | assert cursor.fetchmany() == [(6,), (7,), (8,), (9,)] 383 | assert cursor.fetchmany() == [(10,)] 384 | assert cursor.fetchmany() == [] 385 | assert cursor.fetchone() is None 386 | 387 | cursor.execute("RETURN 100") 388 | assert cursor.fetchmany() == [(100,)] 389 | assert cursor.fetchmany() == [] 390 | assert cursor.fetchone() is None 391 | 392 | def test_cursor_fetchall(self, memgraph_server): 393 | host, port, sslmode, _ = memgraph_server 394 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 395 | 396 | cursor = conn.cursor() 397 | 398 | with pytest.raises(mgclient.InterfaceError): 399 | cursor.fetchall() 400 | 401 | cursor.execute("UNWIND range(1, 10) AS n RETURN n") 402 | 403 | assert cursor.fetchall() == [(n,) for n in range(1, 11)] 404 | assert cursor.fetchall() == [] 405 | assert cursor.fetchone() is None 406 | 407 | cursor.execute("RETURN 100") 408 | 409 | assert cursor.fetchall() == [(100,)] 410 | assert cursor.fetchall() == [] 411 | assert cursor.fetchone() is None 412 | 413 | def test_cursor_syntax_error(self, memgraph_server): 414 | host, port, sslmode, _ = memgraph_server 415 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 416 | cursor = conn.cursor() 417 | 418 | cursor.execute("RETURN 100") 419 | cursor.fetchall() 420 | 421 | with pytest.raises(mgclient.DatabaseError): 422 | cursor.execute("fjdkalfjdsalfaj") 423 | 424 | with pytest.raises(mgclient.InterfaceError): 425 | cursor.fetchall() 426 | 427 | def test_cursor_runtime_error(self, memgraph_server): 428 | host, port, sslmode, _ = memgraph_server 429 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 430 | cursor = conn.cursor() 431 | 432 | cursor.execute("RETURN 100") 433 | assert cursor.fetchall() == [(100,)] 434 | 435 | cursor.execute("UNWIND [true, true, false] AS p RETURN assert(p)") 436 | with pytest.raises(mgclient.DatabaseError): 437 | assert cursor.fetchone() == (True,) 438 | # NOTE: The exception is going to happen here which is unexpected. 439 | # The reason for that is because server pulls one more result ahead 440 | # of time to know are there more results. 441 | assert cursor.fetchone() == (True,) # <- HERE 442 | cursor.fetchone() 443 | 444 | cursor.execute("UNWIND [true, true, false] AS p RETURN assert(p)") 445 | 446 | with pytest.raises(mgclient.DatabaseError): 447 | cursor.fetchmany(5) 448 | 449 | cursor.execute("UNWIND [true, true, false] AS p RETURN assert(p)") 450 | 451 | with pytest.raises(mgclient.DatabaseError): 452 | cursor.fetchall() 453 | 454 | def test_cursor_description(self, memgraph_server): 455 | host, port, sslmode, _ = memgraph_server 456 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 457 | cursor = conn.cursor() 458 | 459 | cursor.execute("RETURN 5 AS x, 6 AS y") 460 | assert len(cursor.description) == 2 461 | assert cursor.description[0].name == "x" 462 | assert cursor.description[1].name == "y" 463 | 464 | cursor.fetchone() 465 | assert len(cursor.description) == 2 466 | assert cursor.description[0].name == "x" 467 | assert cursor.description[1].name == "y" 468 | 469 | cursor.fetchone() 470 | 471 | with pytest.raises(mgclient.DatabaseError): 472 | cursor.execute("jdfklfjkdalfja") 473 | 474 | assert cursor.description is None 475 | 476 | def test_cursor_fetchone_without_result(self, memgraph_server): 477 | host, port, sslmode, _ = memgraph_server 478 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 479 | cursor = conn.cursor() 480 | 481 | cursor.execute("MATCH (n:NonExistingLabel) RETURN n") 482 | result = cursor.fetchone() 483 | assert result is None 484 | 485 | def test_cursor_fetchmany_without_result(self, memgraph_server): 486 | host, port, sslmode, _ = memgraph_server 487 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 488 | cursor = conn.cursor() 489 | 490 | cursor.execute("MATCH (n:NonExistingLabel) RETURN n") 491 | assert cursor.fetchmany() == [] 492 | 493 | def test_cursor_result_ref_counts(self, memgraph_server): 494 | host, port, sslmode, _ = memgraph_server 495 | conn = mgclient.connect(host=host, port=port, lazy=True, sslmode=sslmode) 496 | cursor = conn.cursor() 497 | 498 | cursor.execute("UNWIND [1, 2, 3, 4, 5] AS n RETURN n") 499 | 500 | fetchone_result = cursor.fetchone() 501 | # Refs are the following: 502 | # 1. fetchone_result 503 | # 2. temp reference in sys.getrefcount 504 | assert sys.getrefcount(fetchone_result) == 2 505 | 506 | fetchmany_result = cursor.fetchmany(2) 507 | # Refs are the following: 508 | # 1. fetchmany_result 509 | # 2. temp reference in sys.getrefcount 510 | assert sys.getrefcount(fetchmany_result) == 2 511 | row1 = fetchmany_result[0] 512 | row2 = fetchmany_result[1] 513 | del fetchmany_result 514 | # Refs are the following: 515 | # 1. row{1,2} 516 | # 2. temp reference in sys.getrefcount 517 | assert sys.getrefcount(row1) == 2 518 | assert sys.getrefcount(row2) == 2 519 | 520 | fetchall_result = cursor.fetchall() 521 | # Refs are the following: 522 | # 1. fetchall_result 523 | # 2. temp reference in sys.getrefcount 524 | assert sys.getrefcount(fetchall_result) == 2 525 | row1 = fetchall_result[0] 526 | row2 = fetchall_result[1] 527 | del fetchall_result 528 | # Refs are the following: 529 | # 1. row{1,2} 530 | # 2. temp reference in sys.getrefcount 531 | assert sys.getrefcount(row1) == 2 532 | assert sys.getrefcount(row2) == 2 533 | --------------------------------------------------------------------------------