ricgraph 2.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ricgraph/__init__.py +38 -0
- ricgraph/ricgraph.py +2788 -0
- ricgraph-2.6.dist-info/LICENSE +21 -0
- ricgraph-2.6.dist-info/METADATA +125 -0
- ricgraph-2.6.dist-info/RECORD +7 -0
- ricgraph-2.6.dist-info/WHEEL +5 -0
- ricgraph-2.6.dist-info/top_level.txt +1 -0
ricgraph/ricgraph.py
ADDED
|
@@ -0,0 +1,2788 @@
|
|
|
1
|
+
# ########################################################################
|
|
2
|
+
#
|
|
3
|
+
# Ricgraph - Research in context graph
|
|
4
|
+
#
|
|
5
|
+
# ########################################################################
|
|
6
|
+
#
|
|
7
|
+
# MIT License
|
|
8
|
+
#
|
|
9
|
+
# Copyright (c) 2023, 2024 Rik D.T. Janssen
|
|
10
|
+
#
|
|
11
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
# in the Software without restriction, including without limitation the rights
|
|
14
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
# furnished to do so, subject to the following conditions:
|
|
17
|
+
#
|
|
18
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
# copies or substantial portions of the Software.
|
|
20
|
+
#
|
|
21
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
# SOFTWARE.
|
|
28
|
+
#
|
|
29
|
+
# ########################################################################
|
|
30
|
+
#
|
|
31
|
+
# This file is the main file for Ricgraph.
|
|
32
|
+
# Original version Rik D.T. Janssen, December 2022.
|
|
33
|
+
# Updated Rik D.T. Janssen, February, March, September to December 2023.
|
|
34
|
+
# Updated Rik D.T. Janssen, February 2024, March 2024.
|
|
35
|
+
#
|
|
36
|
+
# ########################################################################
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
"""Ricgraph - Research in context graph.
|
|
40
|
+
|
|
41
|
+
This is Ricgraph - Research in context graph, a graph structure with nodes and edges
|
|
42
|
+
to represent information (as properties in the nodes)
|
|
43
|
+
and their relations (the edges). You can find the code and documentation on
|
|
44
|
+
https://github.com/UtrechtUniversity/ricgraph/.
|
|
45
|
+
|
|
46
|
+
Ricgraph uses Neo4j as graph database, https://neo4j.com.
|
|
47
|
+
Neo4j only allows directed graphs, so Ricgraph is a directed graph.
|
|
48
|
+
They have several products. Their free products are:
|
|
49
|
+
|
|
50
|
+
- Neo4j Desktop (https://neo4j.com/download-center/#desktop);
|
|
51
|
+
|
|
52
|
+
- Neo4j Community Edition (https://neo4j.com/download-center/#community).
|
|
53
|
+
|
|
54
|
+
Neo4j Desktop includes Bloom, which allows exploring the graph in a more
|
|
55
|
+
intuitive way. Community Edition allows only Cypher queries.
|
|
56
|
+
|
|
57
|
+
Ricgraph can be extended for other brands of graph databases
|
|
58
|
+
by changing minor bits of the code.
|
|
59
|
+
|
|
60
|
+
All nodes have the properties in RICGRAPH_PROPERTIES_STANDARD and
|
|
61
|
+
RICGRAPH_PROPERTIES_HIDDEN (see ricgraph.ini):
|
|
62
|
+
|
|
63
|
+
In RICGRAPH_PROPERTIES_STANDARD:
|
|
64
|
+
|
|
65
|
+
- name: name of the node, e.g. ISNI, ORCID, DOI, etc.
|
|
66
|
+
|
|
67
|
+
- category: category of the node,
|
|
68
|
+
e.g. person, person-root, book, journal article, dataset, etc.
|
|
69
|
+
|
|
70
|
+
- value: value of the node.
|
|
71
|
+
|
|
72
|
+
In RICGRAPH_PROPERTIES_HIDDEN:
|
|
73
|
+
|
|
74
|
+
- _key: key value of the node, not to be modified by the user.
|
|
75
|
+
|
|
76
|
+
- _source: sorted list of sources a record has been harvested from,
|
|
77
|
+
not to be modified by the user
|
|
78
|
+
|
|
79
|
+
- _history: list of history events of the node, not to be modified by the user.
|
|
80
|
+
|
|
81
|
+
Additional properties for nodes can be added by changing entry RICGRAPH_PROPERTIES_ADDITIONAL
|
|
82
|
+
in ricgraph.ini (make sure 'history_event' is last).
|
|
83
|
+
In the default configuration of Ricgraph, the following properties are included:
|
|
84
|
+
|
|
85
|
+
- comment: comment for a node, can be anything that is thought to be useful.
|
|
86
|
+
|
|
87
|
+
- url_main: main URL for a node, pointing to e.g. the corresponding ISNI, ORCID or DOI
|
|
88
|
+
record on the web.
|
|
89
|
+
|
|
90
|
+
- url_other: other URL for a node, pointing to e.g. the originating record in a source system.
|
|
91
|
+
|
|
92
|
+
- year: year of a research output.
|
|
93
|
+
|
|
94
|
+
- source_event: an event to be added to the _source list.
|
|
95
|
+
|
|
96
|
+
- history_event: an event to be added to the _history list.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
import os
|
|
101
|
+
import sys
|
|
102
|
+
from datetime import datetime
|
|
103
|
+
import numpy
|
|
104
|
+
import pandas
|
|
105
|
+
import requests
|
|
106
|
+
import csv
|
|
107
|
+
import uuid
|
|
108
|
+
import json
|
|
109
|
+
from typing import Union, Tuple
|
|
110
|
+
from collections import defaultdict
|
|
111
|
+
import configparser
|
|
112
|
+
from neo4j import GraphDatabase, Driver, Result
|
|
113
|
+
from neo4j.graph import Node
|
|
114
|
+
|
|
115
|
+
__author__ = 'Rik D.T. Janssen'
|
|
116
|
+
__copyright__ = 'Copyright (c) 2023, 2024 Rik D.T. Janssen'
|
|
117
|
+
__email__ = ''
|
|
118
|
+
__license__ = 'MIT License'
|
|
119
|
+
__package__ = 'Ricgraph'
|
|
120
|
+
# __version__ is in __init__.py of this module.
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ########################################################################
|
|
124
|
+
# Start of constants section
|
|
125
|
+
# ########################################################################
|
|
126
|
+
RICGRAPH_INI_FILENAME = 'ricgraph.ini'
|
|
127
|
+
RICGRAPH_KEY_SEPARATOR = '|'
|
|
128
|
+
|
|
129
|
+
# Used for some loop iterations, in case no max iteration for such a loop is specified.
|
|
130
|
+
A_LARGE_NUMBER = 9999999999
|
|
131
|
+
|
|
132
|
+
# For the REST API, we need to return an HTTP response status code. These are
|
|
133
|
+
# listed on https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml.
|
|
134
|
+
# For Ricgraph, we only use one HTTP response status code:
|
|
135
|
+
# https://www.rfc-editor.org/rfc/rfc9110.html#name-200-ok.
|
|
136
|
+
HTTP_RESPONSE_OK = 200
|
|
137
|
+
# We use these if an item cannot be found. They should be in the 2xx series
|
|
138
|
+
# since we still return a valid HTTP response. These are in the "unassigned" range,
|
|
139
|
+
# which may be freely used.
|
|
140
|
+
HTTP_RESPONSE_NOTHING_FOUND = 250
|
|
141
|
+
HTTP_RESPONSE_INVALID_SEARCH = 251
|
|
142
|
+
|
|
143
|
+
# This dict is used as a cache for node id's. If we have a node id, we can
|
|
144
|
+
# do a direct lookup for a node in O(1), instead of a search in O(log n).
|
|
145
|
+
# The dict has the format: [Ricgraph _key]: [Node element_id].
|
|
146
|
+
nodes_cache_key_element_id = {}
|
|
147
|
+
# Cache size. 'nodes_cache_key_element_id' will be emptied if it has this number of elements.
|
|
148
|
+
MAX_NODES_CACHE_KEY_ID = 15000
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# These counters are used to count the number of accesses to the
|
|
152
|
+
# graph database backend.
|
|
153
|
+
graphdb_nr_creates = 0
|
|
154
|
+
graphdb_nr_reads = 0
|
|
155
|
+
graphdb_nr_updates = 0
|
|
156
|
+
graphdb_nr_deletes = 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ########################################################################
|
|
160
|
+
# Research output types used in Ricgraph.
|
|
161
|
+
# Harvested sources most often have a type for a research output.
|
|
162
|
+
# In a harvest script for a certain source, you specify a mapping
|
|
163
|
+
# from the names used in the source you harvest, to the values below.
|
|
164
|
+
# This ensures that Ricgraph uses the same wording for types of
|
|
165
|
+
# research outputs harvested from different sources.
|
|
166
|
+
#
|
|
167
|
+
# An example mapping may look like this:
|
|
168
|
+
# mapping = {
|
|
169
|
+
# 'book': ROTYPE_BOOK,
|
|
170
|
+
# 'dataset': ROTYPE_DATASET,
|
|
171
|
+
# 'software': ROTYPE_SOFTWARE
|
|
172
|
+
# }
|
|
173
|
+
#
|
|
174
|
+
# Call the function lookup_resout_type() to do this mapping.
|
|
175
|
+
# If you add a type, also add it to ROTYPE_ALL.
|
|
176
|
+
#
|
|
177
|
+
# This list is inspired by the Strategy Evaluation Protocol 2021-2027
|
|
178
|
+
# https://www.universiteitenvannederland.nl/files/documenten/Domeinen/Onderzoek/SEP_2021-2027.pdf,
|
|
179
|
+
# Appendix E2.
|
|
180
|
+
ROTYPE_ABSTRACT = 'abstract'
|
|
181
|
+
ROTYPE_BOOK = 'book'
|
|
182
|
+
ROTYPE_BOOKCHAPTER = 'book chapter'
|
|
183
|
+
ROTYPE_CONFERENCE_ARTICLE = 'conference article'
|
|
184
|
+
ROTYPE_DATASET = 'data set'
|
|
185
|
+
ROTYPE_DESIGN = 'design'
|
|
186
|
+
ROTYPE_DIGITAL_VISUAL_PRODUCT = 'digital or visual product'
|
|
187
|
+
ROTYPE_EDITORIAL = 'editorial'
|
|
188
|
+
ROTYPE_ENTRY = 'entry for encyclopedia or dictionary'
|
|
189
|
+
ROTYPE_EXHIBITION_PERFORMANCE = 'exhibition or performance'
|
|
190
|
+
ROTYPE_JOURNAL_ARTICLE = 'journal article'
|
|
191
|
+
ROTYPE_LETTER = 'letter to the editor'
|
|
192
|
+
ROTYPE_MEMORANDUM = 'memorandum'
|
|
193
|
+
ROTYPE_METHOD_DESCRIPTION = 'method description'
|
|
194
|
+
ROTYPE_MODEL = 'model'
|
|
195
|
+
ROTYPE_OTHER_CONTRIBUTION = 'other contribution'
|
|
196
|
+
ROTYPE_PATENT = 'patent'
|
|
197
|
+
ROTYPE_PHDTHESIS = 'PhD thesis'
|
|
198
|
+
ROTYPE_POSTER = 'poster'
|
|
199
|
+
ROTYPE_PREPRINT = 'preprint'
|
|
200
|
+
ROTYPE_PRESENTATION = 'presentation'
|
|
201
|
+
ROTYPE_REGISTERED_REPORT = 'registered report'
|
|
202
|
+
ROTYPE_REPORT = 'report'
|
|
203
|
+
ROTYPE_RETRACTION = 'retraction'
|
|
204
|
+
ROTYPE_REVIEW = 'review'
|
|
205
|
+
ROTYPE_SOFTWARE = 'software'
|
|
206
|
+
ROTYPE_THESIS = 'thesis'
|
|
207
|
+
ROTYPE_WEBSITE = 'website or web publication'
|
|
208
|
+
|
|
209
|
+
ROTYPE_ALL = [ROTYPE_ABSTRACT,
|
|
210
|
+
ROTYPE_BOOK,
|
|
211
|
+
ROTYPE_BOOKCHAPTER,
|
|
212
|
+
ROTYPE_CONFERENCE_ARTICLE,
|
|
213
|
+
ROTYPE_DATASET,
|
|
214
|
+
ROTYPE_DESIGN,
|
|
215
|
+
ROTYPE_DIGITAL_VISUAL_PRODUCT,
|
|
216
|
+
ROTYPE_EDITORIAL,
|
|
217
|
+
ROTYPE_ENTRY,
|
|
218
|
+
ROTYPE_EXHIBITION_PERFORMANCE,
|
|
219
|
+
ROTYPE_JOURNAL_ARTICLE,
|
|
220
|
+
ROTYPE_LETTER,
|
|
221
|
+
ROTYPE_MEMORANDUM,
|
|
222
|
+
ROTYPE_METHOD_DESCRIPTION,
|
|
223
|
+
ROTYPE_MODEL,
|
|
224
|
+
ROTYPE_OTHER_CONTRIBUTION,
|
|
225
|
+
ROTYPE_PATENT,
|
|
226
|
+
ROTYPE_PHDTHESIS,
|
|
227
|
+
ROTYPE_POSTER,
|
|
228
|
+
ROTYPE_PREPRINT,
|
|
229
|
+
ROTYPE_PRESENTATION,
|
|
230
|
+
ROTYPE_REGISTERED_REPORT,
|
|
231
|
+
ROTYPE_REPORT,
|
|
232
|
+
ROTYPE_RETRACTION,
|
|
233
|
+
ROTYPE_REVIEW,
|
|
234
|
+
ROTYPE_SOFTWARE,
|
|
235
|
+
ROTYPE_THESIS,
|
|
236
|
+
ROTYPE_WEBSITE]
|
|
237
|
+
|
|
238
|
+
# ########################################################################
|
|
239
|
+
# End of constants section
|
|
240
|
+
# ########################################################################
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
global _graph
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ##############################################################################
|
|
247
|
+
# Modification for Python module neo4j.
|
|
248
|
+
# ##############################################################################
|
|
249
|
+
|
|
250
|
+
def node_eq(self, other):
|
|
251
|
+
"""Modified __eq__ method for Python module neo4j.
|
|
252
|
+
|
|
253
|
+
The __eq__ method of class Node does not work as expected (as of April 2024).
|
|
254
|
+
Two Nodes are supposed to be equal if their Node.element_id's are equal.
|
|
255
|
+
|
|
256
|
+
I would like to be able to test if a 'node' of type Node is in a list called
|
|
257
|
+
'mylist' with an 'if' statement like this:
|
|
258
|
+
'if node in mylist ...' or 'if node not in mylist ...',
|
|
259
|
+
but it does not work: even if Nodes are in that list, the 'if' fails.
|
|
260
|
+
I could have made my own class and extended it with an __eq__ method, but
|
|
261
|
+
I choose this hack. It is called a 'Monkey patch': A Monkey patch is a piece
|
|
262
|
+
of Python code which extends or modifies other code at runtime
|
|
263
|
+
(typically at startup).
|
|
264
|
+
|
|
265
|
+
:return: True on equality, or False if not.
|
|
266
|
+
"""
|
|
267
|
+
return isinstance(other, Node) and self.element_id == other.element_id
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# Modified __eq__ method for Python module neo4j (also see above).
|
|
271
|
+
Node.__eq__ = node_eq
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ##############################################################################
|
|
275
|
+
# Cypher functions.
|
|
276
|
+
# ##############################################################################
|
|
277
|
+
# These are also functions with cypher:
|
|
278
|
+
# - read_all_nodes()
|
|
279
|
+
# - read_all_values_of_property()
|
|
280
|
+
# - get_all_neighbor_nodes()
|
|
281
|
+
# ##############################################################################
|
|
282
|
+
# Note the use of WHERE clauses below. Some of them uses the function elementId(),
|
|
283
|
+
# which does a _direct_ lookup for a node with that id. That is the fastest way possible,
|
|
284
|
+
# compared to a WHERE clause on node['_key'], which is a search.
|
|
285
|
+
# To observe this difference, prefix a Cypher query with PROFILE, e.g.:
|
|
286
|
+
# PROFILE MATCH (n) WHERE elementId(n)=[a string here] RETURN *
|
|
287
|
+
#
|
|
288
|
+
# Read: https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-elementid
|
|
289
|
+
# https://neo4j.com/docs/cypher-manual/current/planning-and-tuning/operators/operators-detail/#query-plan-node-by-elementid-seek
|
|
290
|
+
#
|
|
291
|
+
# [May 6, 2024] Note however, that memgraph on does not have elementId() but only id():
|
|
292
|
+
# https://memgraph.com/docs/querying/differences-in-cypher-implementations.
|
|
293
|
+
# ##############################################################################
|
|
294
|
+
|
|
295
|
+
def open_ricgraph() -> Driver:
|
|
296
|
+
"""Open Ricgraph.
|
|
297
|
+
|
|
298
|
+
:return: graph that has been opened.
|
|
299
|
+
"""
|
|
300
|
+
global _graph
|
|
301
|
+
|
|
302
|
+
print('Opening ricgraph.\n')
|
|
303
|
+
try:
|
|
304
|
+
_graph = GraphDatabase.driver(GRAPHDB_URL,
|
|
305
|
+
auth=(GRAPHDB_USER, GRAPHDB_PASSWORD))
|
|
306
|
+
_graph.verify_connectivity()
|
|
307
|
+
except:
|
|
308
|
+
print('open_ricgraph(): Error opening graph database backend using these parameters:')
|
|
309
|
+
print('GRAPHDB_URL: ' + GRAPHDB_URL)
|
|
310
|
+
print('GRAPHDB_DATABASENAME: ' + ricgraph_databasename())
|
|
311
|
+
print('GRAPHDB_USER: ' + GRAPHDB_USER)
|
|
312
|
+
print('GRAPHDB_PASSWORD: [see ricgraph.ini file]')
|
|
313
|
+
print('\nAre these parameters correct and did you start your graph database backend "' + GRAPHDB + '"?')
|
|
314
|
+
print('Exiting.')
|
|
315
|
+
exit(1)
|
|
316
|
+
|
|
317
|
+
return _graph
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def close_ricgraph() -> None:
|
|
321
|
+
"""Close Ricgraph.
|
|
322
|
+
"""
|
|
323
|
+
global _graph
|
|
324
|
+
|
|
325
|
+
print('Closing ricgraph.\n')
|
|
326
|
+
_graph.close()
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def ricgraph_database() -> str:
|
|
331
|
+
"""Return the name of the Ricgraph database backend.
|
|
332
|
+
"""
|
|
333
|
+
return GRAPHDB
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def ricgraph_databasename() -> str:
|
|
337
|
+
"""Return the name of the database in the Ricgraph database backend.
|
|
338
|
+
"""
|
|
339
|
+
return GRAPHDB_DATABASENAME
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def empty_ricgraph(answer: str = '') -> None:
|
|
343
|
+
"""Empty Ricgraph and create new indexes. Side effect: indexes are deleted and created.
|
|
344
|
+
|
|
345
|
+
:param answer: prefilled answer whether the user wants to empty Ricgraph.
|
|
346
|
+
'yes': Ricgraph will be emptied, no questions asked;
|
|
347
|
+
'no': Ricgraph will not be emptied, no questions asked;
|
|
348
|
+
any other answer: the user will be asked whether to empty Ricgraph.
|
|
349
|
+
:return: None.
|
|
350
|
+
"""
|
|
351
|
+
global _graph
|
|
352
|
+
|
|
353
|
+
if answer == 'yes':
|
|
354
|
+
print('Emptying Ricgraph: Ricgraph will be emptied.\n')
|
|
355
|
+
# Fall through.
|
|
356
|
+
elif answer == 'no':
|
|
357
|
+
print('Emptying Ricgraph: Ricgraph will not be emptied.\n')
|
|
358
|
+
return
|
|
359
|
+
else:
|
|
360
|
+
print('Do you want to empty Ricgraph? You have the following options:')
|
|
361
|
+
print('- "yes": Ricgraph will be emptied.')
|
|
362
|
+
print('- "no": Ricgraph will not be emptied.')
|
|
363
|
+
print('- any other answer: Ricgraph will not be emptied, execution of this script will abort.')
|
|
364
|
+
user_input = input('Please make a choice: ')
|
|
365
|
+
if user_input.lower() == 'yes':
|
|
366
|
+
print('\nRicgraph will be emptied.\n')
|
|
367
|
+
# Fall through.
|
|
368
|
+
elif user_input.lower() == 'no':
|
|
369
|
+
print('\nRicgraph has not been emptied.\n')
|
|
370
|
+
return
|
|
371
|
+
else:
|
|
372
|
+
print('\nRicgraph has not been emptied, exiting.\n')
|
|
373
|
+
exit(1)
|
|
374
|
+
|
|
375
|
+
graphdb_name = ricgraph_database()
|
|
376
|
+
graphdb_databasename = ricgraph_databasename()
|
|
377
|
+
|
|
378
|
+
# Delete the database and start over again.
|
|
379
|
+
# Sometimes the following statement fails with Neo4j Desktop graph database backend
|
|
380
|
+
# if there are many nodes, see e.g.
|
|
381
|
+
# https://stackoverflow.com/questions/23310114/how-to-reset-clear-delete-neo4j-database.
|
|
382
|
+
# Apparently, the community edition of Neo4j does not have a
|
|
383
|
+
# "CREATE OR REPLACE DATABASE customers" command.
|
|
384
|
+
print('Deleting all nodes and edges in Ricgraph...\n')
|
|
385
|
+
_graph.execute_query('MATCH (node) DETACH DELETE node', database_=graphdb_databasename)
|
|
386
|
+
|
|
387
|
+
if graphdb_name == 'neo4j':
|
|
388
|
+
# Do not use TEXT indexes, use RANGE indexes, these are much faster during harvesting
|
|
389
|
+
# (but they do not support a CONTAINS substring search which is being used for a broad search
|
|
390
|
+
# in read_all_nodes()). The RANGE index is the default in Neo4j. More info:
|
|
391
|
+
# https://neo4j.com/docs/cypher-manual/current/indexes/search-performance-indexes/managing-indexes
|
|
392
|
+
# [Jan. 2023] The graph database backend Neo4j can have at most 3 indexes.
|
|
393
|
+
# [June 5, 2024] This is not true anymore. I use 4 indexes.
|
|
394
|
+
_graph.execute_query('DROP INDEX KeyIndex IF EXISTS', database_=graphdb_databasename)
|
|
395
|
+
_graph.execute_query('DROP INDEX NameIndex IF EXISTS', database_=graphdb_databasename)
|
|
396
|
+
_graph.execute_query('DROP INDEX CategoryIndex IF EXISTS', database_=graphdb_databasename)
|
|
397
|
+
_graph.execute_query('DROP INDEX ValueIndex IF EXISTS', database_=graphdb_databasename)
|
|
398
|
+
|
|
399
|
+
print('Creating indexes...')
|
|
400
|
+
_graph.execute_query('CREATE INDEX KeyIndex IF NOT EXISTS FOR (node:RicgraphNode) ON (node._key)',
|
|
401
|
+
database_=graphdb_databasename)
|
|
402
|
+
_graph.execute_query('CREATE INDEX NameIndex IF NOT EXISTS FOR (node:RicgraphNode) ON (node.name)',
|
|
403
|
+
database_=graphdb_databasename)
|
|
404
|
+
_graph.execute_query('CREATE INDEX CategoryIndex IF NOT EXISTS FOR (node:RicgraphNode) ON (node.category)',
|
|
405
|
+
database_=graphdb_databasename)
|
|
406
|
+
_graph.execute_query('CREATE INDEX ValueIndex IF NOT EXISTS FOR (node:RicgraphNode) ON (node.value)',
|
|
407
|
+
database_=graphdb_databasename)
|
|
408
|
+
|
|
409
|
+
print('These indexes have been created:')
|
|
410
|
+
records, summary, keys = _graph.execute_query('SHOW INDEXES',
|
|
411
|
+
database_=graphdb_databasename)
|
|
412
|
+
index_table = pandas.DataFrame(data=records, columns=keys)
|
|
413
|
+
print(index_table.to_string(index=False))
|
|
414
|
+
print('')
|
|
415
|
+
elif graphdb_name == 'memgraph':
|
|
416
|
+
# For Memgraph, we need to create indexes in the following way, otherwise we get an
|
|
417
|
+
# 'Index manipulation not allowed in multicommand transactions'
|
|
418
|
+
# error. We should use implicit (or auto-commit) transactions.
|
|
419
|
+
# See https://memgraph.com/docs/client-libraries/python#transaction-management.
|
|
420
|
+
with _graph.session(database=GRAPHDB) as session:
|
|
421
|
+
# These also work in case they do not exist.
|
|
422
|
+
session.run('DROP INDEX ON :RicgraphNode;')
|
|
423
|
+
session.run('DROP INDEX ON :RicgraphNode(_key);')
|
|
424
|
+
session.run('DROP INDEX ON :RicgraphNode(name);')
|
|
425
|
+
session.run('DROP INDEX ON :RicgraphNode(category);')
|
|
426
|
+
session.run('DROP INDEX ON :RicgraphNode(value);')
|
|
427
|
+
|
|
428
|
+
print('Creating indexes...')
|
|
429
|
+
session.run('CREATE INDEX ON :RicgraphNode;')
|
|
430
|
+
session.run('CREATE INDEX ON :RicgraphNode(_key);')
|
|
431
|
+
session.run('CREATE INDEX ON :RicgraphNode(name);')
|
|
432
|
+
session.run('CREATE INDEX ON :RicgraphNode(category);')
|
|
433
|
+
session.run('CREATE INDEX ON :RicgraphNode(value);')
|
|
434
|
+
|
|
435
|
+
print('These indexes have been created:')
|
|
436
|
+
for index_line in session.run('SHOW INDEX INFO;'):
|
|
437
|
+
print(index_line)
|
|
438
|
+
print('')
|
|
439
|
+
|
|
440
|
+
# session.close() is done automatically because of 'with'.
|
|
441
|
+
else:
|
|
442
|
+
print('empty_ricgraph(): Unknown graph database backend "' + graphdb_name + '".')
|
|
443
|
+
print('Exiting.')
|
|
444
|
+
exit(1)
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def ricgraph_nr_nodes() -> int:
|
|
449
|
+
"""Count the number of nodes in Ricgraph.
|
|
450
|
+
|
|
451
|
+
:return: the number of nodes, or -1 on error.
|
|
452
|
+
"""
|
|
453
|
+
global _graph
|
|
454
|
+
|
|
455
|
+
if _graph is None:
|
|
456
|
+
print('\nricgraph_nr_nodes(): Error: graph has not been initialized or opened.\n\n')
|
|
457
|
+
return -1
|
|
458
|
+
|
|
459
|
+
cypher_query = 'MATCH () RETURN count(*) AS count'
|
|
460
|
+
result = _graph.execute_query(cypher_query,
|
|
461
|
+
result_transformer_=Result.data,
|
|
462
|
+
database_=ricgraph_databasename())
|
|
463
|
+
if len(result) == 0:
|
|
464
|
+
return -1
|
|
465
|
+
result = result[0]
|
|
466
|
+
if 'count' not in result:
|
|
467
|
+
return -1
|
|
468
|
+
result = int(result['count'])
|
|
469
|
+
return result
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def ricgraph_nr_edges() -> int:
|
|
473
|
+
"""Count the number of edges in Ricgraph.
|
|
474
|
+
|
|
475
|
+
:return: the number of edges, or -1 on error.
|
|
476
|
+
"""
|
|
477
|
+
global _graph
|
|
478
|
+
|
|
479
|
+
if _graph is None:
|
|
480
|
+
print('\nricgraph_nr_edges(): Error: graph has not been initialized or opened.\n\n')
|
|
481
|
+
return -1
|
|
482
|
+
|
|
483
|
+
cypher_query = 'MATCH ()-[r]->() RETURN count(r) AS count'
|
|
484
|
+
result = _graph.execute_query(cypher_query,
|
|
485
|
+
result_transformer_=Result.data,
|
|
486
|
+
database_=ricgraph_databasename())
|
|
487
|
+
if len(result) == 0:
|
|
488
|
+
return -1
|
|
489
|
+
result = result[0]
|
|
490
|
+
if 'count' not in result:
|
|
491
|
+
return -1
|
|
492
|
+
result = int(result['count'])
|
|
493
|
+
return result
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def ricgraph_nr_edges_of_node(node_element_id: str) -> int:
|
|
497
|
+
"""Count the number of edges in Ricgraph of a given node.
|
|
498
|
+
|
|
499
|
+
:param node_element_id: the element_id of the node.
|
|
500
|
+
:return: the number of nodes, or -1 on error.
|
|
501
|
+
"""
|
|
502
|
+
global _graph
|
|
503
|
+
|
|
504
|
+
if _graph is None:
|
|
505
|
+
print('\nricgraph_nr_edges_of_node(): Error: graph has not been initialized or opened.\n\n')
|
|
506
|
+
return -1
|
|
507
|
+
|
|
508
|
+
cypher_query = 'MATCH (node:RicgraphNode)-[r]->() '
|
|
509
|
+
if ricgraph_database() == 'neo4j':
|
|
510
|
+
cypher_query += 'WHERE elementId(node)=$node_element_id '
|
|
511
|
+
else:
|
|
512
|
+
cypher_query += 'WHERE id(node)=toInteger($node_element_id) '
|
|
513
|
+
cypher_query += 'RETURN count(r) AS count'
|
|
514
|
+
|
|
515
|
+
result = _graph.execute_query(cypher_query,
|
|
516
|
+
node_element_id=node_element_id,
|
|
517
|
+
result_transformer_=Result.data,
|
|
518
|
+
database_=ricgraph_databasename())
|
|
519
|
+
if len(result) == 0:
|
|
520
|
+
return -1
|
|
521
|
+
result = result[0]
|
|
522
|
+
if 'count' not in result:
|
|
523
|
+
return -1
|
|
524
|
+
result = int(result['count'])
|
|
525
|
+
return result
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def cypher_create_node(node_properties: dict) -> Union[Node, None]:
|
|
529
|
+
"""
|
|
530
|
+
Create a node in the graph database.
|
|
531
|
+
Create means: the node will be created, regardless if it is already
|
|
532
|
+
present or not. No test for presence of a node is done.
|
|
533
|
+
The properties and their values in 'node_properties' will be added
|
|
534
|
+
as properties & values of the node.
|
|
535
|
+
The 'name', 'category', and 'value' properties are expected to be
|
|
536
|
+
in 'node_properties'.
|
|
537
|
+
|
|
538
|
+
:param node_properties: the properties of the node.
|
|
539
|
+
:return: the node updated, or None on error.
|
|
540
|
+
"""
|
|
541
|
+
global graphdb_nr_creates
|
|
542
|
+
|
|
543
|
+
# There are several methods for creating a node in the graph database.
|
|
544
|
+
# This would be an alternative, but it seems to use more memory than the
|
|
545
|
+
# one used now, according to 'PROFILE <cypher query>'.
|
|
546
|
+
# cypher_query = 'CREATE (node:RicgraphNode) SET node=$node_properties ' [etc.]
|
|
547
|
+
cypher_query = 'CREATE (node:RicgraphNode $node_properties) RETURN node'
|
|
548
|
+
|
|
549
|
+
# print('cypher_create_node(): cypher_query: ' + cypher_query)
|
|
550
|
+
# print(' node_properties: ' + str(node_properties))
|
|
551
|
+
|
|
552
|
+
nodes = _graph.execute_query(cypher_query,
|
|
553
|
+
node_properties=node_properties,
|
|
554
|
+
result_transformer_=Result.value,
|
|
555
|
+
database_=ricgraph_databasename())
|
|
556
|
+
graphdb_nr_creates += 1
|
|
557
|
+
if len(nodes) == 0:
|
|
558
|
+
return None
|
|
559
|
+
else:
|
|
560
|
+
return nodes[0]
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def cypher_update_node_properties(node_element_id: str, node_properties: dict) -> Union[Node, None]:
|
|
564
|
+
"""
|
|
565
|
+
Update node properties in a node in the graph database.
|
|
566
|
+
If the node is not present, nothing will happen.
|
|
567
|
+
Only the properties and their values in 'node_properties' will be changed (if
|
|
568
|
+
a property is present in the node) or added (if the property is not present).
|
|
569
|
+
All other properties (i.e. not specified properties in 'node_properties')
|
|
570
|
+
will be left as they are.
|
|
571
|
+
|
|
572
|
+
:param node_element_id: the element_id of the node.
|
|
573
|
+
:param node_properties: the properties of the node.
|
|
574
|
+
:return: the node updated, or None on error.
|
|
575
|
+
"""
|
|
576
|
+
global graphdb_nr_reads, graphdb_nr_updates
|
|
577
|
+
|
|
578
|
+
cypher_query = 'MATCH (node:RicgraphNode) '
|
|
579
|
+
if ricgraph_database() == 'neo4j':
|
|
580
|
+
cypher_query += 'WHERE elementId(node)=$node_element_id '
|
|
581
|
+
else:
|
|
582
|
+
cypher_query += 'WHERE id(node)=toInteger($node_element_id) '
|
|
583
|
+
cypher_query += 'SET node+=$node_properties RETURN node'
|
|
584
|
+
|
|
585
|
+
# print('cypher_update_node_properties(): node_element_id: ' + str(node_element_id) +
|
|
586
|
+
# ', cypher_query: ' + cypher_query)
|
|
587
|
+
# print(' node_properties: ' + str(node_properties))
|
|
588
|
+
|
|
589
|
+
nodes = _graph.execute_query(cypher_query,
|
|
590
|
+
node_element_id=node_element_id,
|
|
591
|
+
node_properties=node_properties,
|
|
592
|
+
result_transformer_=Result.value,
|
|
593
|
+
database_=ricgraph_databasename())
|
|
594
|
+
graphdb_nr_reads += 1
|
|
595
|
+
graphdb_nr_updates += 1
|
|
596
|
+
if len(nodes) == 0:
|
|
597
|
+
return None
|
|
598
|
+
else:
|
|
599
|
+
return nodes[0]
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def cypher_merge_nodes(node_merge_from_element_id: str,
|
|
603
|
+
node_merge_to_element_id: str,
|
|
604
|
+
node_merge_to_properties: dict) -> Union[Node, None]:
|
|
605
|
+
"""Merge two nodes in the graph database.
|
|
606
|
+
The neighbors of 'node_merge_from' will be merged to node 'node_merge_to'
|
|
607
|
+
and 'node_merge_from' will be deleted.
|
|
608
|
+
The node properties in 'node_merge_to' will be set according to
|
|
609
|
+
'node_merge_to_properties'.
|
|
610
|
+
|
|
611
|
+
Only the properties and their values in 'node_properties' will be changed (if
|
|
612
|
+
a property is present in the node) or added (if the property is not present).
|
|
613
|
+
All other properties (i.e. not specified properties in 'node_properties')
|
|
614
|
+
will be left as they are.
|
|
615
|
+
|
|
616
|
+
:param node_merge_from_element_id: the element_id of node_merge_from.
|
|
617
|
+
:param node_merge_to_element_id: the element_id of node_merge_to.
|
|
618
|
+
:param node_merge_to_properties: the properties of node_merge_to.
|
|
619
|
+
:return: the node updated, or None on error.
|
|
620
|
+
"""
|
|
621
|
+
global graphdb_nr_reads, graphdb_nr_updates
|
|
622
|
+
|
|
623
|
+
graphdb_name = ricgraph_database()
|
|
624
|
+
|
|
625
|
+
if node_merge_from_element_id == node_merge_to_element_id:
|
|
626
|
+
# We only update node properties.
|
|
627
|
+
node = cypher_update_node_properties(node_element_id=node_merge_to_element_id,
|
|
628
|
+
node_properties=node_merge_to_properties)
|
|
629
|
+
return node
|
|
630
|
+
|
|
631
|
+
cypher_query = 'MATCH (node_from:RicgraphNode) '
|
|
632
|
+
if graphdb_name == 'neo4j':
|
|
633
|
+
cypher_query += 'WHERE elementId(node_from)=$node_merge_from_element_id '
|
|
634
|
+
else:
|
|
635
|
+
cypher_query += 'WHERE id(node_from)=toInteger($node_merge_from_element_id) '
|
|
636
|
+
cypher_query += 'MATCH (node_to:RicgraphNode) '
|
|
637
|
+
if graphdb_name == 'neo4j':
|
|
638
|
+
cypher_query += 'WHERE elementId(node_to)=$node_merge_to_element_id '
|
|
639
|
+
else:
|
|
640
|
+
cypher_query += 'WHERE id(node_to)=toInteger($node_merge_to_element_id) '
|
|
641
|
+
|
|
642
|
+
cypher_query += 'SET node_to+=$node_merge_to_properties '
|
|
643
|
+
cypher_query += 'WITH node_from, node_to '
|
|
644
|
+
# Only test for an edge in one direction, that is sufficient.
|
|
645
|
+
cypher_query += 'MATCH (node_from)-[:LINKS_TO]->(neighbor_node) '
|
|
646
|
+
# Prevent creating a self-relationship on node_to.
|
|
647
|
+
cypher_query += 'WHERE node_to <> neighbor_node '
|
|
648
|
+
cypher_query += 'MERGE (node_to)-[:LINKS_TO]->(neighbor_node) '
|
|
649
|
+
cypher_query += 'MERGE (node_to)<-[:LINKS_TO]-(neighbor_node) '
|
|
650
|
+
cypher_query += 'DETACH DELETE node_from '
|
|
651
|
+
cypher_query += 'RETURN node_to'
|
|
652
|
+
|
|
653
|
+
# print('cypher_merge_nodes(): node_merge_from_element_id: ' + str(node_merge_from_element_id))
|
|
654
|
+
# print(' node_merge_to_element_id: ' + str(node_merge_to_element_id))
|
|
655
|
+
# print(' node_merge_to_properties: ' + str(node_merge_to_properties))
|
|
656
|
+
# print(' cypher_query: ' + cypher_query)
|
|
657
|
+
|
|
658
|
+
nodes = _graph.execute_query(cypher_query,
|
|
659
|
+
node_merge_from_element_id=node_merge_from_element_id,
|
|
660
|
+
node_merge_to_element_id=node_merge_to_element_id,
|
|
661
|
+
node_merge_to_properties=node_merge_to_properties,
|
|
662
|
+
result_transformer_=Result.value,
|
|
663
|
+
database_=ricgraph_databasename())
|
|
664
|
+
|
|
665
|
+
# If merge_node_from does not have neighbors, it will not be deleted.
|
|
666
|
+
# Do another Cypher query to be sure it is gone.
|
|
667
|
+
cypher_query = 'MATCH (node_from:RicgraphNode) '
|
|
668
|
+
if graphdb_name == 'neo4j':
|
|
669
|
+
cypher_query += 'WHERE elementId(node_from)=$node_merge_from_element_id '
|
|
670
|
+
else:
|
|
671
|
+
cypher_query += 'WHERE id(node_from)=toInteger($node_merge_from_element_id) '
|
|
672
|
+
cypher_query += 'DETACH DELETE node_from'
|
|
673
|
+
_graph.execute_query(cypher_query,
|
|
674
|
+
node_merge_from_element_id=node_merge_from_element_id,
|
|
675
|
+
result_transformer_=Result.value,
|
|
676
|
+
database_=ricgraph_databasename())
|
|
677
|
+
|
|
678
|
+
nr_edges = ricgraph_nr_edges_of_node(node_element_id=node_merge_to_element_id)
|
|
679
|
+
graphdb_nr_reads += 2 * nr_edges # Approximation: edges are in two directions.
|
|
680
|
+
graphdb_nr_updates += 2 * nr_edges # Approximation: edges are in two directions.
|
|
681
|
+
if len(nodes) == 0:
|
|
682
|
+
return None
|
|
683
|
+
else:
|
|
684
|
+
return nodes[0]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def cypher_create_edge_if_not_exists(left_node_element_id: str, right_node_element_id: str) -> None:
|
|
688
|
+
"""Create an edge between two nodes, but only if the edge does not exist.
|
|
689
|
+
|
|
690
|
+
:param left_node_element_id: the element_id of the left node.
|
|
691
|
+
:param right_node_element_id: the element_id of the right node.
|
|
692
|
+
:return: None.
|
|
693
|
+
"""
|
|
694
|
+
global graphdb_nr_reads, graphdb_nr_updates
|
|
695
|
+
|
|
696
|
+
graphdb_name = ricgraph_database()
|
|
697
|
+
|
|
698
|
+
# There are several methods for getting the nodes in the graph database.
|
|
699
|
+
# This one takes the most time.
|
|
700
|
+
# It amounts to ?? database hits according to 'PROFILE <cypher query>', using 'AllNodesScan'.
|
|
701
|
+
# cypher_query = 'MATCH (left_node {ID:' + str(left_node.element_id) + '}) ' [etc.]
|
|
702
|
+
# The next one takes average time.
|
|
703
|
+
# It amounts to 23 database hits according to 'PROFILE', using 'AllNodesScan'.
|
|
704
|
+
# cypher_query = 'MATCH (left_node) WHERE left_node._key="' + str(left_node['_key']) + '" ' [etc.]
|
|
705
|
+
# The next one is the fastest.
|
|
706
|
+
# It amounts to 1 database hits according to 'PROFILE' using 'NodeByIdSeek'.
|
|
707
|
+
cypher_query = 'MATCH (left_node:RicgraphNode) '
|
|
708
|
+
if graphdb_name == 'neo4j':
|
|
709
|
+
cypher_query += 'WHERE elementId(left_node)=$left_node_element_id '
|
|
710
|
+
else:
|
|
711
|
+
cypher_query += 'WHERE id(left_node)=toInteger($left_node_element_id) '
|
|
712
|
+
cypher_query += 'MATCH (right_node:RicgraphNode) '
|
|
713
|
+
if graphdb_name == 'neo4j':
|
|
714
|
+
cypher_query += 'WHERE elementId(right_node)=$right_node_element_id '
|
|
715
|
+
else:
|
|
716
|
+
cypher_query += 'WHERE id(right_node)=toInteger($right_node_element_id) '
|
|
717
|
+
|
|
718
|
+
# We could use 'CREATE', but then we may get multiple edges for the same direction.
|
|
719
|
+
# cypher_query += 'CREATE (left_node)-[:LINKS_TO]->(right_node), ' [etc.]
|
|
720
|
+
# A MERGE is more expensive since it first checks for presence of the edge.
|
|
721
|
+
cypher_query += 'MERGE (left_node)-[:LINKS_TO]->(right_node) '
|
|
722
|
+
cypher_query += 'MERGE (left_node)<-[:LINKS_TO]-(right_node) '
|
|
723
|
+
|
|
724
|
+
# print('cypher_create_edge_if_not_exists(): left_node_id: ' + str(left_node_id) + ', right_node_id: '
|
|
725
|
+
# + str(right_node_id) + ', cypher_query: ' + cypher_query)
|
|
726
|
+
|
|
727
|
+
_graph.execute_query(cypher_query,
|
|
728
|
+
left_node_element_id=left_node_element_id,
|
|
729
|
+
right_node_element_id=right_node_element_id,
|
|
730
|
+
database_=ricgraph_databasename())
|
|
731
|
+
graphdb_nr_reads += 2 # one for left_node, one for right_node
|
|
732
|
+
graphdb_nr_updates += 2 # one for ->, one for <-.
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# ##############################################################################
|
|
737
|
+
# General Ricgraph functions.
|
|
738
|
+
# ##############################################################################
|
|
739
|
+
def get_ricgraph_ini_file() -> str:
|
|
740
|
+
"""Get the location of the ricgraph ini file.
|
|
741
|
+
|
|
742
|
+
:return: the location of the ini file.
|
|
743
|
+
"""
|
|
744
|
+
# Try to find RICGRAPH_INI_FILENAME in the root of the virtual environment.
|
|
745
|
+
ricgraph_ini_path = sys.prefix
|
|
746
|
+
ricgraph_ini = os.path.join(ricgraph_ini_path, RICGRAPH_INI_FILENAME)
|
|
747
|
+
if os.path.exists(ricgraph_ini):
|
|
748
|
+
return ricgraph_ini
|
|
749
|
+
|
|
750
|
+
# Try to find RICGRAPH_INI_FILENAME in the parent directory of the venv,
|
|
751
|
+
# which may happen when using a Python IDE.
|
|
752
|
+
ricgraph_ini_path_parent = os.path.dirname(ricgraph_ini_path)
|
|
753
|
+
ricgraph_ini = os.path.join(ricgraph_ini_path_parent, RICGRAPH_INI_FILENAME)
|
|
754
|
+
if os.path.exists(ricgraph_ini):
|
|
755
|
+
return ricgraph_ini
|
|
756
|
+
|
|
757
|
+
print('Ricgraph initialization: error, Ricgraph ini file "' + RICGRAPH_INI_FILENAME + '" not found in')
|
|
758
|
+
print(' directory "' + ricgraph_ini_path + '", nor in')
|
|
759
|
+
print(' directory "' + ricgraph_ini_path_parent + '", exiting.')
|
|
760
|
+
exit(1)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def timestamp(seconds: bool = False) -> str:
|
|
764
|
+
"""Get a timestamp only consisting of a time.
|
|
765
|
+
|
|
766
|
+
:param seconds: If True, also show seconds in the timestamp.
|
|
767
|
+
:return: the timestamp.
|
|
768
|
+
"""
|
|
769
|
+
now = datetime.now()
|
|
770
|
+
if seconds:
|
|
771
|
+
time_stamp = now.strftime("%H:%M:%S")
|
|
772
|
+
else:
|
|
773
|
+
time_stamp = now.strftime("%H:%M")
|
|
774
|
+
return time_stamp
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def datetimestamp(seconds: bool = False) -> str:
|
|
778
|
+
"""Get a timestamp consisting of a date and a time.
|
|
779
|
+
|
|
780
|
+
:param seconds: If True, also show seconds in the timestamp.
|
|
781
|
+
:return: the timestamp.
|
|
782
|
+
"""
|
|
783
|
+
now = datetime.now()
|
|
784
|
+
if seconds:
|
|
785
|
+
datetime_stamp = now.strftime("%Y-%m-%d %H:%M:%S")
|
|
786
|
+
else:
|
|
787
|
+
datetime_stamp = now.strftime("%Y-%m-%d %H:%M")
|
|
788
|
+
return datetime_stamp
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def graphdb_nr_accesses_reset() -> None:
|
|
792
|
+
"""Reset the counters that are used to count the number of accesses
|
|
793
|
+
to the graph database backend.
|
|
794
|
+
|
|
795
|
+
:return: None.
|
|
796
|
+
"""
|
|
797
|
+
global graphdb_nr_creates, graphdb_nr_reads
|
|
798
|
+
global graphdb_nr_updates, graphdb_nr_deletes
|
|
799
|
+
|
|
800
|
+
graphdb_nr_creates = 0
|
|
801
|
+
graphdb_nr_reads = 0
|
|
802
|
+
graphdb_nr_updates = 0
|
|
803
|
+
graphdb_nr_deletes = 0
|
|
804
|
+
return
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def graphdb_nr_accesses_print() -> None:
|
|
808
|
+
"""Print the values of the counters for the number of accesses to
|
|
809
|
+
the graph database backend.
|
|
810
|
+
|
|
811
|
+
:return: None.
|
|
812
|
+
"""
|
|
813
|
+
# These counters are used to count the number of accesses to the graph database.
|
|
814
|
+
global graphdb_nr_creates, graphdb_nr_reads, graphdb_nr_updates, graphdb_nr_deletes
|
|
815
|
+
|
|
816
|
+
graphdb_total_accesses = graphdb_nr_creates + graphdb_nr_reads
|
|
817
|
+
graphdb_total_accesses += (2 * graphdb_nr_updates) + graphdb_nr_deletes
|
|
818
|
+
nr_nodes = ricgraph_nr_nodes()
|
|
819
|
+
nr_edges = ricgraph_nr_edges()
|
|
820
|
+
|
|
821
|
+
print('\n')
|
|
822
|
+
print('These are the number of accesses to the graph database backend at ' + datetimestamp() + ':')
|
|
823
|
+
print('- total number of creates: {:>10}'.format(graphdb_nr_creates))
|
|
824
|
+
print('- total number of reads: {:>10}'.format(graphdb_nr_reads))
|
|
825
|
+
print('- total number of updates: {:>10}'.format(graphdb_nr_updates))
|
|
826
|
+
print('- total number of deletes: {:>10}'.format(graphdb_nr_deletes))
|
|
827
|
+
print('- total number of accesses: {:>10}'.format(graphdb_total_accesses))
|
|
828
|
+
print('Please note that for the total number of accesses, an "update" counts twice: '
|
|
829
|
+
+ 'one for the read and one for the update of the value.')
|
|
830
|
+
print('\n')
|
|
831
|
+
print('These are the number of nodes and edges in Ricgraph:')
|
|
832
|
+
print('- total number of nodes: {:>8}'.format(nr_nodes))
|
|
833
|
+
print('- total number of edges: {:>8}'.format(nr_edges))
|
|
834
|
+
print('\n')
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def create_ricgraph_key(name: str, value: str) -> str:
|
|
839
|
+
"""Create a key for a node.
|
|
840
|
+
This function generates a composite key for a node in a graph.
|
|
841
|
+
|
|
842
|
+
:param name: name of the node.
|
|
843
|
+
:param value: value of the node.
|
|
844
|
+
:return: key generated for the node.
|
|
845
|
+
"""
|
|
846
|
+
# Check and correct for occurrences of RICGRAPH_KEY_SEPARATOR.
|
|
847
|
+
name = name.replace(RICGRAPH_KEY_SEPARATOR, '#')
|
|
848
|
+
value = value.replace(RICGRAPH_KEY_SEPARATOR, '#')
|
|
849
|
+
|
|
850
|
+
return value.lower() + RICGRAPH_KEY_SEPARATOR + name.lower()
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def get_namepart_from_ricgraph_key(key: str) -> str:
|
|
854
|
+
"""Get the 'name' part from the composite key in a node.
|
|
855
|
+
|
|
856
|
+
:param key: key of the node.
|
|
857
|
+
:return: name part of key for the node.
|
|
858
|
+
"""
|
|
859
|
+
key_list = key.split(sep=RICGRAPH_KEY_SEPARATOR)
|
|
860
|
+
if len(key_list) != 2:
|
|
861
|
+
return ''
|
|
862
|
+
return key_list[1]
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def get_valuepart_from_ricgraph_key(key: str) -> str:
|
|
866
|
+
"""Get the 'value' part from the composite key in a node.
|
|
867
|
+
|
|
868
|
+
:param key: key of the node.
|
|
869
|
+
:return: value part of key for the node.
|
|
870
|
+
"""
|
|
871
|
+
key_list = key.split(sep=RICGRAPH_KEY_SEPARATOR)
|
|
872
|
+
if len(key_list) != 2:
|
|
873
|
+
return ''
|
|
874
|
+
return key_list[0]
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def create_well_known_url(name: str, value: str) -> str:
|
|
878
|
+
"""Create a URL to refer to the source of a 'well known' identifier (i.e. ORCID, ISNI, etc.).
|
|
879
|
+
|
|
880
|
+
:param name: an identifier name, e.g. ORCID, ISNI, etc.
|
|
881
|
+
:param value: the value.
|
|
882
|
+
:return: URL.
|
|
883
|
+
"""
|
|
884
|
+
if name == '' or value == '':
|
|
885
|
+
return ''
|
|
886
|
+
|
|
887
|
+
if name == 'DOI':
|
|
888
|
+
return 'https://doi.org/' + value
|
|
889
|
+
elif name == 'GITHUB':
|
|
890
|
+
return 'https://www.github.com/' + value
|
|
891
|
+
elif name == 'ISNI':
|
|
892
|
+
return 'https://isni.org/isni/' + value
|
|
893
|
+
elif name == 'LINKEDIN':
|
|
894
|
+
return 'https://www.linkedin.com/in/' + value
|
|
895
|
+
# elif name == 'OPENALEX':
|
|
896
|
+
# return 'https://openalex.org/' + value
|
|
897
|
+
elif name == 'ORCID':
|
|
898
|
+
return 'https://orcid.org/' + value
|
|
899
|
+
elif name == 'RESEARCHER_ID':
|
|
900
|
+
return 'https://www.webofscience.com/wos/author/record/' + value
|
|
901
|
+
elif name == 'ROR':
|
|
902
|
+
return 'https://ror.org/' + value
|
|
903
|
+
elif name == 'SCOPUS_AUTHOR_ID':
|
|
904
|
+
return 'https://www.scopus.com/authid/detail.uri?authorId=' + value
|
|
905
|
+
elif name == 'TWITTER':
|
|
906
|
+
return 'https://www.twitter.com/' + value
|
|
907
|
+
else:
|
|
908
|
+
return ''
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def create_history_line(property_name: str, old_value: str, new_value: str) -> str:
|
|
912
|
+
"""Create a line to be used in the 'history' property of a node.
|
|
913
|
+
"""
|
|
914
|
+
return 'Updated property "' + property_name + '" from "' + old_value + '" to "' + new_value + '". '
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def create_name_cache_in_personroot(node: Node, personroot: Node):
|
|
918
|
+
"""This function caches the value of a node with 'name' 'FULL_NAME'
|
|
919
|
+
in the 'comment' property of its person-root node 'personroot'.
|
|
920
|
+
If the cache is not present, it is created.
|
|
921
|
+
|
|
922
|
+
:param node: node to add FULL_NAME to cache.
|
|
923
|
+
:param personroot: personroot node of 'node'. For efficiency, we do not check
|
|
924
|
+
if this really is the case.
|
|
925
|
+
:return: No return value.
|
|
926
|
+
"""
|
|
927
|
+
if node is None:
|
|
928
|
+
return
|
|
929
|
+
if personroot is None:
|
|
930
|
+
return
|
|
931
|
+
if node['name'] == 'FULL_NAME':
|
|
932
|
+
time_stamp = datetimestamp()
|
|
933
|
+
if isinstance(personroot['comment'], str):
|
|
934
|
+
if personroot['comment'] == '':
|
|
935
|
+
old_value = str(personroot['comment']) # Not necessary, for symmetry.
|
|
936
|
+
node_properties = {'comment': [node['value']]}
|
|
937
|
+
history_line = create_history_line(property_name='comment',
|
|
938
|
+
old_value=old_value,
|
|
939
|
+
new_value=str(node_properties['comment']))
|
|
940
|
+
node_properties['_history'] = personroot['_history'].copy()
|
|
941
|
+
node_properties['_history'].append(time_stamp + ': ' + history_line)
|
|
942
|
+
cypher_update_node_properties(node_element_id=personroot.element_id,
|
|
943
|
+
node_properties=node_properties)
|
|
944
|
+
elif isinstance(personroot['comment'], list):
|
|
945
|
+
if node['value'] not in personroot['comment']:
|
|
946
|
+
old_value = str(personroot['comment'])
|
|
947
|
+
node_properties = {'comment': personroot['comment'].copy()}
|
|
948
|
+
node_properties['comment'].append(node['value'])
|
|
949
|
+
history_line = create_history_line(property_name='comment',
|
|
950
|
+
old_value=old_value,
|
|
951
|
+
new_value=str(node_properties['comment']))
|
|
952
|
+
node_properties['_history'] = personroot['_history'].copy()
|
|
953
|
+
node_properties['_history'].append(time_stamp + ': ' + history_line)
|
|
954
|
+
cypher_update_node_properties(node_element_id=personroot.element_id,
|
|
955
|
+
node_properties=node_properties)
|
|
956
|
+
# In all other cases: it is already in the cache,
|
|
957
|
+
# or leave it as it is: 'comment' seems to be used for something we don't know.
|
|
958
|
+
return
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def recreate_name_cache_in_personroot(personroot: Node):
|
|
962
|
+
"""This function recreates the cache of all the nodes with 'name' 'FULL_NAME'
|
|
963
|
+
in the 'comment' property of its person-root node 'personroot'.
|
|
964
|
+
If the cache is not present, it is created.
|
|
965
|
+
|
|
966
|
+
:param personroot: personroot node of 'node'. For efficiency, we do not check
|
|
967
|
+
if this really is the case.
|
|
968
|
+
:return: No return value.
|
|
969
|
+
"""
|
|
970
|
+
if personroot is None:
|
|
971
|
+
return
|
|
972
|
+
neighbornodes = get_all_neighbor_nodes(node=personroot)
|
|
973
|
+
name_cache = []
|
|
974
|
+
for node in neighbornodes:
|
|
975
|
+
if node['name'] == 'FULL_NAME':
|
|
976
|
+
if node['value'] not in name_cache:
|
|
977
|
+
name_cache.append(node['value'])
|
|
978
|
+
|
|
979
|
+
if len(name_cache) != 0:
|
|
980
|
+
if (isinstance(personroot['comment'], str) and personroot['comment'] == '') \
|
|
981
|
+
or isinstance(personroot['comment'], list):
|
|
982
|
+
time_stamp = datetimestamp()
|
|
983
|
+
history_line = create_history_line(property_name='comment',
|
|
984
|
+
old_value=str(personroot['comment']),
|
|
985
|
+
new_value=str(name_cache))
|
|
986
|
+
node_properties = {'comment': name_cache.copy(),
|
|
987
|
+
'_history': personroot['_history'].copy()}
|
|
988
|
+
node_properties['_history'].append(time_stamp + ': Cleaned and recreated name cache. ' + history_line)
|
|
989
|
+
cypher_update_node_properties(node_element_id=personroot.element_id,
|
|
990
|
+
node_properties=node_properties)
|
|
991
|
+
# In all other cases: leave it as it is: 'comment' seems to be used for something we don't know.
|
|
992
|
+
return
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def create_update_node(name: str, category: str, value: str,
|
|
996
|
+
other_properties: dict = None) -> Union[Node, None]:
|
|
997
|
+
"""Create a node, or update its values if is already present.
|
|
998
|
+
The new/updated node will have values 'name', 'category', and 'value', and
|
|
999
|
+
the values in other_properties.
|
|
1000
|
+
For now all properties will have a string value, except for '_source'
|
|
1001
|
+
(a sorted list), and '_history' (a list).
|
|
1002
|
+
Changing the 'name' or 'value' properties (and, subsequently, the '_key' value),
|
|
1003
|
+
cannot be done with this function, since in that case we would not be able
|
|
1004
|
+
to find it.
|
|
1005
|
+
|
|
1006
|
+
:param name: 'name' property of node to create or update.
|
|
1007
|
+
:param category: 'category' property of node.
|
|
1008
|
+
:param value: 'value' property of node.
|
|
1009
|
+
:param other_properties: a dictionary of all the other properties.
|
|
1010
|
+
:return: the node created, or None if this was not possible
|
|
1011
|
+
"""
|
|
1012
|
+
if other_properties is None:
|
|
1013
|
+
other_properties = {}
|
|
1014
|
+
global _graph
|
|
1015
|
+
|
|
1016
|
+
if _graph is None:
|
|
1017
|
+
print('\ncreate_update_node(): Error: graph has not been initialized or opened.\n\n')
|
|
1018
|
+
return None
|
|
1019
|
+
|
|
1020
|
+
if not isinstance(name, str) \
|
|
1021
|
+
or not isinstance(category, str) \
|
|
1022
|
+
or not isinstance(value, str):
|
|
1023
|
+
return None
|
|
1024
|
+
|
|
1025
|
+
if other_properties is None:
|
|
1026
|
+
other_properties = {}
|
|
1027
|
+
|
|
1028
|
+
lname = str(name)
|
|
1029
|
+
lcategory = str(category)
|
|
1030
|
+
lvalue = str(value)
|
|
1031
|
+
|
|
1032
|
+
if lname == '' or lcategory == '' or lvalue == '':
|
|
1033
|
+
return None
|
|
1034
|
+
|
|
1035
|
+
node_properties = {}
|
|
1036
|
+
time_stamp = datetimestamp()
|
|
1037
|
+
node = read_node(name=lname, value=lvalue)
|
|
1038
|
+
if node is None:
|
|
1039
|
+
# Create a node.
|
|
1040
|
+
# First do the properties in RICGRAPH_PROPERTIES_STANDARD.
|
|
1041
|
+
node_properties['name'] = lname
|
|
1042
|
+
node_properties['category'] = lcategory
|
|
1043
|
+
node_properties['value'] = lvalue
|
|
1044
|
+
|
|
1045
|
+
# Then do the properties in RICGRAPH_PROPERTIES_ADDITIONAL.
|
|
1046
|
+
for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
|
|
1047
|
+
if prop_name in other_properties:
|
|
1048
|
+
node_properties[prop_name] = str(other_properties[prop_name])
|
|
1049
|
+
else:
|
|
1050
|
+
node_properties[prop_name] = ''
|
|
1051
|
+
|
|
1052
|
+
# If no url_main has been passed, insert an ISNI, DOI, etc. url if category is ISNI, DOI, etc.
|
|
1053
|
+
url = create_well_known_url(name=lname, value=lvalue)
|
|
1054
|
+
if node_properties['url_main'] == '' and url != '':
|
|
1055
|
+
node_properties['url_main'] = url
|
|
1056
|
+
|
|
1057
|
+
# Then do the properties in RICGRAPH_PROPERTIES_HIDDEN.
|
|
1058
|
+
node_properties['_key'] = create_ricgraph_key(name=lname, value=lvalue)
|
|
1059
|
+
if node_properties['source_event'] == '':
|
|
1060
|
+
node_properties['_source'] = []
|
|
1061
|
+
else:
|
|
1062
|
+
node_properties['_source'] = [node_properties['source_event']]
|
|
1063
|
+
node_properties['source_event'] = ''
|
|
1064
|
+
|
|
1065
|
+
if node_properties['history_event'] == '':
|
|
1066
|
+
node_properties['_history'] = [time_stamp + ': Created. ']
|
|
1067
|
+
else:
|
|
1068
|
+
node_properties['_history'] = [time_stamp + ': Created. ' + node_properties['history_event']]
|
|
1069
|
+
node_properties['history_event'] = ''
|
|
1070
|
+
|
|
1071
|
+
new_node = cypher_create_node(node_properties=node_properties)
|
|
1072
|
+
return new_node
|
|
1073
|
+
|
|
1074
|
+
if RICGRAPH_NODEADD_MODE == 'strict' and node['name'] == 'FULL_NAME':
|
|
1075
|
+
# We only get here if we want to connect some other node A to this FULL_NAME node B.
|
|
1076
|
+
# This FULL_NAME node B already exists. Most probably it is connected to a person-root C
|
|
1077
|
+
# and that person-root C to some other nodes D and E.
|
|
1078
|
+
# If we would continue, A would be connected to B, and also to C, D and E.
|
|
1079
|
+
# However, connecting based on a name is not a very good idea, since two
|
|
1080
|
+
# different persons may have the same name.
|
|
1081
|
+
# So don't do this if we are in NODEADD_MODE 'strict'.
|
|
1082
|
+
return None
|
|
1083
|
+
|
|
1084
|
+
# Update a node.
|
|
1085
|
+
history_line = ''
|
|
1086
|
+
|
|
1087
|
+
# First do the properties in RICGRAPH_PROPERTIES_STANDARD.
|
|
1088
|
+
# It is not possible to update 'name' or 'value'.
|
|
1089
|
+
if node['category'] != lcategory:
|
|
1090
|
+
history_line += create_history_line(property_name='category',
|
|
1091
|
+
old_value=node['category'],
|
|
1092
|
+
new_value=lcategory)
|
|
1093
|
+
node_properties['category'] = lcategory
|
|
1094
|
+
|
|
1095
|
+
# Then do the properties in RICGRAPH_PROPERTIES_ADDITIONAL.
|
|
1096
|
+
for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
|
|
1097
|
+
if prop_name not in other_properties:
|
|
1098
|
+
continue
|
|
1099
|
+
if prop_name == 'history_event' or prop_name == 'source_event':
|
|
1100
|
+
# Do not add these to node_properties, and don't add them to _history.
|
|
1101
|
+
continue
|
|
1102
|
+
# Only in case a property is in other_properties, its value may change.
|
|
1103
|
+
present_val = str(node[prop_name])
|
|
1104
|
+
if present_val != str(other_properties[prop_name]):
|
|
1105
|
+
# But its value is only changed if it is different then the old value.
|
|
1106
|
+
node_properties[prop_name] = str(other_properties[prop_name])
|
|
1107
|
+
history_line += create_history_line(property_name=prop_name,
|
|
1108
|
+
old_value=present_val,
|
|
1109
|
+
new_value=str(other_properties[prop_name]))
|
|
1110
|
+
|
|
1111
|
+
# Finally do the properties in RICGRAPH_PROPERTIES_HIDDEN.
|
|
1112
|
+
# It is not possible to update '_key'.
|
|
1113
|
+
if 'source_event' in other_properties:
|
|
1114
|
+
if other_properties['source_event'] != '':
|
|
1115
|
+
if other_properties['source_event'] not in node['_source']:
|
|
1116
|
+
node_properties['_source'] = node['_source'].copy()
|
|
1117
|
+
node_properties['_source'].append(other_properties['source_event'])
|
|
1118
|
+
node_properties['_source'].sort()
|
|
1119
|
+
history_line += create_history_line(property_name='_source',
|
|
1120
|
+
old_value=str(node['_source']),
|
|
1121
|
+
new_value=str(node_properties['_source']))
|
|
1122
|
+
|
|
1123
|
+
if history_line == '':
|
|
1124
|
+
# No changes.
|
|
1125
|
+
return node
|
|
1126
|
+
|
|
1127
|
+
node_properties['_history'] = node['_history'].copy()
|
|
1128
|
+
node_properties['_history'].append(time_stamp + ': Updated. ' + history_line)
|
|
1129
|
+
updated_node = cypher_update_node_properties(node_element_id=node.element_id,
|
|
1130
|
+
node_properties=node_properties)
|
|
1131
|
+
return updated_node
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def read_node(name: str = '', value: str = '') -> Union[Node, None]:
|
|
1135
|
+
"""Read a node based on name and value.
|
|
1136
|
+
Since all nodes are supposed to be unique if both are
|
|
1137
|
+
specified, return the first one found.
|
|
1138
|
+
|
|
1139
|
+
:param name: 'name' property of node.
|
|
1140
|
+
:param value: 'value' property of node.
|
|
1141
|
+
:return: the node read, or None if no node was found.
|
|
1142
|
+
"""
|
|
1143
|
+
global _graph
|
|
1144
|
+
global nodes_cache_key_element_id
|
|
1145
|
+
global graphdb_nr_reads
|
|
1146
|
+
|
|
1147
|
+
if _graph is None:
|
|
1148
|
+
print('\nread_node(): Error: graph has not been initialized or opened.\n\n')
|
|
1149
|
+
return None
|
|
1150
|
+
|
|
1151
|
+
if not isinstance(name, str) or not isinstance(value, str):
|
|
1152
|
+
return None
|
|
1153
|
+
|
|
1154
|
+
if name == '' or value == '':
|
|
1155
|
+
return None
|
|
1156
|
+
|
|
1157
|
+
graphdb_nr_reads += 1
|
|
1158
|
+
key = create_ricgraph_key(name=name, value=value)
|
|
1159
|
+
if key in nodes_cache_key_element_id:
|
|
1160
|
+
node_element_id = nodes_cache_key_element_id[key]
|
|
1161
|
+
cypher_query = 'MATCH (node:RicgraphNode) WHERE '
|
|
1162
|
+
if ricgraph_database() == 'neo4j':
|
|
1163
|
+
cypher_query += 'elementId(node)=$node_element_id '
|
|
1164
|
+
else:
|
|
1165
|
+
cypher_query += 'id(node)=toInteger($node_element_id) '
|
|
1166
|
+
cypher_query += 'RETURN node'
|
|
1167
|
+
nodes = _graph.execute_query(cypher_query,
|
|
1168
|
+
node_element_id=node_element_id,
|
|
1169
|
+
result_transformer_=Result.value,
|
|
1170
|
+
database_=ricgraph_databasename())
|
|
1171
|
+
if len(nodes) == 0:
|
|
1172
|
+
return None
|
|
1173
|
+
else:
|
|
1174
|
+
return nodes[0]
|
|
1175
|
+
else:
|
|
1176
|
+
if len(nodes_cache_key_element_id) > MAX_NODES_CACHE_KEY_ID:
|
|
1177
|
+
# Empty cache.
|
|
1178
|
+
nodes_cache_key_element_id = {}
|
|
1179
|
+
|
|
1180
|
+
cypher_query = 'MATCH (node:RicgraphNode) WHERE (node.name=$node_name) AND (node.value=$node_value) '
|
|
1181
|
+
cypher_query += 'RETURN node'
|
|
1182
|
+
nodes = _graph.execute_query(cypher_query,
|
|
1183
|
+
node_name=name,
|
|
1184
|
+
node_value=value,
|
|
1185
|
+
result_transformer_=Result.value,
|
|
1186
|
+
database_=ricgraph_databasename())
|
|
1187
|
+
if len(nodes) == 0:
|
|
1188
|
+
return None
|
|
1189
|
+
else:
|
|
1190
|
+
node = nodes[0]
|
|
1191
|
+
nodes_cache_key_element_id[key] = node.element_id
|
|
1192
|
+
return node
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def read_all_nodes(name: str = '', category: str = '', value: str = '',
|
|
1196
|
+
key: str = '',
|
|
1197
|
+
name_is_exact_match: bool = True,
|
|
1198
|
+
value_is_exact_match: bool = True,
|
|
1199
|
+
max_nr_nodes: int = 0) -> list:
|
|
1200
|
+
"""Read a number of nodes based on name, category or value.
|
|
1201
|
+
Any of these parameters can be specified.
|
|
1202
|
+
It is also possible to read a number of nodes based on key.
|
|
1203
|
+
But not name, category, value, and key at the same time.
|
|
1204
|
+
If key has been specified, the values of name_is_exact_match and
|
|
1205
|
+
value_is_exact_match are not used.
|
|
1206
|
+
|
|
1207
|
+
:param name: 'name' property of node.
|
|
1208
|
+
:param category: idem.
|
|
1209
|
+
:param value: idem.
|
|
1210
|
+
:param key: idem.
|
|
1211
|
+
:param name_is_exact_match: if True, then do an exact match search
|
|
1212
|
+
on field 'name', if False, then do a case-insensitive match.
|
|
1213
|
+
Note that a case-insensitive match is more expensive.
|
|
1214
|
+
:param value_is_exact_match: if True, then do an exact match search
|
|
1215
|
+
on field 'value', if False, then do a case-insensitive match.
|
|
1216
|
+
Note that a case-insensitive match is more expensive.
|
|
1217
|
+
:param max_nr_nodes: return at most this number of nodes, 0 = all nodes.
|
|
1218
|
+
:return: list of nodes read, or empty list if nothing found.
|
|
1219
|
+
"""
|
|
1220
|
+
global _graph
|
|
1221
|
+
global graphdb_nr_reads
|
|
1222
|
+
|
|
1223
|
+
if _graph is None:
|
|
1224
|
+
print('\nread_all_nodes(): Error: graph has not been initialized or opened.\n\n')
|
|
1225
|
+
return []
|
|
1226
|
+
|
|
1227
|
+
if not isinstance(name, str) \
|
|
1228
|
+
or not isinstance(category, str) \
|
|
1229
|
+
or not isinstance(value, str) \
|
|
1230
|
+
or not isinstance(key, str):
|
|
1231
|
+
return []
|
|
1232
|
+
|
|
1233
|
+
# Special case, for speed.
|
|
1234
|
+
if value_is_exact_match and name != '' and category == '' and value != '':
|
|
1235
|
+
# Ignore 'max_nr_nodes'.
|
|
1236
|
+
cypher_query = 'MATCH (node:RicgraphNode) WHERE (node.name=$node_name) AND (node.value=$node_value) '
|
|
1237
|
+
cypher_query += 'RETURN node'
|
|
1238
|
+
nodes = _graph.execute_query(cypher_query,
|
|
1239
|
+
node_name=name,
|
|
1240
|
+
node_value=value,
|
|
1241
|
+
result_transformer_=Result.value,
|
|
1242
|
+
database_=ricgraph_databasename())
|
|
1243
|
+
nr_nodes = len(nodes)
|
|
1244
|
+
# Unsure what to count here, this seems reasonable. '+ 1' for first node.
|
|
1245
|
+
graphdb_nr_reads += nr_nodes + 1
|
|
1246
|
+
if nr_nodes == 0:
|
|
1247
|
+
return []
|
|
1248
|
+
else:
|
|
1249
|
+
return nodes
|
|
1250
|
+
|
|
1251
|
+
# Now all other cases.
|
|
1252
|
+
# Don't allow a search for everything.
|
|
1253
|
+
if name == '' and category == '' and value == '' and key == '':
|
|
1254
|
+
return []
|
|
1255
|
+
|
|
1256
|
+
if key != '' and name == '' and category == '' and value == '':
|
|
1257
|
+
# Ignore 'max_nr_nodes'.
|
|
1258
|
+
cypher_query = 'MATCH (node:RicgraphNode) WHERE (node._key=$node_key) '
|
|
1259
|
+
cypher_query += 'RETURN node'
|
|
1260
|
+
nodes = _graph.execute_query(cypher_query,
|
|
1261
|
+
node_key=key,
|
|
1262
|
+
result_transformer_=Result.value,
|
|
1263
|
+
database_=ricgraph_databasename())
|
|
1264
|
+
nr_nodes = len(nodes)
|
|
1265
|
+
# Unsure what to count here, this seems reasonable. '+ 1' for first node.
|
|
1266
|
+
graphdb_nr_reads += nr_nodes + 1
|
|
1267
|
+
if nr_nodes == 0:
|
|
1268
|
+
return []
|
|
1269
|
+
else:
|
|
1270
|
+
return nodes
|
|
1271
|
+
|
|
1272
|
+
cypher_query = 'MATCH (node:RicgraphNode) WHERE '
|
|
1273
|
+
if name != '':
|
|
1274
|
+
if name_is_exact_match:
|
|
1275
|
+
cypher_query += '(node.name="' + name + '") AND '
|
|
1276
|
+
else:
|
|
1277
|
+
cypher_query += '(toLower(node.name) CONTAINS toLower("' + name + '")) AND '
|
|
1278
|
+
if category != '':
|
|
1279
|
+
cypher_query += '(node.category="' + category + '") AND '
|
|
1280
|
+
if value != '':
|
|
1281
|
+
# Value may contain special characters.
|
|
1282
|
+
if value_is_exact_match:
|
|
1283
|
+
# Exact match search.
|
|
1284
|
+
cypher_query += '(node.value=$node_value) AND '
|
|
1285
|
+
else:
|
|
1286
|
+
# Case-insensitive search.
|
|
1287
|
+
cypher_query += '(toLower(node.value) CONTAINS toLower($node_value)) AND '
|
|
1288
|
+
|
|
1289
|
+
# Remove last 'AND ', is of length -4.
|
|
1290
|
+
cypher_query = cypher_query[:-4]
|
|
1291
|
+
cypher_query += 'RETURN node '
|
|
1292
|
+
|
|
1293
|
+
if max_nr_nodes > 0:
|
|
1294
|
+
cypher_query += 'LIMIT ' + str(max_nr_nodes)
|
|
1295
|
+
# print(cypher_query)
|
|
1296
|
+
|
|
1297
|
+
if value != '':
|
|
1298
|
+
# Value may contain special characters.
|
|
1299
|
+
nodes = _graph.execute_query(cypher_query,
|
|
1300
|
+
node_value=value,
|
|
1301
|
+
result_transformer_=Result.value,
|
|
1302
|
+
database_=ricgraph_databasename())
|
|
1303
|
+
else:
|
|
1304
|
+
nodes = _graph.execute_query(cypher_query,
|
|
1305
|
+
result_transformer_=Result.value,
|
|
1306
|
+
database_=ricgraph_databasename())
|
|
1307
|
+
|
|
1308
|
+
nr_nodes = len(nodes)
|
|
1309
|
+
# Unsure what to count here, this seems reasonable. '+ 1' for first node.
|
|
1310
|
+
graphdb_nr_reads += nr_nodes + 1
|
|
1311
|
+
if nr_nodes == 0:
|
|
1312
|
+
return []
|
|
1313
|
+
else:
|
|
1314
|
+
return nodes
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def read_all_values_of_property(node_property: str = '') -> list:
|
|
1318
|
+
"""Read all the values of a certain property.
|
|
1319
|
+
|
|
1320
|
+
:param node_property: the property to find all values.
|
|
1321
|
+
:return: a sorted list with all the values, or empty list on error.
|
|
1322
|
+
"""
|
|
1323
|
+
global _graph
|
|
1324
|
+
|
|
1325
|
+
if _graph is None:
|
|
1326
|
+
print('\nread_all_values_of_property(): Error: graph has not been initialized or opened.\n\n')
|
|
1327
|
+
return []
|
|
1328
|
+
|
|
1329
|
+
if node_property != 'name' \
|
|
1330
|
+
and node_property != 'category' \
|
|
1331
|
+
and node_property != '_source':
|
|
1332
|
+
print('\nread_all_values_of_property(): Error: function does not work for property "'
|
|
1333
|
+
+ node_property + '".\n\n')
|
|
1334
|
+
return []
|
|
1335
|
+
|
|
1336
|
+
cypher_query = 'MATCH (node:RicgraphNode) RETURN COLLECT (DISTINCT node.' + node_property + ') AS entries'
|
|
1337
|
+
result = _graph.execute_query(cypher_query,
|
|
1338
|
+
result_transformer_=Result.data,
|
|
1339
|
+
database_=ricgraph_databasename())
|
|
1340
|
+
if len(result) == 0:
|
|
1341
|
+
return []
|
|
1342
|
+
result = result[0]
|
|
1343
|
+
if 'entries' not in result:
|
|
1344
|
+
return []
|
|
1345
|
+
result_list = result['entries']
|
|
1346
|
+
if len(result_list) == 0:
|
|
1347
|
+
return []
|
|
1348
|
+
if node_property == '_source':
|
|
1349
|
+
# This is a special case, we have a list of lists that we need to untangle.
|
|
1350
|
+
list_of_lists = result_list
|
|
1351
|
+
result_list = []
|
|
1352
|
+
for item in list_of_lists:
|
|
1353
|
+
if len(item) == 0:
|
|
1354
|
+
continue
|
|
1355
|
+
for encapsulated_item in item:
|
|
1356
|
+
if encapsulated_item not in result_list:
|
|
1357
|
+
result_list.append(encapsulated_item)
|
|
1358
|
+
|
|
1359
|
+
# We need 'key' to do a case insenstive search.
|
|
1360
|
+
result_list_sorted = sorted(result_list, key=str.lower)
|
|
1361
|
+
return result_list_sorted
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def update_node_value(name: str, old_value: str, new_value: str) -> Union[Node, None]:
|
|
1365
|
+
"""Update a node, change the value property.
|
|
1366
|
+
This is a special case because we change the key.
|
|
1367
|
+
Use carefully, because other property that contain 'old_value' (such
|
|
1368
|
+
as 'url_main' or 'url_other') are not being updated, so they will
|
|
1369
|
+
point to the wrong URL.
|
|
1370
|
+
|
|
1371
|
+
:param name: 'name' property of node.
|
|
1372
|
+
:param old_value: old 'value' property of node.
|
|
1373
|
+
:param new_value: old 'value' property of node.
|
|
1374
|
+
:return: the node updated, or None if this was not possible.
|
|
1375
|
+
"""
|
|
1376
|
+
# A lot of the code below is copied from create_update_node().
|
|
1377
|
+
global _graph
|
|
1378
|
+
|
|
1379
|
+
if _graph is None:
|
|
1380
|
+
print('\nupdate_node_value(): Error: graph has not been initialized or opened.\n\n')
|
|
1381
|
+
return None
|
|
1382
|
+
|
|
1383
|
+
if not isinstance(name, str) \
|
|
1384
|
+
or not isinstance(old_value, str) \
|
|
1385
|
+
or not isinstance(new_value, str):
|
|
1386
|
+
return None
|
|
1387
|
+
|
|
1388
|
+
lname = str(name)
|
|
1389
|
+
loldvalue = str(old_value)
|
|
1390
|
+
lnewvalue = str(new_value)
|
|
1391
|
+
|
|
1392
|
+
if lname == '' or loldvalue == '' or lnewvalue == '':
|
|
1393
|
+
return None
|
|
1394
|
+
|
|
1395
|
+
time_stamp = datetimestamp()
|
|
1396
|
+
node = read_node(name=lname, value=loldvalue)
|
|
1397
|
+
if node is None:
|
|
1398
|
+
# Node we want to change does not exist.
|
|
1399
|
+
return None
|
|
1400
|
+
|
|
1401
|
+
newnode = read_node(name=lname, value=lnewvalue)
|
|
1402
|
+
if newnode is not None:
|
|
1403
|
+
# The node we want to change to does already exist.
|
|
1404
|
+
# So this happens to be a merge of two nodes.
|
|
1405
|
+
merged_node = merge_two_nodes(node_merge_from=node,
|
|
1406
|
+
node_merge_to=newnode)
|
|
1407
|
+
return merged_node
|
|
1408
|
+
|
|
1409
|
+
node_properties = {'value': lnewvalue}
|
|
1410
|
+
oldkey = node['_key']
|
|
1411
|
+
newkey = create_ricgraph_key(name=lname, value=lnewvalue)
|
|
1412
|
+
node_properties['_key'] = newkey
|
|
1413
|
+
history_line = create_history_line(property_name='value', old_value=loldvalue, new_value=lnewvalue)
|
|
1414
|
+
history_line += create_history_line(property_name='_key', old_value=oldkey, new_value=newkey)
|
|
1415
|
+
node_properties['_history'] = node['_history'].copy()
|
|
1416
|
+
node_properties['_history'].append(time_stamp + ': Updated. ' + history_line)
|
|
1417
|
+
updated_node = cypher_update_node_properties(node_element_id=node.element_id,
|
|
1418
|
+
node_properties=node_properties)
|
|
1419
|
+
|
|
1420
|
+
if updated_node['name'] == 'FULL_NAME':
|
|
1421
|
+
personroot = get_or_create_personroot_node(person_node=updated_node)
|
|
1422
|
+
recreate_name_cache_in_personroot(personroot=personroot)
|
|
1423
|
+
return node
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
# Note the similarity with create_nodepairs_and_edges_df().
|
|
1427
|
+
def update_nodes_df(nodes: pandas.DataFrame) -> None:
|
|
1428
|
+
"""Update the values in a node, but only if values have been changed.
|
|
1429
|
+
This is done for all the nodes passed in the DataFrame.
|
|
1430
|
+
|
|
1431
|
+
The column names specify the property in a node (i.e. 'name', 'category', 'value').
|
|
1432
|
+
The row value of that column specify the property value of that property.
|
|
1433
|
+
|
|
1434
|
+
:param nodes: the nodes in a DataFrame.
|
|
1435
|
+
:return: None.
|
|
1436
|
+
"""
|
|
1437
|
+
nodes_clean = nodes.copy(deep=True)
|
|
1438
|
+
# Ensure that all '' values are NaN, so that those rows can be easily removed with dropna()
|
|
1439
|
+
nodes_clean.replace('', numpy.nan, inplace=True)
|
|
1440
|
+
nodes_clean.dropna(axis=0, how='any', inplace=True)
|
|
1441
|
+
nodes_clean.drop_duplicates(keep='first', inplace=True, ignore_index=True)
|
|
1442
|
+
|
|
1443
|
+
print('There are ' + str(len(nodes_clean)) + ' nodes ('
|
|
1444
|
+
+ timestamp() + '), updating node: 0 ', end='')
|
|
1445
|
+
count = 0
|
|
1446
|
+
columns = nodes_clean.columns
|
|
1447
|
+
for row in nodes_clean.itertuples():
|
|
1448
|
+
count += 1
|
|
1449
|
+
if count % 250 == 0:
|
|
1450
|
+
print(count, ' ', end='', flush=True)
|
|
1451
|
+
if count % 2500 == 0:
|
|
1452
|
+
print('(' + timestamp() + ')\n', end='', flush=True)
|
|
1453
|
+
|
|
1454
|
+
node_properties = {}
|
|
1455
|
+
for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
|
|
1456
|
+
for other_name in columns:
|
|
1457
|
+
if prop_name == other_name:
|
|
1458
|
+
node_properties[prop_name] = getattr(row, other_name)
|
|
1459
|
+
|
|
1460
|
+
create_update_node(name=row.name, category=row.category, value=row.value,
|
|
1461
|
+
other_properties=node_properties)
|
|
1462
|
+
|
|
1463
|
+
print(count, '(' + timestamp() + ')\n', end='', flush=True)
|
|
1464
|
+
return
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def get_or_create_personroot_node(person_node: Node) -> Union[Node, None]:
|
|
1468
|
+
"""Get a 'person-root' node for a given 'person' node, if that node has
|
|
1469
|
+
a 'person-root'. If not, create the 'person-root' node.
|
|
1470
|
+
|
|
1471
|
+
:param person_node: the node.
|
|
1472
|
+
:return: the 'person-root' node, or None on error.
|
|
1473
|
+
"""
|
|
1474
|
+
global _graph
|
|
1475
|
+
|
|
1476
|
+
if person_node is None:
|
|
1477
|
+
print('get_or_create_personroot_node(): Error: node is None.')
|
|
1478
|
+
return
|
|
1479
|
+
|
|
1480
|
+
if person_node['category'] != 'person':
|
|
1481
|
+
print('get_or_create_personroot_node(): node category is not person.')
|
|
1482
|
+
return None
|
|
1483
|
+
|
|
1484
|
+
if person_node['name'] == 'person-root':
|
|
1485
|
+
return person_node
|
|
1486
|
+
|
|
1487
|
+
personroot_nodes = get_all_personroot_nodes(node=person_node)
|
|
1488
|
+
if len(personroot_nodes) == 0:
|
|
1489
|
+
# Create the 'person-root' node with a unique value.
|
|
1490
|
+
value = str(uuid.uuid4())
|
|
1491
|
+
personroot = create_update_node(name='person-root', category='person', value=value)
|
|
1492
|
+
cypher_create_edge_if_not_exists(left_node_element_id=person_node.element_id,
|
|
1493
|
+
right_node_element_id=personroot.element_id)
|
|
1494
|
+
return personroot
|
|
1495
|
+
|
|
1496
|
+
if len(personroot_nodes) > 1:
|
|
1497
|
+
print('get_or_create_personroot_node(): not anticipated: person_node "'
|
|
1498
|
+
+ person_node['_key'] + '" has more than one person-root nodes: ')
|
|
1499
|
+
for node in personroot_nodes:
|
|
1500
|
+
print('"' + node['_key'] + '" ', end='')
|
|
1501
|
+
print('\nReturn the first.')
|
|
1502
|
+
# And fall through.
|
|
1503
|
+
|
|
1504
|
+
personroot = personroot_nodes[0]
|
|
1505
|
+
return personroot
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def connect_person_and_non_person_node(person_node: Node,
|
|
1509
|
+
non_person_node: Node) -> None:
|
|
1510
|
+
"""Connect a 'person' and a non 'person' node.
|
|
1511
|
+
|
|
1512
|
+
:param person_node: the left node, being of category 'person'.
|
|
1513
|
+
:param non_person_node: the right node, being of a different category than 'person'.
|
|
1514
|
+
:return: None.
|
|
1515
|
+
"""
|
|
1516
|
+
global _graph
|
|
1517
|
+
|
|
1518
|
+
if person_node is None or non_person_node is None:
|
|
1519
|
+
print('connect_persons_and_non_person_node(): Error: (one of the) nodes is None.')
|
|
1520
|
+
return
|
|
1521
|
+
|
|
1522
|
+
if person_node['category'] != 'person' or non_person_node['category'] == 'person':
|
|
1523
|
+
print('connect_person_and_non_person_node(): (one of the) nodes have wrong category.')
|
|
1524
|
+
return
|
|
1525
|
+
|
|
1526
|
+
if person_node['name'] == 'person-root':
|
|
1527
|
+
personroot = person_node
|
|
1528
|
+
else:
|
|
1529
|
+
personroot = get_or_create_personroot_node(person_node=person_node)
|
|
1530
|
+
if personroot is None:
|
|
1531
|
+
return
|
|
1532
|
+
|
|
1533
|
+
cypher_create_edge_if_not_exists(left_node_element_id=non_person_node.element_id,
|
|
1534
|
+
right_node_element_id=personroot.element_id)
|
|
1535
|
+
return
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def merge_two_nodes(node_merge_from: Node, node_merge_to: Node) -> Union[Node, None]:
|
|
1539
|
+
"""Merge two nodes.
|
|
1540
|
+
The neighbors of 'node_merge_from' will be merged to node 'node_merge_to'
|
|
1541
|
+
and 'node_merge_from' will be deleted.
|
|
1542
|
+
|
|
1543
|
+
:param node_merge_from: the node to merge from.
|
|
1544
|
+
:param node_merge_to: the node to merge to.
|
|
1545
|
+
:return: the merged node, or None if this was not possible.
|
|
1546
|
+
"""
|
|
1547
|
+
global _graph
|
|
1548
|
+
|
|
1549
|
+
if _graph is None:
|
|
1550
|
+
print('\nmerge_two_nodes(): Error: graph has not been initialized or opened.\n\n')
|
|
1551
|
+
return None
|
|
1552
|
+
|
|
1553
|
+
if node_merge_from is None and node_merge_to is not None:
|
|
1554
|
+
# Done.
|
|
1555
|
+
return node_merge_to
|
|
1556
|
+
|
|
1557
|
+
if node_merge_from is None or node_merge_to is None:
|
|
1558
|
+
print('merge_two_nodes(): Error: one or both of the nodes is None - cannot be found.')
|
|
1559
|
+
return None
|
|
1560
|
+
|
|
1561
|
+
if node_merge_from['name'] != node_merge_to['name'] \
|
|
1562
|
+
or node_merge_from['category'] != node_merge_to['category']:
|
|
1563
|
+
print('merge_two_nodes(): nodes "' + node_merge_from['_key']
|
|
1564
|
+
+ '" and "' + node_merge_to['_key']
|
|
1565
|
+
+ '" have a different "name" or "category" field: not supported.')
|
|
1566
|
+
return None
|
|
1567
|
+
|
|
1568
|
+
if node_merge_from == node_merge_to:
|
|
1569
|
+
# They are the same, done.
|
|
1570
|
+
return node_merge_to
|
|
1571
|
+
|
|
1572
|
+
node_merge_to_properties = {'_history': node_merge_to['_history'].copy()}
|
|
1573
|
+
node_merge_to_properties['_source'] = list(set(node_merge_from['_source'] + node_merge_to['_source']))
|
|
1574
|
+
node_merge_to_properties['_source'].sort()
|
|
1575
|
+
|
|
1576
|
+
time_stamp = datetimestamp(seconds=True)
|
|
1577
|
+
count = 0
|
|
1578
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1579
|
+
what_happened += 'Merged node "'
|
|
1580
|
+
what_happened += node_merge_from['_key'] + '" to this node '
|
|
1581
|
+
what_happened += 'and then deleted it.'
|
|
1582
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1583
|
+
|
|
1584
|
+
count += 1
|
|
1585
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1586
|
+
what_happened += '----- Start of deleted node history and neighbors -----'
|
|
1587
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1588
|
+
|
|
1589
|
+
count += 1
|
|
1590
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1591
|
+
what_happened += 'Start of history of the deleted node.'
|
|
1592
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1593
|
+
|
|
1594
|
+
for history in node_merge_from['_history']:
|
|
1595
|
+
count += 1
|
|
1596
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1597
|
+
what_happened += history
|
|
1598
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1599
|
+
|
|
1600
|
+
count += 1
|
|
1601
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1602
|
+
what_happened += 'End of history of the deleted node.'
|
|
1603
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1604
|
+
|
|
1605
|
+
count += 1
|
|
1606
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1607
|
+
what_happened += 'These were the neighbors of the deleted node, '
|
|
1608
|
+
what_happened += 'now merged with the neighbors of this node:'
|
|
1609
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1610
|
+
|
|
1611
|
+
neighbornodes = get_all_neighbor_nodes(node=node_merge_from)
|
|
1612
|
+
if len(neighbornodes) == 0:
|
|
1613
|
+
count += 1
|
|
1614
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1615
|
+
what_happened += '[the node to be deleted has no neighbors]'
|
|
1616
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1617
|
+
else:
|
|
1618
|
+
for node in neighbornodes:
|
|
1619
|
+
count += 1
|
|
1620
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1621
|
+
what_happened += '"' + str(node['_key']) + '" '
|
|
1622
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1623
|
+
|
|
1624
|
+
count += 1
|
|
1625
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1626
|
+
what_happened += 'End of list of neighbors of the deleted node.'
|
|
1627
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1628
|
+
|
|
1629
|
+
count += 1
|
|
1630
|
+
what_happened = time_stamp + '-' + format(count, '02d') + ': '
|
|
1631
|
+
what_happened += '----- End of deleted node history and neighbors -----'
|
|
1632
|
+
node_merge_to_properties['_history'].append(what_happened)
|
|
1633
|
+
|
|
1634
|
+
# Note only properties '_history' and '_source' are changed.
|
|
1635
|
+
merged_node = cypher_merge_nodes(node_merge_from_element_id=node_merge_from.element_id,
|
|
1636
|
+
node_merge_to_element_id=node_merge_to.element_id,
|
|
1637
|
+
node_merge_to_properties=node_merge_to_properties)
|
|
1638
|
+
if merged_node is None:
|
|
1639
|
+
return None
|
|
1640
|
+
if merged_node['name'] == 'person-root':
|
|
1641
|
+
# Property 'comment' is also updated.
|
|
1642
|
+
recreate_name_cache_in_personroot(personroot=merged_node)
|
|
1643
|
+
|
|
1644
|
+
return merged_node
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def merge_two_nodes_name_value(node_merge_from_name: str, node_merge_from_value: str,
|
|
1648
|
+
node_merge_to_name: str, node_merge_to_value: str) -> None:
|
|
1649
|
+
"""Merge two nodes, by specifying their 'name' and 'value' fields.
|
|
1650
|
+
The neighbors of 'node_merge_from' will be merged to node 'node_merge_to'
|
|
1651
|
+
and 'node_merge_from' will be deleted.
|
|
1652
|
+
|
|
1653
|
+
:param node_merge_from_name: 'name' property of node_merge_from.
|
|
1654
|
+
:param node_merge_from_value: 'value' property of node_merge_from.
|
|
1655
|
+
:param node_merge_to_name: 'name' property of node_merge_to.
|
|
1656
|
+
:param node_merge_to_value: 'value' property of node_merge_to.
|
|
1657
|
+
:return: None.
|
|
1658
|
+
"""
|
|
1659
|
+
global _graph
|
|
1660
|
+
|
|
1661
|
+
if _graph is None:
|
|
1662
|
+
print('\nmerge_two_nodes_name_value(): Error: graph has not been initialized or opened.\n\n')
|
|
1663
|
+
return
|
|
1664
|
+
|
|
1665
|
+
node_merge_from = read_node(name=node_merge_from_name,
|
|
1666
|
+
value=node_merge_from_value)
|
|
1667
|
+
node_merge_to = read_node(name=node_merge_to_name,
|
|
1668
|
+
value=node_merge_to_value)
|
|
1669
|
+
merge_two_nodes(node_merge_from=node_merge_from,
|
|
1670
|
+
node_merge_to=node_merge_to)
|
|
1671
|
+
return
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def connect_person_and_person_node(left_node: Node, right_node: Node) -> None:
|
|
1675
|
+
"""Connect two person nodes.
|
|
1676
|
+
|
|
1677
|
+
:param left_node: the left node.
|
|
1678
|
+
:param right_node: the right node.
|
|
1679
|
+
:return: None.
|
|
1680
|
+
"""
|
|
1681
|
+
global _graph
|
|
1682
|
+
|
|
1683
|
+
if left_node is None or right_node is None:
|
|
1684
|
+
print('connect_person_and_person_node(): (one of the) nodes is None.')
|
|
1685
|
+
return
|
|
1686
|
+
|
|
1687
|
+
if left_node['category'] != 'person' or right_node['category'] != 'person':
|
|
1688
|
+
print('connect_person_and_person_node(): (one of the) nodes have wrong category.')
|
|
1689
|
+
return
|
|
1690
|
+
|
|
1691
|
+
if left_node['name'] == 'person-root' or right_node['name'] == 'person-root':
|
|
1692
|
+
print('connect_person_and_person_node(): not anticipated: (one of the) person_nodes '
|
|
1693
|
+
+ 'are "person-root" nodes.')
|
|
1694
|
+
return
|
|
1695
|
+
|
|
1696
|
+
nr_edges_left_node = ricgraph_nr_edges_of_node(node_element_id=left_node.element_id)
|
|
1697
|
+
nr_edges_right_node = ricgraph_nr_edges_of_node(node_element_id=right_node.element_id)
|
|
1698
|
+
if nr_edges_left_node == 0 and nr_edges_right_node == 0:
|
|
1699
|
+
# None of the nodes have a 'person-root' node, create one and connect.
|
|
1700
|
+
personroot = get_or_create_personroot_node(person_node=left_node)
|
|
1701
|
+
if personroot is None:
|
|
1702
|
+
return
|
|
1703
|
+
cypher_create_edge_if_not_exists(left_node_element_id=right_node.element_id,
|
|
1704
|
+
right_node_element_id=personroot.element_id)
|
|
1705
|
+
return
|
|
1706
|
+
|
|
1707
|
+
if nr_edges_left_node > 0 and nr_edges_right_node == 0:
|
|
1708
|
+
# 'left_node' already has a 'person-root' node.
|
|
1709
|
+
personroot = get_or_create_personroot_node(person_node=left_node)
|
|
1710
|
+
if personroot is None:
|
|
1711
|
+
return
|
|
1712
|
+
cypher_create_edge_if_not_exists(left_node_element_id=right_node.element_id,
|
|
1713
|
+
right_node_element_id=personroot.element_id)
|
|
1714
|
+
return
|
|
1715
|
+
|
|
1716
|
+
if nr_edges_left_node == 0 and nr_edges_right_node > 0:
|
|
1717
|
+
# 'right_node' already has a 'person-root' node.
|
|
1718
|
+
personroot = get_or_create_personroot_node(person_node=right_node)
|
|
1719
|
+
if personroot is None:
|
|
1720
|
+
return
|
|
1721
|
+
cypher_create_edge_if_not_exists(left_node_element_id=left_node.element_id,
|
|
1722
|
+
right_node_element_id=personroot.element_id)
|
|
1723
|
+
return
|
|
1724
|
+
|
|
1725
|
+
left_personroot_node = get_or_create_personroot_node(person_node=left_node)
|
|
1726
|
+
right_personroot_node = get_or_create_personroot_node(person_node=right_node)
|
|
1727
|
+
if left_personroot_node is None or right_personroot_node is None:
|
|
1728
|
+
return
|
|
1729
|
+
|
|
1730
|
+
if left_personroot_node == right_personroot_node:
|
|
1731
|
+
# Already connected, nothing to do.
|
|
1732
|
+
return
|
|
1733
|
+
|
|
1734
|
+
# Only continue depending on RICGRAPH_NODEADD_MODE.
|
|
1735
|
+
if RICGRAPH_NODEADD_MODE == 'strict':
|
|
1736
|
+
# For more explanation, see file docs/ricgraph_install_configure.md,
|
|
1737
|
+
# section RICGRAPH_NODEADD_MODE.
|
|
1738
|
+
return
|
|
1739
|
+
|
|
1740
|
+
# Connect crosswise.
|
|
1741
|
+
cypher_create_edge_if_not_exists(left_node_element_id=left_node.element_id,
|
|
1742
|
+
right_node_element_id=right_personroot_node.element_id)
|
|
1743
|
+
cypher_create_edge_if_not_exists(left_node_element_id=right_node.element_id,
|
|
1744
|
+
right_node_element_id=left_personroot_node.element_id)
|
|
1745
|
+
|
|
1746
|
+
time_stamp = datetimestamp()
|
|
1747
|
+
message = 'The node pair "'
|
|
1748
|
+
message += left_node['_key'] + '" and "' + right_node['_key']
|
|
1749
|
+
message += '" caused this node to have more than one neighbor. '
|
|
1750
|
+
message += 'These are its person-root nodes: "'
|
|
1751
|
+
message += left_personroot_node['_key'] + '" and "'
|
|
1752
|
+
message += right_personroot_node['_key'] + '". '
|
|
1753
|
+
message += 'This might be caused by a mislabeling in a harvested system.'
|
|
1754
|
+
timestamped_message = time_stamp + ': ' + message
|
|
1755
|
+
|
|
1756
|
+
print('\nconnect_person_and_person_node(): ' + message)
|
|
1757
|
+
node_property = {'_history': left_node['_history'].copy()}
|
|
1758
|
+
node_property['_history'].append(timestamped_message)
|
|
1759
|
+
cypher_update_node_properties(node_element_id=left_node.element_id,
|
|
1760
|
+
node_properties=node_property)
|
|
1761
|
+
node_property = {'_history': right_node['_history'].copy()}
|
|
1762
|
+
node_property['_history'].append(timestamped_message)
|
|
1763
|
+
cypher_update_node_properties(node_element_id=right_node.element_id,
|
|
1764
|
+
node_properties=node_property)
|
|
1765
|
+
return
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def connect_two_nodes(left_node: Node, right_node: Node) -> None:
|
|
1769
|
+
"""Connect two nodes with two directed edges.
|
|
1770
|
+
|
|
1771
|
+
In case a node is to contain a personal ID (e.g. ORCID, ISNI, etc.) (then it has
|
|
1772
|
+
category = 'person'), a 'person-root' node will be created if it does not exist.
|
|
1773
|
+
This 'person-root' node is to be seen as the 'representative' for a person.
|
|
1774
|
+
All research outputs will be linked to the 'person-root' node, not to the personal ID node.
|
|
1775
|
+
|
|
1776
|
+
:param left_node: the left node.
|
|
1777
|
+
:param right_node: the right node.
|
|
1778
|
+
:return: None.
|
|
1779
|
+
"""
|
|
1780
|
+
global _graph
|
|
1781
|
+
|
|
1782
|
+
if left_node is None or right_node is None:
|
|
1783
|
+
return
|
|
1784
|
+
|
|
1785
|
+
if left_node['category'] != 'person' and right_node['category'] != 'person':
|
|
1786
|
+
# This is not a person to person link, link directly
|
|
1787
|
+
cypher_create_edge_if_not_exists(left_node_element_id=left_node.element_id,
|
|
1788
|
+
right_node_element_id=right_node.element_id)
|
|
1789
|
+
return
|
|
1790
|
+
|
|
1791
|
+
# At least one of the nodes is a 'person' link. These should be linked via their 'person-root' node.
|
|
1792
|
+
if left_node['category'] == 'person' and right_node['category'] != 'person':
|
|
1793
|
+
connect_person_and_non_person_node(person_node=left_node,
|
|
1794
|
+
non_person_node=right_node)
|
|
1795
|
+
return
|
|
1796
|
+
|
|
1797
|
+
if left_node['category'] != 'person' and right_node['category'] == 'person':
|
|
1798
|
+
connect_person_and_non_person_node(person_node=right_node,
|
|
1799
|
+
non_person_node=left_node)
|
|
1800
|
+
return
|
|
1801
|
+
|
|
1802
|
+
connect_person_and_person_node(left_node=left_node,
|
|
1803
|
+
right_node=right_node)
|
|
1804
|
+
return
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
def create_two_nodes_and_edge(name1: str, category1: str, value1: str,
|
|
1808
|
+
name2: str, category2: str, value2: str,
|
|
1809
|
+
**other_properties: Union[dict, str]) -> None:
|
|
1810
|
+
"""Create two nodes (if they do not exist) and two directed edges between those two nodes.
|
|
1811
|
+
The nodes are specified by the properties passed.
|
|
1812
|
+
All 'left' nodes end with '1', all right nodes end with '2'.
|
|
1813
|
+
|
|
1814
|
+
:param name1: 'name' property of left node.
|
|
1815
|
+
:param category1: idem.
|
|
1816
|
+
:param value1: idem.
|
|
1817
|
+
:param name2: 'name' property of right node.
|
|
1818
|
+
:param category2: idem.
|
|
1819
|
+
:param value2: idem
|
|
1820
|
+
:param other_properties: a dictionary of all the other properties.
|
|
1821
|
+
Names ending with '1' belong to a 'left' node,
|
|
1822
|
+
names ending with '2' to a 'right' node.
|
|
1823
|
+
:return: None.
|
|
1824
|
+
"""
|
|
1825
|
+
node_properties1 = {}
|
|
1826
|
+
node_properties2 = {}
|
|
1827
|
+
for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
|
|
1828
|
+
for other_name, other_value in other_properties.items():
|
|
1829
|
+
if prop_name + '1' == other_name:
|
|
1830
|
+
node_properties1.update({prop_name: other_value})
|
|
1831
|
+
if prop_name + '2' == other_name:
|
|
1832
|
+
node_properties2.update({prop_name: other_value})
|
|
1833
|
+
|
|
1834
|
+
node1 = create_update_node(name=name1, category=category1, value=value1,
|
|
1835
|
+
other_properties=node_properties1)
|
|
1836
|
+
node2 = create_update_node(name=name2, category=category2, value=value2,
|
|
1837
|
+
other_properties=node_properties2)
|
|
1838
|
+
if node1 is None or node2 is None:
|
|
1839
|
+
return
|
|
1840
|
+
|
|
1841
|
+
connect_two_nodes(left_node=node1, right_node=node2)
|
|
1842
|
+
if node1['name'] == 'FULL_NAME':
|
|
1843
|
+
personroot = get_or_create_personroot_node(person_node=node1)
|
|
1844
|
+
create_name_cache_in_personroot(node=node1, personroot=personroot)
|
|
1845
|
+
if node2['name'] == 'FULL_NAME':
|
|
1846
|
+
personroot = get_or_create_personroot_node(person_node=node2)
|
|
1847
|
+
create_name_cache_in_personroot(node=node2, personroot=personroot)
|
|
1848
|
+
return
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
def create_nodepairs_and_edges_df(left_and_right_nodepairs: pandas.DataFrame) -> None:
|
|
1852
|
+
"""Create two nodes (called 'node pairs') and two directed edges between those nodes.
|
|
1853
|
+
This is done for all the nodes passed. The nodes are specified in a DataFrame.
|
|
1854
|
+
One row in the DataFrame contains two nodes which are to be connected.
|
|
1855
|
+
|
|
1856
|
+
The column names specify the property in a node (i.e. 'name1', 'category1', 'value1').
|
|
1857
|
+
The row value of that column specify the property value of that property.
|
|
1858
|
+
All 'left' nodes end with '1', all right nodes end with '2'.
|
|
1859
|
+
|
|
1860
|
+
:param left_and_right_nodepairs: the node pairs in a DataFrame.
|
|
1861
|
+
:return: None.
|
|
1862
|
+
"""
|
|
1863
|
+
print('There are ' + str(len(left_and_right_nodepairs)) + ' rows ('
|
|
1864
|
+
+ timestamp() + '), creating nodes and edges for row: 0 ', end='')
|
|
1865
|
+
count = 0
|
|
1866
|
+
columns = left_and_right_nodepairs.columns
|
|
1867
|
+
for row in left_and_right_nodepairs.itertuples():
|
|
1868
|
+
count += 1
|
|
1869
|
+
if count % 250 == 0:
|
|
1870
|
+
print(count, ' ', end='', flush=True)
|
|
1871
|
+
if count % 2500 == 0:
|
|
1872
|
+
print('(' + timestamp() + ')\n', end='', flush=True)
|
|
1873
|
+
|
|
1874
|
+
node_properties = {}
|
|
1875
|
+
for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
|
|
1876
|
+
for other_name in columns:
|
|
1877
|
+
if prop_name + '1' == other_name:
|
|
1878
|
+
node_properties[prop_name + '1'] = getattr(row, other_name)
|
|
1879
|
+
for other_name in columns:
|
|
1880
|
+
if prop_name + '2' == other_name:
|
|
1881
|
+
node_properties[prop_name + '2'] = getattr(row, other_name)
|
|
1882
|
+
|
|
1883
|
+
create_two_nodes_and_edge(name1=row.name1, category1=row.category1, value1=row.value1,
|
|
1884
|
+
name2=row.name2, category2=row.category2, value2=row.value2,
|
|
1885
|
+
**node_properties)
|
|
1886
|
+
print(count, '(' + timestamp() + ')\n', end='', flush=True)
|
|
1887
|
+
return
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
def create_nodepairs_and_edges_params(name1: Union[dict, str], category1: Union[dict, str], value1: Union[dict, str],
|
|
1891
|
+
name2: Union[dict, str], category2: Union[dict, str], value2: Union[dict, str],
|
|
1892
|
+
**other_properties: Union[dict, str]) -> None:
|
|
1893
|
+
"""Create two nodes (called 'node pairs') and two directed edges between those nodes.
|
|
1894
|
+
This is done for all the nodes passed. The nodes are specified by values passed to this function.
|
|
1895
|
+
Each value can be either a string or a dict containing strings.
|
|
1896
|
+
All 'left' nodes end with '1', all right nodes end with '2'.
|
|
1897
|
+
|
|
1898
|
+
If it is a string (like 'ORCID') then that value will be used for every
|
|
1899
|
+
property value for that property. That is, every node pair will have the same value for that property.
|
|
1900
|
+
|
|
1901
|
+
If it is a dict containing strings, then every node pair will have a different value for that property.
|
|
1902
|
+
|
|
1903
|
+
:param name1: 'name' property of left node.
|
|
1904
|
+
:param category1: idem.
|
|
1905
|
+
:param value1: idem.
|
|
1906
|
+
:param name2: 'name' property of right node.
|
|
1907
|
+
:param category2: idem.
|
|
1908
|
+
:param value2: idem.
|
|
1909
|
+
:param other_properties: a dictionary of all the other properties.
|
|
1910
|
+
Names ending with '1' belong to a 'left' node,
|
|
1911
|
+
names ending with '2' to a 'right' node.
|
|
1912
|
+
:return: None.
|
|
1913
|
+
"""
|
|
1914
|
+
if isinstance(value1, str):
|
|
1915
|
+
left_and_right_nodepairs = pandas.DataFrame(data={'value1': value1},
|
|
1916
|
+
index=[0])
|
|
1917
|
+
else:
|
|
1918
|
+
left_and_right_nodepairs = pandas.DataFrame(data=value1).copy(deep=True)
|
|
1919
|
+
left_and_right_nodepairs.rename(columns={value1.name: 'value1'}, inplace=True)
|
|
1920
|
+
|
|
1921
|
+
# This makes programming more efficient
|
|
1922
|
+
allowed_properties = RICGRAPH_PROPERTIES_ADDITIONAL + RICGRAPH_PROPERTIES_STANDARD
|
|
1923
|
+
ext_other_properties = other_properties.copy()
|
|
1924
|
+
ext_other_properties['name1'] = name1
|
|
1925
|
+
ext_other_properties['name2'] = name2
|
|
1926
|
+
ext_other_properties['category1'] = category1
|
|
1927
|
+
ext_other_properties['category2'] = category2
|
|
1928
|
+
# 'value1' has been included above
|
|
1929
|
+
ext_other_properties['value2'] = value2
|
|
1930
|
+
|
|
1931
|
+
for prop_name in allowed_properties:
|
|
1932
|
+
for other_name in ext_other_properties:
|
|
1933
|
+
other_value = ext_other_properties[other_name]
|
|
1934
|
+
if prop_name + '1' == other_name:
|
|
1935
|
+
if isinstance(other_value, str):
|
|
1936
|
+
left_and_right_nodepairs[prop_name + '1'] = other_value
|
|
1937
|
+
else:
|
|
1938
|
+
left_and_right_nodepairs = pandas.concat([left_and_right_nodepairs, other_value], axis='columns')
|
|
1939
|
+
left_and_right_nodepairs.rename(columns={other_value.name: prop_name + '1'}, inplace=True)
|
|
1940
|
+
|
|
1941
|
+
if prop_name + '2' == other_name:
|
|
1942
|
+
if isinstance(other_value, str):
|
|
1943
|
+
left_and_right_nodepairs[prop_name + '2'] = other_value
|
|
1944
|
+
else:
|
|
1945
|
+
left_and_right_nodepairs = pandas.concat([left_and_right_nodepairs, other_value], axis='columns')
|
|
1946
|
+
left_and_right_nodepairs.rename(columns={other_value.name: prop_name + '2'}, inplace=True)
|
|
1947
|
+
|
|
1948
|
+
# Reorder a bit, starting with the last, only the name-category-value property, since they will surely exist
|
|
1949
|
+
left_and_right_nodepairs.insert(0, 'value2', left_and_right_nodepairs.pop('value2'))
|
|
1950
|
+
left_and_right_nodepairs.insert(0, 'category2', left_and_right_nodepairs.pop('category2'))
|
|
1951
|
+
left_and_right_nodepairs.insert(0, 'name2', left_and_right_nodepairs.pop('name2'))
|
|
1952
|
+
left_and_right_nodepairs.insert(0, 'value1', left_and_right_nodepairs.pop('value1'))
|
|
1953
|
+
left_and_right_nodepairs.insert(0, 'category1', left_and_right_nodepairs.pop('category1'))
|
|
1954
|
+
left_and_right_nodepairs.insert(0, 'name1', left_and_right_nodepairs.pop('name1'))
|
|
1955
|
+
|
|
1956
|
+
create_nodepairs_and_edges_df(left_and_right_nodepairs=left_and_right_nodepairs)
|
|
1957
|
+
return
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
def unify_personal_identifiers(personal_identifiers: pandas.DataFrame,
|
|
1961
|
+
source_event: Union[dict, str], history_event: Union[dict, str]) -> None:
|
|
1962
|
+
"""Unify a collection of personal identifiers (e.g. ORCID, ISNI, etc.).
|
|
1963
|
+
That means that every column is unified to every other column.
|
|
1964
|
+
The identifiers are specified as columns in a DataFrame.
|
|
1965
|
+
|
|
1966
|
+
:param personal_identifiers: DataFrame containing personal identifiers.
|
|
1967
|
+
:param source_event: an event to add to the _source list.
|
|
1968
|
+
:param history_event: a history event to add.
|
|
1969
|
+
:return: None.
|
|
1970
|
+
"""
|
|
1971
|
+
# Use i & j to make sure not to unify twice
|
|
1972
|
+
i = 0
|
|
1973
|
+
for column1 in personal_identifiers.columns:
|
|
1974
|
+
i += 1
|
|
1975
|
+
j = 0
|
|
1976
|
+
for column2 in personal_identifiers.columns:
|
|
1977
|
+
j += 1
|
|
1978
|
+
if i >= j:
|
|
1979
|
+
continue
|
|
1980
|
+
print('\nUnifying personal identifiers ' + column1 + ' and ' + column2
|
|
1981
|
+
+ ' at ' + timestamp() + '...')
|
|
1982
|
+
identifiers = personal_identifiers[[column1, column2]].copy(deep=True)
|
|
1983
|
+
|
|
1984
|
+
# Ensure that all '' values are NaN, so that those rows can be easily removed with dropna()
|
|
1985
|
+
identifiers.replace('', numpy.nan, inplace=True)
|
|
1986
|
+
# dropna(how='any'): drop row if one or more row values contain NaN
|
|
1987
|
+
identifiers.dropna(axis=0, how='any', inplace=True)
|
|
1988
|
+
identifiers.drop_duplicates(keep='first', inplace=True, ignore_index=True)
|
|
1989
|
+
|
|
1990
|
+
if RICGRAPH_NODEADD_MODE == 'strict':
|
|
1991
|
+
# Remove rows which have a cell value that occurs more than once.
|
|
1992
|
+
# E.g. if we have a DataFrame:
|
|
1993
|
+
# FULL_NAME ORCID
|
|
1994
|
+
# Name1 10
|
|
1995
|
+
# Name2 11
|
|
1996
|
+
# Name3 11
|
|
1997
|
+
# then we remove both rows with ORCID 11.
|
|
1998
|
+
# For more explanation, see file docs/ricgraph_install_configure.md,
|
|
1999
|
+
# section RICGRAPH_NODEADD_MODE.
|
|
2000
|
+
identifiers.drop_duplicates(subset=column1, keep=False, inplace=True, ignore_index=True)
|
|
2001
|
+
identifiers.drop_duplicates(subset=column2, keep=False, inplace=True, ignore_index=True)
|
|
2002
|
+
|
|
2003
|
+
create_nodepairs_and_edges_params(name1=column1, category1='person', value1=identifiers[column1],
|
|
2004
|
+
name2=column2, category2='person', value2=identifiers[column2],
|
|
2005
|
+
source_event1=source_event, source_event2=source_event,
|
|
2006
|
+
history_event1=history_event, history_event2=history_event)
|
|
2007
|
+
print('\nDone at ' + timestamp() + '.\n')
|
|
2008
|
+
return
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
def print_node_values(node: Node) -> None:
|
|
2012
|
+
"""Print the values of all properties in a node.
|
|
2013
|
+
|
|
2014
|
+
:param node: the node.
|
|
2015
|
+
:return: None.
|
|
2016
|
+
"""
|
|
2017
|
+
print('Node values:')
|
|
2018
|
+
print('_key: ' + node['_key'])
|
|
2019
|
+
print('name: ' + node['name'])
|
|
2020
|
+
print('category: ' + node['category'])
|
|
2021
|
+
print('value: ' + node['value'])
|
|
2022
|
+
for prop_name in RICGRAPH_PROPERTIES_ADDITIONAL:
|
|
2023
|
+
print(prop_name + ': ' + node[prop_name])
|
|
2024
|
+
print('source:')
|
|
2025
|
+
for source in node['_source']:
|
|
2026
|
+
print('- ' + source)
|
|
2027
|
+
print('history:')
|
|
2028
|
+
for history in node['_history']:
|
|
2029
|
+
print('- ' + history)
|
|
2030
|
+
print('')
|
|
2031
|
+
return
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
# ##############################################################################
|
|
2035
|
+
# Neighbor node and edge functions.
|
|
2036
|
+
# These start with "get_", not with "read_" as some above.
|
|
2037
|
+
# The difference is that "get_" functions work in constant time (starting from
|
|
2038
|
+
# a node) while the "read_" functions read (aka search, find) the whole graph.
|
|
2039
|
+
# ##############################################################################
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
# ######
|
|
2043
|
+
# This function does not seem necessary. Use get_all_neighbor_nodes() or
|
|
2044
|
+
# ricgraph_nr_edges_of_node().
|
|
2045
|
+
# Note that this function returns a list of edges, but you cannot get the next node
|
|
2046
|
+
# directly from an edge in that list.
|
|
2047
|
+
# ######
|
|
2048
|
+
# def get_edges(node: Node) -> list:
|
|
2049
|
+
# """Get the edges attached to a node.
|
|
2050
|
+
#
|
|
2051
|
+
# :param node: the node.
|
|
2052
|
+
# :return: a list of the edges, or empty list if no edges are connected.
|
|
2053
|
+
# """
|
|
2054
|
+
# global _graph
|
|
2055
|
+
#
|
|
2056
|
+
# cypher_query = 'MATCH (node)-[edge]->() WHERE elementId(node)=$node_element_id RETURN edge'
|
|
2057
|
+
# edges = _graph.execute_query(cypher_query,
|
|
2058
|
+
# node_element_id=node.element_id,
|
|
2059
|
+
# result_transformer_=Result.value,
|
|
2060
|
+
# database_=ricgraph_databasename())
|
|
2061
|
+
# if len(edges) == 0:
|
|
2062
|
+
# return []
|
|
2063
|
+
# else:
|
|
2064
|
+
# return edges
|
|
2065
|
+
# ######
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
def get_personroot_node(node: Node) -> Union[Node, None]:
|
|
2069
|
+
"""Get the 'person-root' node for any type of node.
|
|
2070
|
+
If 'node' is already a 'person-root' node, return 'node'.
|
|
2071
|
+
If there is more than one person-root node (which should not happen
|
|
2072
|
+
if the category is 'person'), return the first.
|
|
2073
|
+
|
|
2074
|
+
:param node: the node.
|
|
2075
|
+
:return: the person-root node.
|
|
2076
|
+
"""
|
|
2077
|
+
personroot_nodes = get_all_personroot_nodes(node)
|
|
2078
|
+
if len(personroot_nodes) == 0:
|
|
2079
|
+
return None
|
|
2080
|
+
else:
|
|
2081
|
+
return personroot_nodes[0]
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def get_all_personroot_nodes(node: Node) -> list:
|
|
2085
|
+
"""Get the 'person-root' node(s) for any type of node.
|
|
2086
|
+
If 'node' is already a 'person-root' node, return 'node'.
|
|
2087
|
+
If there is more than one person-root node (which can happen if
|
|
2088
|
+
node is e.g. a research output, and which should not happen
|
|
2089
|
+
if the category is 'person'), all will be returned in a list.
|
|
2090
|
+
|
|
2091
|
+
:param node: the node.
|
|
2092
|
+
:return: a list of all the person-root nodes found.
|
|
2093
|
+
"""
|
|
2094
|
+
if node is None:
|
|
2095
|
+
return []
|
|
2096
|
+
|
|
2097
|
+
if node['name'] == 'person-root':
|
|
2098
|
+
return [node]
|
|
2099
|
+
|
|
2100
|
+
personroot_nodes = get_all_neighbor_nodes(node=node,
|
|
2101
|
+
name_want='person-root')
|
|
2102
|
+
return personroot_nodes
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
def get_all_neighbor_nodes(node: Node,
|
|
2106
|
+
name_want: Union[str, list] = '',
|
|
2107
|
+
name_dontwant: Union[str, list] = '',
|
|
2108
|
+
category_want: Union[str, list] = '',
|
|
2109
|
+
category_dontwant: Union[str, list] = '',
|
|
2110
|
+
max_nr_neighbor_nodes: int = 0) -> list:
|
|
2111
|
+
"""Get all the neighbors of 'node' in a list.
|
|
2112
|
+
You can restrict the nodes returned by specifying one or more of the
|
|
2113
|
+
other parameters. If more than one is specified, the result is an AND.
|
|
2114
|
+
|
|
2115
|
+
:param node: the node we need neighbors from.
|
|
2116
|
+
:param name_want: either a string which indicates that we only want neighbor
|
|
2117
|
+
nodes where the property 'name' is equal to 'name_want'
|
|
2118
|
+
(e.g. 'ORCID'),
|
|
2119
|
+
or a list containing several node names, indicating
|
|
2120
|
+
that we want all neighbor nodes where the property 'name' equals
|
|
2121
|
+
one of the names in the list 'name_want'
|
|
2122
|
+
(e.g. ['ORCID', 'ISNI', 'FULL_NAME']).
|
|
2123
|
+
If empty (empty string), return all nodes.
|
|
2124
|
+
:param name_dontwant: similar, but for property 'name' and nodes we don't want.
|
|
2125
|
+
If empty (empty string), all nodes are 'wanted'.
|
|
2126
|
+
:param category_want: similar to 'name_want', but now for the property 'category'.
|
|
2127
|
+
:param category_dontwant: similar, but for property 'category' and nodes we don't want.
|
|
2128
|
+
:param max_nr_neighbor_nodes: return at most this number of nodes, 0 = all nodes.
|
|
2129
|
+
:return: the list of neighboring nodes satisfying all these criteria, or
|
|
2130
|
+
empty list if nothing found.
|
|
2131
|
+
"""
|
|
2132
|
+
global _graph
|
|
2133
|
+
global graphdb_nr_reads
|
|
2134
|
+
|
|
2135
|
+
if _graph is None:
|
|
2136
|
+
print('\nget_all_neighbor_nodes(): Error: graph has not been initialized or opened.\n\n')
|
|
2137
|
+
return []
|
|
2138
|
+
|
|
2139
|
+
if node is None:
|
|
2140
|
+
return []
|
|
2141
|
+
|
|
2142
|
+
cypher_query = 'MATCH (node:RicgraphNode)-[]->(neighbor:RicgraphNode) '
|
|
2143
|
+
if ricgraph_database() == 'neo4j':
|
|
2144
|
+
cypher_query += 'WHERE (elementId(node)=$node_element_id) '
|
|
2145
|
+
else:
|
|
2146
|
+
cypher_query += 'WHERE (id(node)=toInteger($node_element_id)) '
|
|
2147
|
+
|
|
2148
|
+
nr_of_not_clauses = 0
|
|
2149
|
+
if isinstance(name_want, str):
|
|
2150
|
+
if name_want != '':
|
|
2151
|
+
cypher_query += 'AND (neighbor.name IN ["' + name_want + '"]) '
|
|
2152
|
+
else:
|
|
2153
|
+
if len(name_want) > 0:
|
|
2154
|
+
string_list = '["' + '", "'.join(str(item) for item in name_want) + '"]'
|
|
2155
|
+
cypher_query += 'AND (neighbor.name IN ' + string_list + ') '
|
|
2156
|
+
|
|
2157
|
+
if isinstance(name_dontwant, str):
|
|
2158
|
+
if name_dontwant != '':
|
|
2159
|
+
cypher_query += 'AND (NOT neighbor.name IN ["' + name_dontwant + '"]) '
|
|
2160
|
+
nr_of_not_clauses += 1
|
|
2161
|
+
else:
|
|
2162
|
+
if len(name_dontwant) > 0:
|
|
2163
|
+
string_list = '["' + '", "'.join(str(item) for item in name_dontwant) + '"]'
|
|
2164
|
+
cypher_query += 'AND (NOT neighbor.name IN ' + string_list + ') '
|
|
2165
|
+
nr_of_not_clauses += 1
|
|
2166
|
+
|
|
2167
|
+
if isinstance(category_want, str):
|
|
2168
|
+
if category_want != '':
|
|
2169
|
+
cypher_query += 'AND (neighbor.category IN ["' + category_want + '"]) '
|
|
2170
|
+
else:
|
|
2171
|
+
if len(category_want) > 0:
|
|
2172
|
+
string_list = '["' + '", "'.join(str(item) for item in category_want) + '"]'
|
|
2173
|
+
cypher_query += 'AND (neighbor.category IN ' + string_list + ') '
|
|
2174
|
+
|
|
2175
|
+
if isinstance(category_dontwant, str):
|
|
2176
|
+
if category_dontwant != '':
|
|
2177
|
+
cypher_query += 'AND (NOT neighbor.category IN ["' + category_dontwant + '"]) '
|
|
2178
|
+
nr_of_not_clauses += 1
|
|
2179
|
+
else:
|
|
2180
|
+
if len(category_dontwant) > 0:
|
|
2181
|
+
string_list = '["' + '", "'.join(str(item) for item in category_dontwant) + '"]'
|
|
2182
|
+
cypher_query += 'AND (NOT neighbor.category IN ' + string_list + ') '
|
|
2183
|
+
nr_of_not_clauses += 1
|
|
2184
|
+
|
|
2185
|
+
if nr_of_not_clauses >= 2:
|
|
2186
|
+
# This is a special case in which the Cypher query produces unexpected results.
|
|
2187
|
+
neighbor_nodes = get_all_neighbor_nodes_loop(node=node,
|
|
2188
|
+
name_want=name_want,
|
|
2189
|
+
name_dontwant=name_dontwant,
|
|
2190
|
+
category_want=category_want,
|
|
2191
|
+
category_dontwant=category_dontwant,
|
|
2192
|
+
max_nr_neighbor_nodes=max_nr_neighbor_nodes)
|
|
2193
|
+
return neighbor_nodes
|
|
2194
|
+
|
|
2195
|
+
cypher_query += 'RETURN DISTINCT neighbor '
|
|
2196
|
+
if max_nr_neighbor_nodes > 0:
|
|
2197
|
+
cypher_query += 'LIMIT ' + str(max_nr_neighbor_nodes)
|
|
2198
|
+
# print(cypher_query)
|
|
2199
|
+
|
|
2200
|
+
neighbor_nodes = _graph.execute_query(cypher_query,
|
|
2201
|
+
node_element_id=node.element_id,
|
|
2202
|
+
result_transformer_=Result.value,
|
|
2203
|
+
database_=ricgraph_databasename())
|
|
2204
|
+
nr_neighbors = len(neighbor_nodes)
|
|
2205
|
+
# Unsure what to count here, this seems reasonable. '+ 1' for 'node'.
|
|
2206
|
+
graphdb_nr_reads += nr_neighbors + 1
|
|
2207
|
+
if nr_neighbors == 0:
|
|
2208
|
+
return []
|
|
2209
|
+
else:
|
|
2210
|
+
return neighbor_nodes
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
def get_all_neighbor_nodes_loop(node: Node,
|
|
2214
|
+
name_want: Union[str, list] = '',
|
|
2215
|
+
name_dontwant: Union[str, list] = '',
|
|
2216
|
+
category_want: Union[str, list] = '',
|
|
2217
|
+
category_dontwant: Union[str, list] = '',
|
|
2218
|
+
max_nr_neighbor_nodes: int = 0) -> list:
|
|
2219
|
+
"""Get all the neighbors of 'node' in a list.
|
|
2220
|
+
You can restrict the nodes returned by specifying one or more of the
|
|
2221
|
+
other parameters. If more than one is specified, the result is an AND.
|
|
2222
|
+
|
|
2223
|
+
This is the same function as get_all_neighbor_nodes() but it is much slower
|
|
2224
|
+
since it uses a loop. We need this in case
|
|
2225
|
+
len(name_dontwant_list) > 0 and len(category_dontwant_list) > 0.
|
|
2226
|
+
|
|
2227
|
+
:param node: the node we need neighbors from.
|
|
2228
|
+
:param name_want: either a string which indicates that we only want neighbor
|
|
2229
|
+
nodes where the property 'name' is equal to 'name_want'
|
|
2230
|
+
(e.g. 'ORCID'),
|
|
2231
|
+
or a list containing several node names, indicating
|
|
2232
|
+
that we want all neighbor nodes where the property 'name' equals
|
|
2233
|
+
one of the names in the list 'name_want'
|
|
2234
|
+
(e.g. ['ORCID', 'ISNI', 'FULL_NAME']).
|
|
2235
|
+
If empty (empty string), return all nodes.
|
|
2236
|
+
:param name_dontwant: similar, but for property 'name' and nodes we don't want.
|
|
2237
|
+
If empty (empty string), all nodes are 'wanted'.
|
|
2238
|
+
:param category_want: similar to 'name_want', but now for the property 'category'.
|
|
2239
|
+
:param category_dontwant: similar, but for property 'category' and nodes we don't want.
|
|
2240
|
+
:param max_nr_neighbor_nodes: return at most this number of nodes, 0 = all nodes.
|
|
2241
|
+
:return: the list of neighboring nodes satisfying all these criteria.
|
|
2242
|
+
"""
|
|
2243
|
+
global _graph
|
|
2244
|
+
global graphdb_nr_reads
|
|
2245
|
+
|
|
2246
|
+
if _graph is None:
|
|
2247
|
+
print('\nget_all_neighbor_nodes_loop(): Error: graph has not been initialized or opened.\n\n')
|
|
2248
|
+
return []
|
|
2249
|
+
|
|
2250
|
+
if node is None:
|
|
2251
|
+
return []
|
|
2252
|
+
|
|
2253
|
+
candidate_neighbor_nodes = get_all_neighbor_nodes(node=node)
|
|
2254
|
+
if len(candidate_neighbor_nodes) == 0:
|
|
2255
|
+
return []
|
|
2256
|
+
|
|
2257
|
+
# If 'name_want' etc. are strings, convert them to lists.
|
|
2258
|
+
if isinstance(name_want, str):
|
|
2259
|
+
if name_want == '':
|
|
2260
|
+
name_want_list = []
|
|
2261
|
+
else:
|
|
2262
|
+
name_want_list = [name_want]
|
|
2263
|
+
else:
|
|
2264
|
+
name_want_list = name_want
|
|
2265
|
+
|
|
2266
|
+
if isinstance(name_dontwant, str):
|
|
2267
|
+
if name_dontwant == '':
|
|
2268
|
+
name_dontwant_list = []
|
|
2269
|
+
else:
|
|
2270
|
+
name_dontwant_list = [name_dontwant]
|
|
2271
|
+
else:
|
|
2272
|
+
name_dontwant_list = name_dontwant
|
|
2273
|
+
|
|
2274
|
+
if isinstance(category_want, str):
|
|
2275
|
+
if category_want == '':
|
|
2276
|
+
category_want_list = []
|
|
2277
|
+
else:
|
|
2278
|
+
category_want_list = [category_want]
|
|
2279
|
+
else:
|
|
2280
|
+
category_want_list = category_want
|
|
2281
|
+
|
|
2282
|
+
if isinstance(category_dontwant, str):
|
|
2283
|
+
if category_dontwant == '':
|
|
2284
|
+
category_dontwant_list = []
|
|
2285
|
+
else:
|
|
2286
|
+
category_dontwant_list = [category_dontwant]
|
|
2287
|
+
else:
|
|
2288
|
+
category_dontwant_list = category_dontwant
|
|
2289
|
+
|
|
2290
|
+
count = 0
|
|
2291
|
+
all_nodes = A_LARGE_NUMBER
|
|
2292
|
+
if max_nr_neighbor_nodes == 0:
|
|
2293
|
+
max_nr_neighbor_nodes = all_nodes
|
|
2294
|
+
neighbor_nodes = []
|
|
2295
|
+
for neighbor in candidate_neighbor_nodes:
|
|
2296
|
+
if count >= max_nr_neighbor_nodes:
|
|
2297
|
+
break
|
|
2298
|
+
if node == neighbor:
|
|
2299
|
+
continue
|
|
2300
|
+
if neighbor['name'] in name_dontwant_list:
|
|
2301
|
+
continue
|
|
2302
|
+
if neighbor['category'] in category_dontwant_list:
|
|
2303
|
+
continue
|
|
2304
|
+
if len(name_want) == 0 and len(category_want) == 0:
|
|
2305
|
+
neighbor_nodes.append(neighbor)
|
|
2306
|
+
count += 1
|
|
2307
|
+
continue
|
|
2308
|
+
if neighbor['name'] in name_want_list \
|
|
2309
|
+
and neighbor['category'] in category_want_list:
|
|
2310
|
+
neighbor_nodes.append(neighbor)
|
|
2311
|
+
count += 1
|
|
2312
|
+
continue
|
|
2313
|
+
if neighbor['name'] in name_want_list and len(category_want_list) == 0:
|
|
2314
|
+
neighbor_nodes.append(neighbor)
|
|
2315
|
+
count += 1
|
|
2316
|
+
continue
|
|
2317
|
+
if len(name_want_list) == 0 and neighbor['category'] in category_want_list:
|
|
2318
|
+
neighbor_nodes.append(neighbor)
|
|
2319
|
+
count += 1
|
|
2320
|
+
continue
|
|
2321
|
+
# Any other next_node we do not want.
|
|
2322
|
+
|
|
2323
|
+
# Unsure what to count here, this seems reasonable. '+ 1' for 'node'.
|
|
2324
|
+
graphdb_nr_reads += len(neighbor_nodes) + 1
|
|
2325
|
+
return neighbor_nodes
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
# ##############################################################################
|
|
2329
|
+
# Utility functions
|
|
2330
|
+
# ##############################################################################
|
|
2331
|
+
|
|
2332
|
+
def create_multidimensional_dict(dimension: int, dict_type):
|
|
2333
|
+
"""Create a multimensional dict.
|
|
2334
|
+
Non-existing keys will be added automatically.
|
|
2335
|
+
From https://stackoverflow.com/questions/29348345/declaring-a-multi-dimensional-dictionary-in-python,
|
|
2336
|
+
the second answer.
|
|
2337
|
+
|
|
2338
|
+
Example use:
|
|
2339
|
+
new_dict = create_multidimensional_dict(2, int)
|
|
2340
|
+
new_dict['key1']['key2'] += 5
|
|
2341
|
+
|
|
2342
|
+
:param dimension: dimension of the dict.
|
|
2343
|
+
:param dict_type: type of the dict.
|
|
2344
|
+
:return: the dict.
|
|
2345
|
+
"""
|
|
2346
|
+
if dimension == 0:
|
|
2347
|
+
return None
|
|
2348
|
+
if dimension == 1:
|
|
2349
|
+
return defaultdict(dict_type)
|
|
2350
|
+
else:
|
|
2351
|
+
return defaultdict(lambda: create_multidimensional_dict(dimension=dimension - 1,
|
|
2352
|
+
dict_type=dict_type))
|
|
2353
|
+
|
|
2354
|
+
|
|
2355
|
+
def lookup_resout_type(research_output_type: str,
|
|
2356
|
+
research_output_mapping: dict) -> str:
|
|
2357
|
+
"""Convert a research output type from a harvested system
|
|
2358
|
+
to a shorter and easier Ricgraph research output type, according to a certain mapping.
|
|
2359
|
+
The reason for doing this is to ensure a constant naming of research output
|
|
2360
|
+
types for objects harvested from different sources.
|
|
2361
|
+
For more explanation, see the text at 'Research output types used in Ricgraph' at
|
|
2362
|
+
the start of this file.
|
|
2363
|
+
|
|
2364
|
+
:param research_output_type: A research output type from a source system.
|
|
2365
|
+
:param research_output_mapping: The mapping from the source system to Ricgraph
|
|
2366
|
+
research output types.
|
|
2367
|
+
:return: The result, in a few words.
|
|
2368
|
+
"""
|
|
2369
|
+
if research_output_type == '':
|
|
2370
|
+
print('lookup_resout_type(): no research output type specified .')
|
|
2371
|
+
return 'empty'
|
|
2372
|
+
|
|
2373
|
+
if research_output_type not in research_output_mapping:
|
|
2374
|
+
print('lookup_resout_type(): unknown research output type: "' + research_output_type + '".')
|
|
2375
|
+
return 'unknown'
|
|
2376
|
+
|
|
2377
|
+
return research_output_mapping[research_output_type]
|
|
2378
|
+
|
|
2379
|
+
|
|
2380
|
+
# #####
|
|
2381
|
+
# Note:
|
|
2382
|
+
# This function was written using the JSON harvested by the following python files:
|
|
2383
|
+
# - GET: harvest_openalex_to_ricgraph.py.
|
|
2384
|
+
# - POST: harvest_pure_to_ricgraph.py.
|
|
2385
|
+
#
|
|
2386
|
+
# Possibly for other harvests some changes should be made. Please also test the result
|
|
2387
|
+
# with the above-mentioned files, and add the filename of the new harvest script.
|
|
2388
|
+
# #####
|
|
2389
|
+
def harvest_json(url: str, headers: dict, body: dict = None, max_recs_to_harvest: int = 0, chunksize: int = 0) -> list:
|
|
2390
|
+
"""Harvest json data from a file.
|
|
2391
|
+
|
|
2392
|
+
:param url: URL to harvest.
|
|
2393
|
+
:param headers: headers required.
|
|
2394
|
+
:param body: the body of a POST request, or [] for a GET request.
|
|
2395
|
+
:param max_recs_to_harvest: maximum records to harvest.
|
|
2396
|
+
:param chunksize: chunk size to use (i.e. the number of records harvested in one call to 'url').
|
|
2397
|
+
:return: list of records in json format, or empty list if nothing found.
|
|
2398
|
+
"""
|
|
2399
|
+
if body is None:
|
|
2400
|
+
body = []
|
|
2401
|
+
|
|
2402
|
+
print('Harvesting json data from ' + url + '.')
|
|
2403
|
+
print('Getting data at ' + datetimestamp() + '...')
|
|
2404
|
+
|
|
2405
|
+
all_records = A_LARGE_NUMBER
|
|
2406
|
+
if max_recs_to_harvest == 0:
|
|
2407
|
+
max_recs_to_harvest = all_records
|
|
2408
|
+
if chunksize == 0:
|
|
2409
|
+
chunksize = 1
|
|
2410
|
+
if len(body) == 0:
|
|
2411
|
+
# GET http request
|
|
2412
|
+
request_type = 'get'
|
|
2413
|
+
url += '&per_page=' + str(chunksize)
|
|
2414
|
+
else:
|
|
2415
|
+
# POST http request
|
|
2416
|
+
request_type = 'post'
|
|
2417
|
+
body['size'] = chunksize
|
|
2418
|
+
|
|
2419
|
+
# Do a first harvest to determine the number of records to harvest.
|
|
2420
|
+
if request_type == 'get':
|
|
2421
|
+
response = requests.get(url=url, headers=headers)
|
|
2422
|
+
else:
|
|
2423
|
+
response = requests.post(url=url, headers=headers, json=body)
|
|
2424
|
+
|
|
2425
|
+
if response.status_code != requests.codes.ok:
|
|
2426
|
+
print('harvest_json(): error during harvest, possibly '
|
|
2427
|
+
+ 'a missing API-key or mixed up POST body?')
|
|
2428
|
+
print('Status code: ' + str(response.status_code))
|
|
2429
|
+
print('Url: ' + response.url)
|
|
2430
|
+
print('Error: ' + response.text)
|
|
2431
|
+
exit(1)
|
|
2432
|
+
|
|
2433
|
+
chunk_json_data = response.json()
|
|
2434
|
+
total_records = 0
|
|
2435
|
+
if request_type == 'get':
|
|
2436
|
+
if 'meta' in chunk_json_data:
|
|
2437
|
+
if 'count' in chunk_json_data['meta']:
|
|
2438
|
+
total_records = chunk_json_data['meta']['count']
|
|
2439
|
+
else:
|
|
2440
|
+
if 'count' in chunk_json_data:
|
|
2441
|
+
total_records = chunk_json_data['count']
|
|
2442
|
+
|
|
2443
|
+
if total_records == 0:
|
|
2444
|
+
print('harvest_json(): Warning: malformed json, "count" is missing.')
|
|
2445
|
+
|
|
2446
|
+
if max_recs_to_harvest == all_records:
|
|
2447
|
+
print('There are ' + str(total_records)
|
|
2448
|
+
+ ' records, harvesting in chunks of ' + str(chunksize)
|
|
2449
|
+
+ ' items.')
|
|
2450
|
+
else:
|
|
2451
|
+
print('There are ' + str(total_records)
|
|
2452
|
+
+ ' records, harvesting in chunks of ' + str(chunksize)
|
|
2453
|
+
+ ', at most ' + str(max_recs_to_harvest) + ' items.')
|
|
2454
|
+
|
|
2455
|
+
print('Harvesting record: ', end='', flush=True)
|
|
2456
|
+
|
|
2457
|
+
json_data = []
|
|
2458
|
+
records_harvested = 0
|
|
2459
|
+
page_nr = 1
|
|
2460
|
+
# And now start the real harvesting.
|
|
2461
|
+
while records_harvested <= max_recs_to_harvest:
|
|
2462
|
+
if request_type == 'get':
|
|
2463
|
+
url_page = url + '&page=' + str(page_nr)
|
|
2464
|
+
response = requests.get(url=url_page, headers=headers)
|
|
2465
|
+
else:
|
|
2466
|
+
if max_recs_to_harvest - records_harvested <= chunksize:
|
|
2467
|
+
# We have to harvest the last few (< chunksize).
|
|
2468
|
+
body['size'] = max_recs_to_harvest - records_harvested
|
|
2469
|
+
body['offset'] = records_harvested
|
|
2470
|
+
response = requests.post(url=url, headers=headers, json=body)
|
|
2471
|
+
|
|
2472
|
+
if response.status_code != requests.codes.ok:
|
|
2473
|
+
print('harvest_json(): error during harvest, possibly '
|
|
2474
|
+
+ 'a missing API-key or mixed up POST body?')
|
|
2475
|
+
print('Status code: ' + str(response.status_code))
|
|
2476
|
+
print('Url: ' + response.url)
|
|
2477
|
+
print('Error: ' + response.text)
|
|
2478
|
+
exit(1)
|
|
2479
|
+
|
|
2480
|
+
chunk_json_data = response.json()
|
|
2481
|
+
if request_type == 'get':
|
|
2482
|
+
if 'results' not in chunk_json_data:
|
|
2483
|
+
print('harvest_json(): Error: malformed json, "results" is missing.')
|
|
2484
|
+
return []
|
|
2485
|
+
if len(chunk_json_data['results']) == 0:
|
|
2486
|
+
break
|
|
2487
|
+
json_items = chunk_json_data['results']
|
|
2488
|
+
else:
|
|
2489
|
+
if 'items' not in chunk_json_data:
|
|
2490
|
+
print('harvest_json(): Error: malformed json, "items" is missing.')
|
|
2491
|
+
return []
|
|
2492
|
+
if len(chunk_json_data['items']) == 0:
|
|
2493
|
+
break
|
|
2494
|
+
json_items = chunk_json_data['items']
|
|
2495
|
+
|
|
2496
|
+
json_data += json_items
|
|
2497
|
+
print(records_harvested, ' ', end='', flush=True)
|
|
2498
|
+
if page_nr % 10 == 0:
|
|
2499
|
+
if page_nr != 0:
|
|
2500
|
+
print('(' + timestamp() + ')\n', end='', flush=True)
|
|
2501
|
+
records_harvested += chunksize
|
|
2502
|
+
page_nr += 1
|
|
2503
|
+
|
|
2504
|
+
print(' Done at ' + timestamp() + '.\n')
|
|
2505
|
+
return json_data
|
|
2506
|
+
|
|
2507
|
+
|
|
2508
|
+
def harvest_json_and_write_to_file(filename: str, url: str, headers: dict, body: dict = None,
|
|
2509
|
+
max_recs_to_harvest: int = 0, chunksize: int = 0) -> list:
|
|
2510
|
+
"""Harvest json data and write the data found to a file.
|
|
2511
|
+
This data is a list of records in json format. If no records are harvested, nothing is written.
|
|
2512
|
+
|
|
2513
|
+
:param filename: filename of the file to use for writing.
|
|
2514
|
+
:param url: URL to harvest.
|
|
2515
|
+
:param headers: headers required.
|
|
2516
|
+
:param body: the body of a POST request, or '' for a GET request.
|
|
2517
|
+
:param max_recs_to_harvest: maximum records to harvest.
|
|
2518
|
+
:param chunksize: chunk size to use (i.e. the number of records harvested in one call to 'url').
|
|
2519
|
+
:return: list of records in json format, or empty list if nothing found.
|
|
2520
|
+
"""
|
|
2521
|
+
if body is None:
|
|
2522
|
+
body = []
|
|
2523
|
+
|
|
2524
|
+
json_data = harvest_json(url=url,
|
|
2525
|
+
headers=headers,
|
|
2526
|
+
body=body,
|
|
2527
|
+
max_recs_to_harvest=max_recs_to_harvest,
|
|
2528
|
+
chunksize=chunksize)
|
|
2529
|
+
if len(json_data) == 0:
|
|
2530
|
+
return []
|
|
2531
|
+
write_json_to_file(json_data=json_data, filename=filename)
|
|
2532
|
+
return json_data
|
|
2533
|
+
|
|
2534
|
+
|
|
2535
|
+
def write_json_to_file(json_data: list, filename: str) -> None:
|
|
2536
|
+
"""Write json data to a file.
|
|
2537
|
+
If no records are harvested, nothing is written.
|
|
2538
|
+
|
|
2539
|
+
:param json_data: a list of records in json format.
|
|
2540
|
+
:param filename: filename of the file to use for writing.
|
|
2541
|
+
:return: None.
|
|
2542
|
+
"""
|
|
2543
|
+
print('Saving json data to ' + filename + '... ', end='', flush=True)
|
|
2544
|
+
with open(filename, 'w') as fd:
|
|
2545
|
+
json.dump(obj=json_data, fp=fd, ensure_ascii=False, indent=2)
|
|
2546
|
+
print('Done.')
|
|
2547
|
+
return
|
|
2548
|
+
|
|
2549
|
+
|
|
2550
|
+
def read_json_from_file(filename: str) -> list:
|
|
2551
|
+
"""Read json data from a file.
|
|
2552
|
+
|
|
2553
|
+
:param filename: filename of the file to use for writing.
|
|
2554
|
+
:return: list of records in json format, or empty list if nothing found.
|
|
2555
|
+
"""
|
|
2556
|
+
print('Reading json data from ' + filename + '... ', end='', flush=True)
|
|
2557
|
+
with open(filename) as fd:
|
|
2558
|
+
json_data = json.load(fp=fd)
|
|
2559
|
+
print('Done.')
|
|
2560
|
+
if json_data is None:
|
|
2561
|
+
return []
|
|
2562
|
+
return json_data
|
|
2563
|
+
|
|
2564
|
+
|
|
2565
|
+
def read_dataframe_from_csv(filename: str, columns: dict = None,
|
|
2566
|
+
nr_rows: int = None, datatype=None) -> pandas.DataFrame:
|
|
2567
|
+
"""Reads a datastructure (DataFrame) from file
|
|
2568
|
+
|
|
2569
|
+
:param filename: csv file to read.
|
|
2570
|
+
:param columns: which columns to read.
|
|
2571
|
+
:param nr_rows: how many rows to read (default: all).
|
|
2572
|
+
:param datatype: type of (some) columns to read.
|
|
2573
|
+
:return: dataframe read.
|
|
2574
|
+
"""
|
|
2575
|
+
try:
|
|
2576
|
+
# Some inputfiles have problems reading in utf-8
|
|
2577
|
+
csv_data = pandas.read_csv(filename,
|
|
2578
|
+
sep=',',
|
|
2579
|
+
usecols=columns,
|
|
2580
|
+
dtype=datatype,
|
|
2581
|
+
engine='python',
|
|
2582
|
+
nrows=nr_rows,
|
|
2583
|
+
parse_dates=False,
|
|
2584
|
+
iterator=False,
|
|
2585
|
+
quotechar='"',
|
|
2586
|
+
encoding='utf-8')
|
|
2587
|
+
except BaseException:
|
|
2588
|
+
print('read_dataframe_from_csv(): error reading in utf-8 format, reading in latin-1 format.')
|
|
2589
|
+
csv_data = pandas.read_csv(filename,
|
|
2590
|
+
sep=',',
|
|
2591
|
+
usecols=columns,
|
|
2592
|
+
dtype=datatype,
|
|
2593
|
+
engine='python',
|
|
2594
|
+
nrows=nr_rows,
|
|
2595
|
+
parse_dates=False,
|
|
2596
|
+
iterator=False,
|
|
2597
|
+
quotechar='"',
|
|
2598
|
+
encoding='latin-1')
|
|
2599
|
+
return csv_data
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
def write_dataframe_to_csv(filename: str, df: pandas.DataFrame) -> None:
|
|
2603
|
+
"""Write a datastructure (DataFrame) to file.
|
|
2604
|
+
|
|
2605
|
+
:param filename: csv file to write.
|
|
2606
|
+
:param df: dataframe to write.
|
|
2607
|
+
:return: None.
|
|
2608
|
+
"""
|
|
2609
|
+
print('\nWriting to csv file: ' + filename + '... ', end='', flush=True)
|
|
2610
|
+
df.to_csv(filename,
|
|
2611
|
+
sep=',',
|
|
2612
|
+
quotechar='"',
|
|
2613
|
+
quoting=csv.QUOTE_ALL,
|
|
2614
|
+
encoding='utf-8',
|
|
2615
|
+
index=False)
|
|
2616
|
+
print('Done.')
|
|
2617
|
+
return
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
def convert_nodes_to_list_of_dict(nodes_list: list,
|
|
2621
|
+
max_nr_items: str = '0') -> list:
|
|
2622
|
+
"""Convert a list of nodes to a list of dict.
|
|
2623
|
+
|
|
2624
|
+
:param nodes_list: The list of nodes.
|
|
2625
|
+
:param max_nr_items: The maximum number of items to return.
|
|
2626
|
+
:return: A list of dicts
|
|
2627
|
+
"""
|
|
2628
|
+
if max_nr_items == '0':
|
|
2629
|
+
max_nr_nodes = A_LARGE_NUMBER
|
|
2630
|
+
else:
|
|
2631
|
+
max_nr_nodes = int(max_nr_items)
|
|
2632
|
+
|
|
2633
|
+
result_list = []
|
|
2634
|
+
count = 0
|
|
2635
|
+
for node in nodes_list:
|
|
2636
|
+
count += 1
|
|
2637
|
+
if count > max_nr_nodes:
|
|
2638
|
+
break
|
|
2639
|
+
result = {}
|
|
2640
|
+
for item in node:
|
|
2641
|
+
result[item] = node[item]
|
|
2642
|
+
result_list.append(result)
|
|
2643
|
+
return result_list
|
|
2644
|
+
|
|
2645
|
+
|
|
2646
|
+
def create_http_response(result_list: list = None,
|
|
2647
|
+
message: str = '',
|
|
2648
|
+
http_status: int = HTTP_RESPONSE_OK) -> Tuple[dict, int]:
|
|
2649
|
+
"""Create an HTTP response.
|
|
2650
|
+
|
|
2651
|
+
:param result_list: A list of dicts, to be put in the 'result' section of
|
|
2652
|
+
the response.
|
|
2653
|
+
:param message: An optional message to be put in the 'meta' section of
|
|
2654
|
+
the response.
|
|
2655
|
+
:param http_status: The HTTP status code to be put in the 'meta' section of
|
|
2656
|
+
the response.
|
|
2657
|
+
:return: An HTTP response (as dict, to be translated to json)
|
|
2658
|
+
and an HTTP response code.
|
|
2659
|
+
"""
|
|
2660
|
+
if result_list is None:
|
|
2661
|
+
result_list = []
|
|
2662
|
+
|
|
2663
|
+
meta = {'count': len(result_list),
|
|
2664
|
+
'page': 1, # More pages not implemented yet.
|
|
2665
|
+
'per_page': len(result_list), # Should be page length, not implemented yet.
|
|
2666
|
+
'status': http_status}
|
|
2667
|
+
if message != '':
|
|
2668
|
+
meta['message'] = message
|
|
2669
|
+
|
|
2670
|
+
response = {'meta': meta,
|
|
2671
|
+
'results': result_list}
|
|
2672
|
+
return response, http_status
|
|
2673
|
+
|
|
2674
|
+
|
|
2675
|
+
def print_commandline_arguments(argument_list: list) -> None:
|
|
2676
|
+
"""Print the script name and all command line arguments.
|
|
2677
|
+
|
|
2678
|
+
:param argument_list: the argument list.
|
|
2679
|
+
:return: None.
|
|
2680
|
+
"""
|
|
2681
|
+
if len(argument_list) == 0:
|
|
2682
|
+
print('print_commandline_arguments(): Error, empty argument_list passed, exiting.')
|
|
2683
|
+
exit(1)
|
|
2684
|
+
|
|
2685
|
+
print('\nScript name: ' + argument_list[0])
|
|
2686
|
+
if len(argument_list) == 1:
|
|
2687
|
+
print('Command line arguments: [none]')
|
|
2688
|
+
else:
|
|
2689
|
+
print('Command line arguments: '
|
|
2690
|
+
+ ' '.join([str(arg) for arg in argument_list[1:]])
|
|
2691
|
+
+ '\n')
|
|
2692
|
+
return
|
|
2693
|
+
|
|
2694
|
+
|
|
2695
|
+
def get_commandline_argument(argument: str, argument_list: list) -> str:
|
|
2696
|
+
"""Get the value of a command line argument
|
|
2697
|
+
|
|
2698
|
+
:param argument: the command line argument name.
|
|
2699
|
+
:param argument_list: the argument list.
|
|
2700
|
+
:return: the value that belongs to 'argument'.
|
|
2701
|
+
"""
|
|
2702
|
+
length = len(argument_list)
|
|
2703
|
+
if length == 0:
|
|
2704
|
+
print('get_commandline_argument(): Error, empty argument_list passed, exiting.')
|
|
2705
|
+
exit(1)
|
|
2706
|
+
|
|
2707
|
+
if str(argument) == '':
|
|
2708
|
+
# No argument passed.
|
|
2709
|
+
return ''
|
|
2710
|
+
|
|
2711
|
+
if length == 1:
|
|
2712
|
+
# The argument list contains the script name only.
|
|
2713
|
+
return ''
|
|
2714
|
+
|
|
2715
|
+
for i in range(1, length - 1):
|
|
2716
|
+
if str(argument_list[i]) == str(argument):
|
|
2717
|
+
if i + 1 <= length:
|
|
2718
|
+
# Only get the next index if we are still in the array bounds.
|
|
2719
|
+
return str(argument_list[i + 1])
|
|
2720
|
+
return ''
|
|
2721
|
+
|
|
2722
|
+
|
|
2723
|
+
# ############################################
|
|
2724
|
+
# ################### main ###################
|
|
2725
|
+
# ############################################
|
|
2726
|
+
# This will be executed on module initialization
|
|
2727
|
+
config = configparser.ConfigParser()
|
|
2728
|
+
config.read(get_ricgraph_ini_file())
|
|
2729
|
+
try:
|
|
2730
|
+
RICGRAPH_PROPERTIES_STANDARD = tuple(config['Ricgraph']['ricgraph_properties_standard'].split(','))
|
|
2731
|
+
RICGRAPH_PROPERTIES_HIDDEN = tuple(config['Ricgraph']['ricgraph_properties_hidden'].split(','))
|
|
2732
|
+
RICGRAPH_PROPERTIES_ADDITIONAL = tuple(config['Ricgraph']['ricgraph_properties_additional'].split(','))
|
|
2733
|
+
if len(RICGRAPH_PROPERTIES_STANDARD) == 0 \
|
|
2734
|
+
or len(RICGRAPH_PROPERTIES_HIDDEN) == 0 \
|
|
2735
|
+
or len(RICGRAPH_PROPERTIES_ADDITIONAL) == 0:
|
|
2736
|
+
print('Ricgraph initialization: error, ricgraph_properties_standard and/or')
|
|
2737
|
+
print(' ricgraph_properties_hidden and/or ricgraph_properties_additional')
|
|
2738
|
+
print(' are empty in Ricgraph ini file, exiting.')
|
|
2739
|
+
exit(1)
|
|
2740
|
+
|
|
2741
|
+
if RICGRAPH_PROPERTIES_STANDARD[0] == '' \
|
|
2742
|
+
or RICGRAPH_PROPERTIES_HIDDEN[0] == '' \
|
|
2743
|
+
or RICGRAPH_PROPERTIES_ADDITIONAL[0] == '':
|
|
2744
|
+
print('Ricgraph initialization: error, ricgraph_properties_standard and/or')
|
|
2745
|
+
print(' ricgraph_properties_hidden and/or ricgraph_properties_additional')
|
|
2746
|
+
print(' are empty in Ricgraph ini file, exiting.')
|
|
2747
|
+
exit(1)
|
|
2748
|
+
|
|
2749
|
+
# For more explanation, see file docs/ricgraph_install_configure.md,
|
|
2750
|
+
# section RICGRAPH_NODEADD_MODE.
|
|
2751
|
+
RICGRAPH_NODEADD_MODE = config['Ricgraph']['ricgraph_nodeadd_mode']
|
|
2752
|
+
if RICGRAPH_NODEADD_MODE != 'strict' and RICGRAPH_NODEADD_MODE != 'lenient':
|
|
2753
|
+
print('Ricgraph initialization: error, unknown value "' + RICGRAPH_NODEADD_MODE
|
|
2754
|
+
+ '" for ricgraph_nodeadd_mode in Ricgraph ini file, exiting.')
|
|
2755
|
+
exit(1)
|
|
2756
|
+
else:
|
|
2757
|
+
print('Ricgraph is using "' + RICGRAPH_NODEADD_MODE + '" for ricgraph_nodeadd_mode.')
|
|
2758
|
+
except KeyError:
|
|
2759
|
+
print('Ricgraph initialization: error, ricgraph_properties_standard and/or')
|
|
2760
|
+
print(' ricgraph_properties_hidden and/or ricgraph_properties_additional and/or ricgraph_nodeadd_mode')
|
|
2761
|
+
print(' not found in Ricgraph ini file, exiting.')
|
|
2762
|
+
exit(1)
|
|
2763
|
+
|
|
2764
|
+
try:
|
|
2765
|
+
GRAPHDB = config['GraphDB']['graphdb']
|
|
2766
|
+
GRAPHDB_HOSTNAME = config['GraphDB']['graphdb_hostname']
|
|
2767
|
+
GRAPHDB_DATABASENAME = config['GraphDB']['graphdb_databasename']
|
|
2768
|
+
GRAPHDB_USER = config['GraphDB']['graphdb_user']
|
|
2769
|
+
GRAPHDB_PASSWORD = config['GraphDB']['graphdb_password']
|
|
2770
|
+
GRAPHDB_SCHEME = config['GraphDB']['graphdb_scheme']
|
|
2771
|
+
GRAPHDB_PORT = config['GraphDB']['graphdb_port']
|
|
2772
|
+
if GRAPHDB == '' or GRAPHDB_HOSTNAME == '' or GRAPHDB_DATABASENAME == '' \
|
|
2773
|
+
or GRAPHDB_USER == '' \
|
|
2774
|
+
or GRAPHDB_PASSWORD == '' or GRAPHDB_SCHEME == '' or GRAPHDB_PORT == '':
|
|
2775
|
+
print('Ricgraph initialization: error, one or more of the GraphDB parameters '
|
|
2776
|
+
+ 'empty in Ricgraph ini file, exiting.')
|
|
2777
|
+
exit(1)
|
|
2778
|
+
GRAPHDB_URL = '{scheme}://{hostname}:{port}'.format(scheme=GRAPHDB_SCHEME,
|
|
2779
|
+
hostname=GRAPHDB_HOSTNAME,
|
|
2780
|
+
port=GRAPHDB_PORT)
|
|
2781
|
+
except KeyError:
|
|
2782
|
+
print('Ricgraph initialization: error, one or more of GraphDB parameters '
|
|
2783
|
+
+ 'not found in Ricgraph ini file, exiting.')
|
|
2784
|
+
exit(1)
|
|
2785
|
+
|
|
2786
|
+
# Make sure DataFrames are printed with all columns on full width
|
|
2787
|
+
pandas.set_option('display.max_columns', None)
|
|
2788
|
+
pandas.set_option('display.width', 500)
|