Coverage for /var/devmt/py/docp-dbi_1.1.0/docp_dbi/databases/neo4j.py: 100%
78 statements
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-20 13:13 +0100
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-20 13:13 +0100
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""
4:Purpose: This module provides a :class:`Graph` class for interacting
5 with a Neo4j graph database.
7:Platform: Linux/Windows | Python 3.11+
8:Developer: J Berendt
9:Email: development@s3dev.uk
11:Comments: n/a
13:Example:
15 Example showing how to generate a ``.env`` file for easy connection::
17 >>> from docp_dbi.databases import neo4j
19 >>> neo4j.create_dotenv_file(uri='neo4j://127.0.0.1:7687',
20 database='neo4j',
21 username='neo4j',
22 password='neo4j')
24 # Copy the .env file from your Desktop to the project's
25 # directory.
28 Example showing how to use the *local* ``.env`` file to connect to
29 the database::
31 >>> from docp_dbi import Neo4jDB
32 >>> neodb = Neo4jDB()
34 # Verify the database is connected.
35 >>> neodb.connected
36 True
38 # Query the database.
39 >>> results = neodb.execute_query('match (n) return count(n)')
42 Example showing how to use the ``.env`` *filepath* to connect to
43 the database::
45 >>> from docp_dbi import Neo4jDB
46 >>> neodb = Neo4jDB(auth='/path/to/.env')
48 # Verify the database is connected.
49 >>> neodb.connected
50 True
52 # Query the database.
53 >>> results = neodb.execute_query('match (n) return count(n)')
56 Example showing how to query a graph database, using the URI and
57 credentials::
59 >>> from docp_dbi import Neo4jDB
60 >>> neodb = Neo4jDB(uri='neo4j://127.0.0.1:7687',
61 auth=('username', 'password'))
63 # Verify the database is connected.
64 >>> neodb.connected
65 True
67 # Query the database.
68 >>> results = neodb.execute_query('match (n) return count(n)')
70"""
71# pylint: disable=import-error # Spurious
72# pylint: disable=no-name-in-module # Spurious: neo4j._sync
73# pylint: disable=wrong-import-order
75from __future__ import annotations
77import dotenv
78import os
79# Imported this way as this module is also called neo4j.
80from neo4j._sync.driver import GraphDatabase as _GraphDatabase
81# locals
82try:
83 from ..libs.utilities import utilities
84 from ..objects.neo4jobjects import GraphObject
85except ImportError:
86 from docp_dbi.libs.utilities import utilities
87 from docp_dbi.objects.neo4jobjects import GraphObject
90class Neo4jDB:
91 """Graph object class for interacting with a Neo4j graph database.
93 Args:
94 uri (str, optional): Database connection URI. Defaults to None.
95 auth (str | tuple, optional): Either a path to the ``.env`` file
96 containing database credentials, or a tuple containing
97 authentication credentials as ``(username, password)``.
98 If None, this parameter's value is altered to search the
99 current directory for a ``.env`` file. Defaults to None.
100 database (str, optional): Name of the database to be used. Can
101 also be provided via the ``.env`` file using the
102 key: ``NEO4J_DATABASE``. Defaults to None.
103 **config: Pass-through configuration items provided to the driver.
105 .. note::
106 If values are not provided to the class constructor, an attempt
107 is made to obtain the values from the ``.env`` file, either in
108 the current directory, or from the path provided to the ``auth``
109 parameter.
111 """
113 _DIR_ROOT = os.path.dirname(os.path.realpath(__file__))
115 def __init__(self,
116 uri: str=None,
117 *,
118 auth: str | tuple=None,
119 database: str=None,
120 **config) -> None:
121 """Graph class initialiser."""
122 self._uri = uri
123 self._auth = auth
124 self._db = database
125 self._config = config
126 self._connected = False
127 self._driver = None
128 self._connect()
130 def __del__(self) -> None:
131 """Class deconstructor."""
132 # pylint: disable=broad-exception-caught # OK, it's a pass.
133 try:
134 self._driver.close()
135 self._connected = False
136 except Exception: # nocover
137 pass
139 @property
140 def connected(self) -> bool:
141 """The database is connected.
143 Returns:
144 bool: True if connected, otherwise False.
146 """
147 return self._connected
149 @property
150 def database(self) -> str:
151 """Accessor to the database name.
153 The database name can be provided using the ``.env`` file with
154 the key: ``NEO4J_DATABASE``.
156 """
157 return self._db
159 @property
160 def driver(self) -> Neo4jDriver: # noqa # pylint: disable=undefined-variable
161 """Accessor to the database driver object."""
162 return self._driver
164 def close(self) -> None:
165 """Close the connection."""
166 self._driver.close()
167 self._connected = False
169 def execute_query(self,
170 query: str,
171 parameters: dict=None,
172 database: str=None,
173 **kwargs) -> EagerResult: # noqa # pylint: disable=undefined-variable
174 """Execute a query on the database.
176 Args:
177 query (str): Cypher query to be executed.
178 parameters (dict, optional): Query parameters.
179 Defaults to None.
180 database (str, optional): Database to be used.
181 This parameter can be used to override the class'
182 database attribute (:attr:`database`), if provided via
183 the ``.env`` file. Defaults to None.
184 **kwargs: Pass-through keyword arguments.
186 Returns:
187 EagerResult: Query results object.
189 """
190 return self._driver.execute_query(query_=query,
191 parameters_=parameters,
192 database_=database or self._db,
193 **kwargs)
195 def load_from_csv(self,
196 *,
197 path_nodes: str=None,
198 path_edges: str=None) -> tuple[bool, list]:
199 """Load graph data from a CSV file.
201 This function is designed to load node and/or edge (relationship)
202 data. The required format for each CSV type is as follows, where
203 the property data is *optional*, whereas the ``id`` and ``label``
204 are *required*.
206 **Node Data**
208 - **Field Definitions**
209 - **id:** Unique (user-defined) node ID.
210 - **label:** Node label. For example: Book, Character, etc.
211 - **Properties:** The column heading for each property will
212 be used as its key in the graph.
214 - **Format:**
216 ```csv
217 id,label,prop1,...,propN
218 ```
220 **Edge Data**
222 - **Field Definitions**
223 - **source:** The ID of the *source* node.
224 - **target:** The ID of the *target* node.
225 - **type:** edge type. For example: AUTHOR,
226 APPEARS_IN, etc.
227 - **Properties:** The column heading for each property will
228 be used as its key in the graph.
230 - **Format:**
232 ```csv
233 source,target,type,prop1,...,propN
234 ```
236 Args:
237 path_nodes (str, optional): Full path to the node input data.
238 Defaults to None.
239 path_edges (str, optional): Full path to the edge input data.
240 Defaults to None.
242 Returns:
243 tuple[bool, list]: A tuple containing a success flag and a
244 list of query result objects.
246 """
247 n = []
248 e = []
249 src = None
250 if path_nodes:
251 n = utilities.csv_to_nodes(path=path_nodes)
252 src = os.path.basename(path_nodes)
253 if path_edges:
254 e = utilities.csv_to_edges(path=path_edges)
255 if src is None:
256 src = os.path.basename(path_edges)
257 go = GraphObject(nodes=n, edges=e, source=src)
258 return self._add_graph_object(graphobject=go)
260 def _add_graph_object(self, graphobject: GraphObject) -> tuple[bool, list]: # noqa
261 """Add nodes and relationships to the graph via GraphObject.
263 Args:
264 graphobjects (GraphObject): A :class:`GraphObject`
265 container class wrapping the :class:`Node` and
266 :class:`Edge` objects to be added to the graph.
268 Returns:
269 tuple[bool, list]: A tuple containing a success flag and a
270 list of Neo4j result objects.
272 """
273 results = []
274 for node in graphobject.nodes:
275 q = utilities.merge_node_query(node=node)
276 results.append(self.execute_query(q, parameters=node.properties))
277 for edge in graphobject.edges:
278 q = utilities.merge_edge_query(edge=edge)
279 results.append(self.execute_query(q, parameters=edge.properties))
280 act = len(results)
281 exp = graphobject.count
282 if exp == act:
283 print(f'{act} nodes/edges added successfully.')
284 else: # nocover
285 print('[ERROR]: {act} of {exp} nodes/edges added.')
286 return (exp == act), results
288 def _attempt_credentials(self) -> None:
289 """Attempt to use the ``.env`` file for driver connection."""
290 if not all((os.path.isfile(self._auth), os.path.exists(self._auth))):
291 raise RuntimeError('The authentication file either is not a file or '
292 f'cannot be found: {self._auth}')
293 dotenv.load_dotenv(dotenv_path=self._auth)
294 if self._uri is None:
295 self._uri = os.environ.get('NEO4J_URI')
296 self._auth = (os.environ.get('NEO4J_USERNAME'), os.environ.get('NEO4J_PASSWORD'))
297 # Class constructor overrides the database name.
298 if self._db is None:
299 self._db = os.environ.get('NEO4J_DATABASE')
301 def _connect(self) -> None:
302 """Connect to the database.
304 If the ``auth`` argument is a file path, an attempt is made to
305 use the credentials from the ``.env`` file. Otherwise, the
306 ``auth`` argument credentials tuple is used.
308 """
309 if self._auth is None: # nocover
310 self._auth = os.path.join(self._DIR_ROOT, '.env')
311 if isinstance(self._auth, str):
312 self._attempt_credentials()
313 self._driver = _GraphDatabase.driver(uri=self._uri, auth=self._auth, **self._config)
314 if self._driver is None or not self._driver.verify_authentication():
315 raise RuntimeError('Database authentication failed. Review the credentials.')
316 assert self._driver.verify_connectivity() is None
317 self._connected = True
320def create_dotenv_file(uri: str='neo4j://127.0.0.1:7687',
321 database: str='neo4j',
322 password: str='neo4j',
323 username: str='neo4j',
324 replace: bool=False) -> None: # nocover
325 """Generate a ``.env`` file from the provided credentials.
327 The resulting file will be placed on the user's Desktop.
329 Args:
330 uri (str, optional): Database location URI.
331 Defaults to 'neo4j://127.0.0.1:7687'.
332 database (str, optional): Database name. Defaults to 'neo4j'.
333 password (str, optional): Authentication password.
334 Defaults to 'neo4j'.
335 username (str, optional): Authentication username.
336 Defaults to 'neo4j'.
337 replace (bool, optional): Force replace an existing ``.env``
338 file. Defaults to False.
340 Raises:
341 RuntimeError: Raised if a ``.env`` file already exists on the
342 user's Desktop *and* ``replace=False``.
344 """
345 path = os.path.expanduser('~/Desktop/.env')
346 if os.path.exists(path) and not replace:
347 raise RuntimeError('An .env file already exists on the Desktop. '
348 'To overwrite, pass replace=True.')
349 with open(path, 'w', encoding='utf-8') as f:
350 f.write(f'NEO4J_URI="{uri}"\n')
351 f.write(f'NEO4J_DATABASE="{database}"\n')
352 f.write(f'NEO4J_PASSWORD="{password}"\n')
353 f.write(f'NEO4J_USERNAME="{username}"')