refscan 0.0.0__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.
- refscan/__init__.py +18 -0
- refscan/cli/cli.py +27 -0
- refscan/cli/graph.py +80 -0
- refscan/cli/scan.py +211 -0
- refscan/cli/version.py +14 -0
- refscan/grapher.py +173 -0
- refscan/lib/Finder.py +229 -0
- refscan/lib/Reference.py +37 -0
- refscan/lib/ReferenceList.py +206 -0
- refscan/lib/Violation.py +23 -0
- refscan/lib/ViolationList.py +28 -0
- refscan/lib/constants.py +8 -0
- refscan/lib/helpers.py +267 -0
- refscan/scanner.py +369 -0
- refscan/templates/graph.template.html +297 -0
- refscan-0.0.0.dist-info/LICENSE +42 -0
- refscan-0.0.0.dist-info/METADATA +382 -0
- refscan-0.0.0.dist-info/RECORD +20 -0
- refscan-0.0.0.dist-info/WHEEL +4 -0
- refscan-0.0.0.dist-info/entry_points.txt +3 -0
refscan/lib/helpers.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from typing import Optional, List, Dict
|
|
2
|
+
from functools import cache
|
|
3
|
+
|
|
4
|
+
from pymongo import MongoClient, timeout
|
|
5
|
+
from linkml_runtime import SchemaView, linkml_model
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.progress import Progress, TextColumn, MofNCompleteColumn, BarColumn, TimeElapsedColumn, TimeRemainingColumn
|
|
8
|
+
|
|
9
|
+
from refscan.lib.ReferenceList import ReferenceList
|
|
10
|
+
from refscan.lib.Reference import Reference
|
|
11
|
+
from refscan.lib.constants import DATABASE_CLASS_NAME, console
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def connect_to_database(mongo_uri: str, database_name: str, verbose: bool = True) -> MongoClient:
|
|
15
|
+
"""
|
|
16
|
+
Returns a Mongo client. Raises an exception if the database is not accessible.
|
|
17
|
+
"""
|
|
18
|
+
mongo_client: MongoClient = MongoClient(host=mongo_uri, directConnection=True)
|
|
19
|
+
|
|
20
|
+
with timeout(5): # if any message exchange takes > 5 seconds, this will raise an exception
|
|
21
|
+
if mongo_client.address is None:
|
|
22
|
+
raise ValueError("Failed to determine MongoDB server hostname or port number.")
|
|
23
|
+
(host, port_number) = mongo_client.address
|
|
24
|
+
|
|
25
|
+
if verbose:
|
|
26
|
+
console.print(f'Connected to MongoDB server: "{host}:{port_number}"')
|
|
27
|
+
|
|
28
|
+
# Check whether the database exists on the MongoDB server.
|
|
29
|
+
if database_name not in mongo_client.list_database_names():
|
|
30
|
+
raise ValueError(f'Database "{database_name}" not found on the MongoDB server.')
|
|
31
|
+
|
|
32
|
+
return mongo_client
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@cache # memoizes the decorated function
|
|
36
|
+
def get_collection_names_from_schema(schema_view: SchemaView) -> list[str]:
|
|
37
|
+
"""
|
|
38
|
+
Returns the names of the slots of the `Database` class that describe database collections.
|
|
39
|
+
|
|
40
|
+
:param schema_view: A `SchemaView` instance
|
|
41
|
+
"""
|
|
42
|
+
collection_names = []
|
|
43
|
+
|
|
44
|
+
for slot_name in schema_view.class_slots(DATABASE_CLASS_NAME):
|
|
45
|
+
slot_definition = schema_view.induced_slot(slot_name, DATABASE_CLASS_NAME)
|
|
46
|
+
|
|
47
|
+
# Filter out any hypothetical (future) slots that don't correspond to a collection (e.g. `db_version`).
|
|
48
|
+
if slot_definition.multivalued and slot_definition.inlined_as_list:
|
|
49
|
+
collection_names.append(slot_name)
|
|
50
|
+
|
|
51
|
+
# Filter out duplicate names. This is to work around the following issues in the schema:
|
|
52
|
+
# - https://github.com/microbiomedata/nmdc-schema/issues/1954
|
|
53
|
+
# - https://github.com/microbiomedata/nmdc-schema/issues/1955
|
|
54
|
+
collection_names = list(set(collection_names))
|
|
55
|
+
|
|
56
|
+
return collection_names
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@cache # memoizes the decorated function
|
|
60
|
+
def get_names_of_classes_eligible_for_collection(schema_view: SchemaView, collection_name: str) -> list[str]:
|
|
61
|
+
r"""
|
|
62
|
+
Returns a list of the names of the classes whose instances can be stored in the specified collection,
|
|
63
|
+
according to the specified `SchemaView`.
|
|
64
|
+
"""
|
|
65
|
+
slot_definition = schema_view.induced_slot(collection_name, DATABASE_CLASS_NAME)
|
|
66
|
+
names_of_eligible_classes = get_names_of_classes_in_effective_range_of_slot(
|
|
67
|
+
schema_view=schema_view, slot_definition=slot_definition
|
|
68
|
+
)
|
|
69
|
+
return names_of_eligible_classes
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@cache # memoizes the decorated function
|
|
73
|
+
def get_collection_name_to_class_names_map(
|
|
74
|
+
schema_view: SchemaView,
|
|
75
|
+
) -> Dict[str, List[str]]:
|
|
76
|
+
r"""
|
|
77
|
+
Returns a mapping of collection names to the names of the classes whose instances can be stored
|
|
78
|
+
in those collections, according to the specified `SchemaView`.
|
|
79
|
+
|
|
80
|
+
Example output:
|
|
81
|
+
```
|
|
82
|
+
{
|
|
83
|
+
"study_set": ["Study"],
|
|
84
|
+
"biosample_set": ["Biosample"],
|
|
85
|
+
...
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
"""
|
|
89
|
+
collection_name_to_class_names = {}
|
|
90
|
+
for collection_name in get_collection_names_from_schema(schema_view):
|
|
91
|
+
class_names = get_names_of_classes_eligible_for_collection(schema_view, collection_name)
|
|
92
|
+
collection_name_to_class_names[collection_name] = class_names
|
|
93
|
+
return collection_name_to_class_names
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@cache # memoizes the decorated function
|
|
97
|
+
def translate_class_uri_into_schema_class_name(schema_view: SchemaView, class_uri: str) -> Optional[str]:
|
|
98
|
+
r"""
|
|
99
|
+
Returns the name of the schema class that has the specified value as its `class_uri`.
|
|
100
|
+
|
|
101
|
+
Example: "nmdc:Biosample" (a `class_uri` value) -> "Biosample" (a class name)
|
|
102
|
+
|
|
103
|
+
References:
|
|
104
|
+
- https://linkml.io/linkml/developers/schemaview.html#linkml_runtime.utils.schemaview.SchemaView.all_classes
|
|
105
|
+
- https://linkml.io/linkml/code/metamodel.html#linkml_runtime.linkml_model.meta.ClassDefinition.class_uri
|
|
106
|
+
"""
|
|
107
|
+
schema_class_name = None
|
|
108
|
+
all_class_definitions_in_schema = schema_view.all_classes()
|
|
109
|
+
for class_name, class_definition in all_class_definitions_in_schema.items():
|
|
110
|
+
if class_definition.class_uri == class_uri:
|
|
111
|
+
schema_class_name = class_definition.name
|
|
112
|
+
break
|
|
113
|
+
return schema_class_name
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@cache # memoizes the decorated function
|
|
117
|
+
def translate_schema_class_name_into_class_uri(schema_view: SchemaView, schema_class_name: str) -> Optional[str]:
|
|
118
|
+
r"""
|
|
119
|
+
Returns the `class_uri` of the schema class having the specified name.
|
|
120
|
+
|
|
121
|
+
Example: "Biosample" (a schema class name) -> "nmdc:Biosample" (a `class_uri` value)
|
|
122
|
+
"""
|
|
123
|
+
class_uri = None
|
|
124
|
+
all_class_definitions_in_schema = schema_view.all_classes()
|
|
125
|
+
for class_name, class_definition in all_class_definitions_in_schema.items():
|
|
126
|
+
if class_name == schema_class_name:
|
|
127
|
+
class_uri = class_definition.class_uri
|
|
128
|
+
break
|
|
129
|
+
return class_uri
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def derive_schema_class_name_from_document(schema_view: SchemaView, document: dict) -> Optional[str]:
|
|
133
|
+
r"""
|
|
134
|
+
Returns the name of the schema class, if any, of which the specified document claims to represent an instance.
|
|
135
|
+
|
|
136
|
+
This function is written under the assumption that the document has a `type` field whose value is the `class_uri`
|
|
137
|
+
belonging to the schema class of which the document represents an instance. Slot definition for such a field:
|
|
138
|
+
https://github.com/microbiomedata/berkeley-schema-fy24/blob/fc2d9600/src/schema/basic_slots.yaml#L420-L436
|
|
139
|
+
"""
|
|
140
|
+
schema_class_name = None
|
|
141
|
+
if "type" in document and isinstance(document["type"], str):
|
|
142
|
+
class_uri = document["type"]
|
|
143
|
+
schema_class_name = translate_class_uri_into_schema_class_name(schema_view, class_uri)
|
|
144
|
+
return schema_class_name
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def init_progress_bar() -> Progress:
|
|
148
|
+
r"""
|
|
149
|
+
Initialize a progress bar that shows the elapsed time, M-of-N completed count, and more.
|
|
150
|
+
|
|
151
|
+
Reference: https://rich.readthedocs.io/en/stable/progress.html?highlight=progress#columns
|
|
152
|
+
"""
|
|
153
|
+
custom_progress = Progress(
|
|
154
|
+
TextColumn("[progress.description]{task.description}"),
|
|
155
|
+
TextColumn("[red]{task.fields[num_violations]}[/red] violations in"),
|
|
156
|
+
MofNCompleteColumn(),
|
|
157
|
+
TextColumn("source documents"),
|
|
158
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
159
|
+
BarColumn(),
|
|
160
|
+
TimeElapsedColumn(),
|
|
161
|
+
TextColumn("elapsed"),
|
|
162
|
+
TimeRemainingColumn(elapsed_when_finished=True),
|
|
163
|
+
TextColumn("{task.fields[remaining_time_label]}"),
|
|
164
|
+
console=console,
|
|
165
|
+
refresh_per_second=1,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return custom_progress
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_lowercase_key(key_value_pair: tuple) -> str:
|
|
172
|
+
r"""Returns the key from a `(key, value)` tuple, in lowercase."""
|
|
173
|
+
return key_value_pair[0].lower()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def print_section_header(console: Console, text: str) -> None:
|
|
177
|
+
r"""
|
|
178
|
+
Helper function that prints a vertically-padded,
|
|
179
|
+
labeled, horizontal rule to the specified console.
|
|
180
|
+
|
|
181
|
+
Reference: https://rich.readthedocs.io/en/stable/console.html#rules
|
|
182
|
+
"""
|
|
183
|
+
console.print("")
|
|
184
|
+
console.rule(f"[bold]{text}[/bold]")
|
|
185
|
+
console.print("")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_names_of_classes_in_effective_range_of_slot(
|
|
189
|
+
schema_view: SchemaView, slot_definition: linkml_model.SlotDefinition
|
|
190
|
+
) -> List[str]:
|
|
191
|
+
r"""
|
|
192
|
+
Determine the slot's "effective" range, by taking into account its `any_of` constraints (if defined).
|
|
193
|
+
|
|
194
|
+
Note: The `any_of` constraints constrain the slot's "effective" range beyond that described by the
|
|
195
|
+
induced slot definition's `range` attribute. `SchemaView` does not seem to provide the result
|
|
196
|
+
of applying those additional constraints, so we do it manually here (if any are defined).
|
|
197
|
+
Reference: https://github.com/orgs/linkml/discussions/2101#discussion-6625646
|
|
198
|
+
|
|
199
|
+
Reference: https://linkml.io/linkml-model/latest/docs/any_of/
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
# Initialize the list to be empty.
|
|
203
|
+
names_of_eligible_target_classes = []
|
|
204
|
+
|
|
205
|
+
# If the `any_of` constraint is defined on this slot, use that instead of the `range`.
|
|
206
|
+
if "any_of" in slot_definition and len(slot_definition.any_of) > 0:
|
|
207
|
+
for slot_expression in slot_definition.any_of:
|
|
208
|
+
# Use the slot expression's `range` to get the specified eligible class name
|
|
209
|
+
# and the names of all classes that inherit from that eligible class.
|
|
210
|
+
if slot_expression.range in schema_view.all_classes():
|
|
211
|
+
own_and_descendant_class_names = schema_view.class_descendants(slot_expression.range)
|
|
212
|
+
names_of_eligible_target_classes.extend(own_and_descendant_class_names)
|
|
213
|
+
else:
|
|
214
|
+
# Use the slot's `range` to get the specified eligible class name
|
|
215
|
+
# and the names of all classes that inherit from that eligible class.
|
|
216
|
+
if slot_definition.range in schema_view.all_classes():
|
|
217
|
+
own_and_descendant_class_names = schema_view.class_descendants(slot_definition.range)
|
|
218
|
+
names_of_eligible_target_classes.extend(own_and_descendant_class_names)
|
|
219
|
+
|
|
220
|
+
# Remove duplicate class names.
|
|
221
|
+
names_of_eligible_target_classes = list(set(names_of_eligible_target_classes))
|
|
222
|
+
|
|
223
|
+
return names_of_eligible_target_classes
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def identify_references(
|
|
227
|
+
schema_view: SchemaView,
|
|
228
|
+
collection_name_to_class_names: dict[str, list[str]],
|
|
229
|
+
) -> ReferenceList:
|
|
230
|
+
r"""
|
|
231
|
+
Returns a `ReferenceList`, identifying all the inter-document references that the schema allows to exist.
|
|
232
|
+
|
|
233
|
+
Note: This list is derived from the schema alone. There is no database involved.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
# Initialize the list of references.
|
|
237
|
+
references = ReferenceList()
|
|
238
|
+
|
|
239
|
+
# For each class whose instances can be stored in each collection, determine which of its slots can be a reference.
|
|
240
|
+
sorted_collection_names_to_class_names = sorted(collection_name_to_class_names.items(), key=get_lowercase_key)
|
|
241
|
+
for collection_name, class_names in sorted_collection_names_to_class_names:
|
|
242
|
+
for class_name in class_names:
|
|
243
|
+
for slot_name in schema_view.class_slots(class_name):
|
|
244
|
+
|
|
245
|
+
# Get the slot definition in the context of its use on this particular class.
|
|
246
|
+
slot_definition = schema_view.induced_slot(slot_name=slot_name, class_name=class_name)
|
|
247
|
+
|
|
248
|
+
# Determine the slot's "effective" range, taking into account its `any_of` constraint (if it has one).
|
|
249
|
+
names_of_eligible_target_classes = get_names_of_classes_in_effective_range_of_slot(
|
|
250
|
+
schema_view=schema_view,
|
|
251
|
+
slot_definition=slot_definition,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# For each of those classes whose instances can be stored in any collection, catalog a reference.
|
|
255
|
+
for name_of_eligible_target_class in names_of_eligible_target_classes:
|
|
256
|
+
for target_collection_name, class_names_in_collection in collection_name_to_class_names.items():
|
|
257
|
+
if name_of_eligible_target_class in class_names_in_collection:
|
|
258
|
+
reference = Reference(
|
|
259
|
+
source_collection_name=collection_name,
|
|
260
|
+
source_class_name=class_name,
|
|
261
|
+
source_field_name=slot_name,
|
|
262
|
+
target_collection_name=target_collection_name,
|
|
263
|
+
target_class_name=name_of_eligible_target_class,
|
|
264
|
+
)
|
|
265
|
+
references.append(reference)
|
|
266
|
+
|
|
267
|
+
return references
|
refscan/scanner.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
from typing import List, Optional, Dict, Set, Tuple
|
|
2
|
+
|
|
3
|
+
from pymongo.database import Database
|
|
4
|
+
from pymongo.client_session import ClientSession
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from linkml_runtime import SchemaView
|
|
7
|
+
|
|
8
|
+
from refscan.lib.Finder import Finder
|
|
9
|
+
from refscan.lib.helpers import (
|
|
10
|
+
derive_schema_class_name_from_document,
|
|
11
|
+
get_collection_names_from_schema,
|
|
12
|
+
translate_class_uri_into_schema_class_name,
|
|
13
|
+
translate_schema_class_name_into_class_uri,
|
|
14
|
+
init_progress_bar,
|
|
15
|
+
)
|
|
16
|
+
from refscan.lib.ReferenceList import ReferenceList
|
|
17
|
+
from refscan.lib.Violation import Violation
|
|
18
|
+
from refscan.lib.ViolationList import ViolationList
|
|
19
|
+
from refscan.lib.constants import console as default_console
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def identify_referring_documents(
|
|
23
|
+
document: dict,
|
|
24
|
+
schema_view: SchemaView,
|
|
25
|
+
references: ReferenceList,
|
|
26
|
+
finder: Finder,
|
|
27
|
+
client_session: Optional[ClientSession] = None,
|
|
28
|
+
) -> List[dict]:
|
|
29
|
+
r"""
|
|
30
|
+
Identifies documents that reference the specified one.
|
|
31
|
+
|
|
32
|
+
Note: This function only searches collections and fields that the _schema_ says
|
|
33
|
+
can reference the specified document. If an arbitrary collection contains
|
|
34
|
+
a document that has a field that contains the `id` of the specified document
|
|
35
|
+
(e.g. the `foo_set` collection contains a document whose `nickname` field
|
|
36
|
+
contains `someIdentifier`), it is not necessarily the case that this function
|
|
37
|
+
will consider that to be a reference. It depends upon what the _schema_ says.
|
|
38
|
+
|
|
39
|
+
Note: This can be useful for determining whether the specified document can be safely deleted.
|
|
40
|
+
|
|
41
|
+
:param document: The document whose incoming references you want to identify
|
|
42
|
+
:param schema_view: A `SchemaView` bound to the schema
|
|
43
|
+
:param references: A `ReferenceList` derived from the schema
|
|
44
|
+
:param finder: A `Finder` bound to the database being scanned
|
|
45
|
+
:param client_session: A `pymongo.client_session.ClientSession` instance that, if specified, will be used when
|
|
46
|
+
searching for referring documents. If a transaction happens to be pending on that session,
|
|
47
|
+
the scan will effectively happen on the database as it _would_ exist if that transaction
|
|
48
|
+
were to be committed.
|
|
49
|
+
:return: A list of descriptors of documents that reference the specified document
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
# Initialize a list of descriptors of documents that reference the specified document.
|
|
53
|
+
referring_document_descriptors = []
|
|
54
|
+
|
|
55
|
+
# If the specified document does not have an `id` field, we know it cannot be referenced.
|
|
56
|
+
if "id" not in document:
|
|
57
|
+
return referring_document_descriptors
|
|
58
|
+
|
|
59
|
+
# Get the specified document's `id` and derive its schema class name.
|
|
60
|
+
document_id = document["id"]
|
|
61
|
+
document_class_name = derive_schema_class_name_from_document(schema_view, document)
|
|
62
|
+
if document_class_name is None:
|
|
63
|
+
raise ValueError(f"Failed to identify schema class of document having `id`: {document_id}")
|
|
64
|
+
|
|
65
|
+
# Identify potential references to the specified document, then group them by their source collection names.
|
|
66
|
+
potential_references_to_document = references.get_by_target_class_name(document_class_name)
|
|
67
|
+
potential_references_by_source_collection = potential_references_to_document.group_by_source_collection_name()
|
|
68
|
+
|
|
69
|
+
# For each source collection name, check each of its potential references.
|
|
70
|
+
for collection_name, potential_references_in_collection in potential_references_by_source_collection.items():
|
|
71
|
+
# Make a list of all the distinct `class_uri`-and-`field_name` tuples.
|
|
72
|
+
#
|
|
73
|
+
# Note: Since a `Reference` doesn't have a `source_class_uri` attribute (but it does have a
|
|
74
|
+
# `source_class_name` attribute), we derive its `class_uri` from that `source_class_name` attribute.
|
|
75
|
+
#
|
|
76
|
+
class_uri_and_field_name_tuples: Set[Tuple[str, str]] = set()
|
|
77
|
+
for potential_reference in potential_references_in_collection:
|
|
78
|
+
class_uri = translate_schema_class_name_into_class_uri(
|
|
79
|
+
schema_view=schema_view, schema_class_name=potential_reference.source_class_name
|
|
80
|
+
)
|
|
81
|
+
if class_uri is None:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Failed to translate schema class name '{potential_reference.source_class_name}' into class_uri."
|
|
84
|
+
)
|
|
85
|
+
class_uri_and_field_name_tuple = (class_uri, potential_reference.source_field_name)
|
|
86
|
+
class_uri_and_field_name_tuples.add(class_uri_and_field_name_tuple)
|
|
87
|
+
|
|
88
|
+
# For each distinct combination of `class_uri` and `field_name`, check whether any documents in this
|
|
89
|
+
# collection both (a) have a `type` value matching that `class_uri` and (b) have the subject document's
|
|
90
|
+
# `id` value in the specified field.
|
|
91
|
+
referring_documents = finder.find_documents_having_type_and_value_in_field(
|
|
92
|
+
collection_name=collection_name,
|
|
93
|
+
type_and_field_name_tuples=list(class_uri_and_field_name_tuples),
|
|
94
|
+
value=document_id,
|
|
95
|
+
client_session=client_session,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# For each referring document, store a descriptor of it.
|
|
99
|
+
for referring_document in referring_documents:
|
|
100
|
+
|
|
101
|
+
# Handle the case where the source document does not have an `id`.
|
|
102
|
+
#
|
|
103
|
+
# Note: As a reminder, according to the NMDC Schema (as of version v11.8.0), documents in the
|
|
104
|
+
# `functional_annotation_agg` collection do not have an `id` field.
|
|
105
|
+
# Reference: https://microbiomedata.github.io/nmdc-schema/FunctionalAnnotationAggMember/
|
|
106
|
+
#
|
|
107
|
+
source_document_id = referring_document.get("id", None)
|
|
108
|
+
if not isinstance(source_document_id, str):
|
|
109
|
+
source_document_id = ""
|
|
110
|
+
|
|
111
|
+
source_class_name = translate_class_uri_into_schema_class_name(
|
|
112
|
+
schema_view=schema_view,
|
|
113
|
+
class_uri=referring_document["type"],
|
|
114
|
+
)
|
|
115
|
+
document_descriptor = dict(
|
|
116
|
+
source_collection_name=collection_name,
|
|
117
|
+
source_class_name=source_class_name,
|
|
118
|
+
source_document_object_id=referring_document["_id"],
|
|
119
|
+
source_document_id=source_document_id,
|
|
120
|
+
)
|
|
121
|
+
referring_document_descriptors.append(document_descriptor)
|
|
122
|
+
|
|
123
|
+
return referring_document_descriptors
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def scan_outgoing_references(
|
|
127
|
+
document: dict,
|
|
128
|
+
schema_view: SchemaView,
|
|
129
|
+
references: ReferenceList,
|
|
130
|
+
finder: Finder,
|
|
131
|
+
source_collection_name: str,
|
|
132
|
+
client_session: Optional[ClientSession] = None,
|
|
133
|
+
user_wants_to_locate_misplaced_documents: bool = False,
|
|
134
|
+
) -> ViolationList:
|
|
135
|
+
r"""
|
|
136
|
+
Scans the references emanating from the specified document, for referential integrity violations. In other words,
|
|
137
|
+
checks whether all documents references by this one exist in places the schema allows them to exist.
|
|
138
|
+
|
|
139
|
+
:param document: The source document from which the references emanate
|
|
140
|
+
:param schema_view: A SchemaView bound to the schema
|
|
141
|
+
:param references: A `ReferenceList` derived from the schema
|
|
142
|
+
:param finder: A `Finder` bound to the database being scanned
|
|
143
|
+
:param source_collection_name: Name of collection in which source document resides (this is included in the
|
|
144
|
+
violation report in an attempt to facilitate investigation of violations)
|
|
145
|
+
:param client_session: A `pymongo.client_session.ClientSession` instance that, if specified, will be used when
|
|
146
|
+
searching for referenced documents. If a transaction happens to be pending on that session,
|
|
147
|
+
the scan will effectively happen on the database as it _would_ exist if that transaction
|
|
148
|
+
were to be committed.
|
|
149
|
+
:param user_wants_to_locate_misplaced_documents: Whether the user wants the function to proceed to search illegal
|
|
150
|
+
collections after failing to find the referenced document among
|
|
151
|
+
all the legal collections
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
# Initialize a list of violations.
|
|
155
|
+
violations = ViolationList()
|
|
156
|
+
|
|
157
|
+
# Get the document's `id` so that we can include it in this script's output.
|
|
158
|
+
source_document_object_id = document["_id"]
|
|
159
|
+
source_document_id: Optional[str] = document["id"] if "id" in document else None
|
|
160
|
+
|
|
161
|
+
# Get the document's schema class name so that we can interpret its fields accordingly.
|
|
162
|
+
source_class_name = derive_schema_class_name_from_document(schema_view, document)
|
|
163
|
+
if source_class_name is None:
|
|
164
|
+
raise ValueError(f"Failed to derive schema class name from document having `id`: {source_document_id}")
|
|
165
|
+
|
|
166
|
+
# Get the names of that class's fields that can contain references.
|
|
167
|
+
reference_field_names_by_source_class_name = references.get_reference_field_names_by_source_class_name()
|
|
168
|
+
names_of_reference_fields = reference_field_names_by_source_class_name.get(source_class_name, [])
|
|
169
|
+
|
|
170
|
+
# Get the names of collections that are described by the schema.
|
|
171
|
+
collection_names = get_collection_names_from_schema(schema_view=schema_view)
|
|
172
|
+
|
|
173
|
+
# Check each field that both (a) exists in the document and (b) can contain a reference.
|
|
174
|
+
for field_name in names_of_reference_fields:
|
|
175
|
+
if field_name in document:
|
|
176
|
+
# Determine which collections can contain the referenced document, based upon
|
|
177
|
+
# the schema class of which this source document is an instance.
|
|
178
|
+
target_collection_names = references.get_target_collection_names(
|
|
179
|
+
source_class_name=source_class_name,
|
|
180
|
+
source_field_name=field_name,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Handle both the multi-value (array) and the single-value (scalar) case,
|
|
184
|
+
# normalizing the value or values into a list of values in either case.
|
|
185
|
+
if type(document[field_name]) is list:
|
|
186
|
+
target_ids = document[field_name]
|
|
187
|
+
else:
|
|
188
|
+
target_id = document[field_name]
|
|
189
|
+
target_ids = [target_id] # makes a one-item list
|
|
190
|
+
|
|
191
|
+
for target_id in target_ids:
|
|
192
|
+
name_of_collection_containing_target_document = (
|
|
193
|
+
finder.check_whether_document_having_id_exists_among_collections(
|
|
194
|
+
collection_names=target_collection_names, document_id=target_id, client_session=client_session
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
if name_of_collection_containing_target_document is None:
|
|
198
|
+
|
|
199
|
+
# If the user wants to locate misplaced documents,
|
|
200
|
+
# search all illegal collections for the misplaced document.
|
|
201
|
+
if user_wants_to_locate_misplaced_documents:
|
|
202
|
+
names_of_ineligible_collections = list(set(collection_names) - set(target_collection_names))
|
|
203
|
+
name_of_collection_containing_target_document = (
|
|
204
|
+
finder.check_whether_document_having_id_exists_among_collections(
|
|
205
|
+
collection_names=names_of_ineligible_collections,
|
|
206
|
+
document_id=target_id,
|
|
207
|
+
client_session=client_session,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Handle the case where the source document does not have an `id`.
|
|
212
|
+
#
|
|
213
|
+
# Note: As a reminder, according to the NMDC Schema (as of version v11.8.0), documents in the
|
|
214
|
+
# `functional_annotation_agg` collection do not have an `id` field.
|
|
215
|
+
# Reference: https://microbiomedata.github.io/nmdc-schema/FunctionalAnnotationAggMember/
|
|
216
|
+
#
|
|
217
|
+
if not isinstance(source_document_id, str):
|
|
218
|
+
source_document_id = ""
|
|
219
|
+
|
|
220
|
+
# Instantiate a `Violation` containing information about this referential integrity violation.
|
|
221
|
+
violation = Violation(
|
|
222
|
+
source_collection_name=source_collection_name,
|
|
223
|
+
source_class_name=source_class_name,
|
|
224
|
+
source_field_name=field_name,
|
|
225
|
+
source_document_object_id=source_document_object_id,
|
|
226
|
+
source_document_id=source_document_id,
|
|
227
|
+
target_id=target_id,
|
|
228
|
+
name_of_collection_containing_target=name_of_collection_containing_target_document,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Append this violation to the list of this document's violations.
|
|
232
|
+
violations.append(violation)
|
|
233
|
+
|
|
234
|
+
return violations
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def scan(
|
|
238
|
+
db: Database,
|
|
239
|
+
schema_view: SchemaView,
|
|
240
|
+
references: ReferenceList,
|
|
241
|
+
names_of_source_collections_to_skip: List[str],
|
|
242
|
+
user_wants_to_locate_misplaced_documents: bool = False,
|
|
243
|
+
console: Console = default_console,
|
|
244
|
+
verbose: bool = False,
|
|
245
|
+
) -> Dict[str, ViolationList]:
|
|
246
|
+
"""
|
|
247
|
+
Scans the NMDC MongoDB database for referential integrity violations according to the parameters passed in,
|
|
248
|
+
returning a dictionary containing a list of violations for each collection scanned.
|
|
249
|
+
|
|
250
|
+
:param db: Database you want to scan for referential integrity violations
|
|
251
|
+
:param schema_view: A SchemaView bound to the schema with which that database complies
|
|
252
|
+
(except that it may not be compliant in terms of referential integrity)
|
|
253
|
+
:param references: A `ReferenceList` derived from the schema
|
|
254
|
+
:param names_of_source_collections_to_skip: List of source collections to skip
|
|
255
|
+
:param user_wants_to_locate_misplaced_documents: Whether the user wants the function to proceed to search illegal
|
|
256
|
+
collections after failing to find the referenced document among
|
|
257
|
+
all the legal collections
|
|
258
|
+
:param console: A `Console` to which the function can print messages
|
|
259
|
+
:param verbose: Whether you want the function to print a higher-than-normal amount of information to the console
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
# Initialize a progress bar.
|
|
263
|
+
custom_progress = init_progress_bar()
|
|
264
|
+
|
|
265
|
+
# Make a finder bound to this database.
|
|
266
|
+
# Note: A finder is a wrapper around a database that adds some caching that speeds up searches in some situations.
|
|
267
|
+
finder = Finder(database=db)
|
|
268
|
+
|
|
269
|
+
source_collections_and_their_violations: dict[str, ViolationList] = {}
|
|
270
|
+
with custom_progress as progress:
|
|
271
|
+
|
|
272
|
+
# Filter out the collections that the schema says can contain references, but that don't exist in the database.
|
|
273
|
+
source_collection_names_in_db = []
|
|
274
|
+
for collection_name in references.get_source_collection_names():
|
|
275
|
+
if db.get_collection(collection_name) is None:
|
|
276
|
+
console.print(f"🤷 [orange]Database lacks collection:[/orange] {collection_name}")
|
|
277
|
+
else:
|
|
278
|
+
source_collection_names_in_db.append(collection_name)
|
|
279
|
+
|
|
280
|
+
# Print a message about each collection being skipped.
|
|
281
|
+
for i, collection_name in enumerate(names_of_source_collections_to_skip):
|
|
282
|
+
if i == 0:
|
|
283
|
+
console.print() # leading newline
|
|
284
|
+
console.print(f"⚠️ [orange][bold]Skipping source collection:[/bold][/orange] {collection_name}")
|
|
285
|
+
console.print() # newline
|
|
286
|
+
|
|
287
|
+
# Process each collection, checking for referential integrity violations;
|
|
288
|
+
# using the reference catalog created earlier to know which collections can
|
|
289
|
+
# contain "referrers" (documents), which of their slots can contain references (fields),
|
|
290
|
+
# and which collections can contain the referred-to "referees" (documents).
|
|
291
|
+
for source_collection_name in sorted(source_collection_names_in_db):
|
|
292
|
+
|
|
293
|
+
# If this source collection is one of the ones the user wanted to skip, skip it now.
|
|
294
|
+
if source_collection_name in names_of_source_collections_to_skip:
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
collection = db.get_collection(source_collection_name)
|
|
298
|
+
|
|
299
|
+
# Prepare the query we will use to fetch documents from this collection. The documents we will fetch are
|
|
300
|
+
# those that have _any_ of the fields (of classes whose instances are allowed to reside in this collection)
|
|
301
|
+
# that the schema allows to contain a reference to an instance.
|
|
302
|
+
source_field_names = references.get_source_field_names_of_source_collection(source_collection_name)
|
|
303
|
+
or_terms = [{field_name: {"$exists": True}} for field_name in source_field_names]
|
|
304
|
+
query_filter = {"$or": or_terms}
|
|
305
|
+
if verbose:
|
|
306
|
+
console.print(f"{query_filter=}")
|
|
307
|
+
|
|
308
|
+
# Ensure the fields we fetch include:
|
|
309
|
+
# - "id" (so we can produce a more user-friendly report later)
|
|
310
|
+
# - "type" (so we can map the document to a schema class)
|
|
311
|
+
additional_field_names_for_projection = []
|
|
312
|
+
if "id" not in source_field_names:
|
|
313
|
+
additional_field_names_for_projection.append("id")
|
|
314
|
+
if "type" not in source_field_names:
|
|
315
|
+
additional_field_names_for_projection.append("type")
|
|
316
|
+
query_projection = source_field_names + additional_field_names_for_projection
|
|
317
|
+
if verbose:
|
|
318
|
+
console.print(f"{query_projection=}")
|
|
319
|
+
|
|
320
|
+
# Set up the progress bar for the task of scanning those documents.
|
|
321
|
+
num_relevant_documents = collection.count_documents(query_filter)
|
|
322
|
+
task_id = progress.add_task(
|
|
323
|
+
f"{source_collection_name}",
|
|
324
|
+
total=num_relevant_documents,
|
|
325
|
+
num_violations=0,
|
|
326
|
+
remaining_time_label="remaining",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Advance the progress bar by 0 (this makes it so that, even if there are 0 relevant documents, the progress
|
|
330
|
+
# bar does not continue incrementing its "elapsed time" even after a subsequent task has begun).
|
|
331
|
+
progress.update(task_id, advance=0)
|
|
332
|
+
|
|
333
|
+
# Initialize the violation list for this collection.
|
|
334
|
+
source_collections_and_their_violations[source_collection_name] = ViolationList()
|
|
335
|
+
|
|
336
|
+
# Process each relevant document.
|
|
337
|
+
for document in collection.find(query_filter, projection=query_projection):
|
|
338
|
+
violations = scan_outgoing_references(
|
|
339
|
+
document=document,
|
|
340
|
+
schema_view=schema_view,
|
|
341
|
+
references=references,
|
|
342
|
+
finder=finder,
|
|
343
|
+
source_collection_name=source_collection_name,
|
|
344
|
+
user_wants_to_locate_misplaced_documents=user_wants_to_locate_misplaced_documents,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Add these violations—if any—to the list for this source collection.
|
|
348
|
+
source_collections_and_their_violations[source_collection_name].extend(violations)
|
|
349
|
+
|
|
350
|
+
# If operating verbosely, print a message about each violation.
|
|
351
|
+
if verbose:
|
|
352
|
+
for violation in violations:
|
|
353
|
+
console.print(
|
|
354
|
+
f"Failed to find document having `id` '{violation.target_id}' "
|
|
355
|
+
f"among collections allowed by schema. "
|
|
356
|
+
f"{violation=}"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Advance the progress bar to account for the current document's contribution to the violations count.
|
|
360
|
+
progress.update(
|
|
361
|
+
task_id,
|
|
362
|
+
advance=1,
|
|
363
|
+
num_violations=len(source_collections_and_their_violations[source_collection_name]),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Update the progress bar to indicate the current task is complete.
|
|
367
|
+
progress.update(task_id, remaining_time_label="done")
|
|
368
|
+
|
|
369
|
+
return source_collections_and_their_violations
|