Coverage for /var/devmt/py/docp-dbi_1.1.0/docp_dbi/objects/neo4jobjects.py: 100%

92 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2026-06-16 17:52 +0100

1#!/usr/bin/env python3 

2# -*- coding: utf-8 -*- 

3""" 

4:Purpose: This module provides the implementation for the Neo4j objects 

5 used for loading data into a graph database. 

6 

7 .. note:: 

8 Generally, this module is for internal use only. 

9 

10:Platform: Linux/Windows | Python 3.10+ 

11:Developer: J Berendt 

12:Email: development@s3dev.uk 

13 

14:Comments: n/a 

15 

16""" 

17 

18from __future__ import annotations 

19 

20 

21class _GraphBaseUtilities: 

22 """Base utilities for graph classes.""" 

23 # pylint: disable=no-member # Provided by child class. 

24 

25 _QUOTES = ('\'', '"') 

26 

27 def _clean_properties(self) -> None: 

28 """Clean property values. 

29 

30 :Tasks: 

31 - Convert integer strings to type ``int``. 

32 

33 """ 

34 for k, v in self._properties.items(): 

35 if v: 

36 if all((isinstance(v, str), v.isdigit())): 

37 self._properties[k] = int(v) 

38 

39 # Cannot be in libs.utilities due to circular import. 

40 def _isquoted(self, s: str) -> bool: 

41 """Test if a string is already quoted. 

42 

43 Args: 

44 s (str): The string to be tested. 

45 

46 Returns: 

47 bool: True if the string contains starting and ending quotes. 

48 Otherwise, False. 

49 

50 """ 

51 return s[0] in self._QUOTES and s[-1] in self._QUOTES 

52 

53 

54class Edge(_GraphBaseUtilities): 

55 """Object representing an edge (a relationship) in the graph. 

56 

57 Args: 

58 source (Node): The edge's source :class:`Node` object. 

59 target (Node): The edge's target :class:`Node` object. 

60 type (str): Edge type. 

61 For example: 'PARENT', 'AUTHOR_OF', 'MEMBER_OF', etc. 

62 

63 .. note:: 

64 To follow Cypher convention, **upper case** is 

65 recommended. Therefore, all types names are converted to 

66 upper case. 

67 

68 properties (dict, optional): Any properties which are to be 

69 assigned to the edge. Defaults to None. 

70 

71 """ 

72 # pylint: disable=redefined-builtin # type 

73 

74 def __init__(self, source: Node, target: Node, type: str, properties: dict=None) -> None: 

75 """Relationship class initialiser.""" 

76 self._source = source 

77 self._target = target 

78 self._type = type.upper() 

79 self._properties = properties or {} 

80 self._validate() 

81 self._clean_properties() 

82 

83 def __repr__(self) -> str: # nocover 

84 """Object's string representation.""" 

85 r = (f'<source={self._source}; ' 

86 f'target={self._target}; ' 

87 f'type={self._type}; ' 

88 f'properties={self._properties}>') 

89 return r 

90 

91 @property 

92 def properties(self) -> dict: 

93 """Accessor to the edge properties.""" 

94 return self._properties 

95 

96 @property 

97 def source(self) -> Node: 

98 """Accessor to the source :class:`Node` object.""" 

99 return self._source 

100 

101 @property 

102 def target(self) -> Node: 

103 """Accessor to the target :class:`Node` object.""" 

104 return self._target 

105 

106 @property 

107 def type(self) -> str: 

108 """Target to the type name.""" 

109 return self._type 

110 

111 def _validate(self) -> None: 

112 """Validate the nodes to ensure a Node object exists. 

113 

114 If the source and target values come from a CSV file, they will 

115 be of string or integer type and must be converted to a 

116 :class:`Node` object before being added to the graph. 

117 

118 """ 

119 def _convert(val: int | str) -> int | str: 

120 """Set the ID value as an integer, if possible.""" 

121 # pylint: disable=possibly-used-before-assignment # ValueError raised to catch this. 

122 if not isinstance(val, int | str): 

123 raise ValueError('Unexpected type when converting source (or) target to Node.') 

124 if isinstance(val, str): 

125 id_ = val if not val.isdigit() else int(val) 

126 elif isinstance(val, int): 

127 id_ = val 

128 return Node(id=id_) 

129 if not isinstance(self._source, Node): 

130 self._source = _convert(val=self._source) 

131 if not isinstance(self._target, Node): 

132 self._target = _convert(val=self._target) 

133 

134 

135class Node(_GraphBaseUtilities): 

136 """Object representing a node in the graph. 

137 

138 Args: 

139 id (int | str): User-defined ID to be assigned to the node. 

140 This ID will be used for relationship linking and MATCH 

141 referencing. 

142 label (str, optional): Node label. 

143 For example: 'Person' or 'Book'. Defaults to 'Node'. 

144 

145 .. note:: 

146 To follow Cypher convention, **Pascal case** is 

147 recommended. 

148 

149 properties (dict, optional): Any properties which are to be 

150 assigned to the node. Defaults to None. 

151 

152 """ 

153 # pylint: disable=redefined-builtin # id 

154 

155 def __init__(self, id: int | str, label: str='Node', properties: dict=None) -> None: 

156 """Node class initialiser.""" 

157 self._id = self._create_id(id_=id) 

158 self._label = label 

159 self._properties = properties or {} 

160 self._clean_properties() 

161 

162 def __repr__(self) -> str: # nocover 

163 """Object's string representation.""" 

164 r = f'<id={self._id}; label={self._label}; properties={self._properties}>' 

165 return r 

166 

167 @property 

168 def id(self) -> int | str: 

169 """Accessor to the node's ID.""" 

170 return self._id 

171 

172 @property 

173 def label(self) -> str: 

174 """Accessor to the node label.""" 

175 return self._label 

176 

177 @property 

178 def properties(self) -> dict: 

179 """Accessor to the node's properties as a dictionary.""" 

180 return self._properties 

181 

182 def _create_id(self, id_: int | str) -> int | str: 

183 """Setter for the node ID. 

184 

185 Args: 

186 id_ (int | str): ID to be set. 

187 

188 Returns: 

189 str | int: If the ID is a numeric value and was defined as a 

190 string, the value is returned as an integer type. Otherwise, 

191 the a *quoted* string is returned (if not already quoted). 

192 

193 """ 

194 if isinstance(id_, int): 

195 return id_ 

196 if isinstance(id_, str) and id_.isdigit(): 

197 return int(id_) 

198 if self._isquoted(s=id_): 

199 return id_ 

200 return f'"{id_}"' 

201 

202 

203class GraphObject: 

204 """Container for nodes and relationships for adding to the database. 

205 

206 Args: 

207 nodes (list, optional): A list of :class:`Node` objects. 

208 Defaults to None. 

209 edges (list, optional): A list of :class:`Edge` objects. 

210 Defaults to None. 

211 source (str, optional): Data source. Defaults to None. 

212 

213 """ 

214 

215 def __init__(self, nodes: list=None, edges: list=None, source: str=None) -> None: 

216 """GraphObjects class initialiser.""" 

217 self._nodes = [] 

218 self._edges = [] 

219 self._source = source 

220 self._set(n=nodes, e=edges) 

221 self._validate() 

222 

223 @property 

224 def count(self) -> int: 

225 """Accessor to the count of node *and* edge objects.""" 

226 return sum((len(self._nodes), len(self._edges))) 

227 

228 @property 

229 def nodes(self) -> list[Node]: #noqa 

230 """Accessor to the :class:`Node` objects list.""" 

231 return self._nodes 

232 

233 @property 

234 def edges(self) -> list[Edge]: # noqa 

235 """Accessor to the :class:`Edge` objects list.""" 

236 return self._edges 

237 

238 def _validate(self) -> None: 

239 """Validate the passed object types. 

240 

241 Raises: 

242 TypeError: Raised if the wrong data type is detected. 

243 

244 """ 

245 if not all(map(lambda x: isinstance(x, Node | None), self._nodes)): 

246 raise TypeError('All node-based nodes must be of type: Node. ' 

247 f'Got: {list(map(type, self._nodes))}') 

248 if not all(map(lambda x: isinstance(x, Edge | None), self._edges)): 

249 raise TypeError('All edge-based nodes must be of type: Edge. ' 

250 f'Got: {list(map(type, self._edges))}') 

251 

252 def _set(self, n: list[Node], e: list[Edge]) -> None: # noqa 

253 """Set the passed objects as class instance attributes. 

254 

255 Args: 

256 n (list[Node]): List of :class:`Node` objects. 

257 r (list[Edge]): List of :class:`Edge` objects. 

258 

259 .. note:: 

260 If only a single class is passed into the constructor, this 

261 is converted to a list before setting. 

262 

263 """ 

264 if n: 

265 self._nodes = n if isinstance(n, list) else [n] 

266 if e: 

267 self._edges = e if isinstance(e, list) else [e]