refscan 0.2.4__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: refscan
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: Command-line program that scans the NMDC MongoDB database for referential integrity violations
5
5
  Keywords: mongodb,mongo,relationships,reference,database,data,referential integrity,scan
6
6
  Requires-Python: >=3.9,<4.0
@@ -313,6 +313,16 @@ While editing the tool's source code, you can run the tool as you normally would
313
313
  poetry run refscan --help
314
314
  ```
315
315
 
316
+ ### Check types
317
+
318
+ We use [mypy](https://mypy.readthedocs.io/en/stable/) as the static type checker for `refscan`.
319
+
320
+ You can perform static type checking by running the following command from the root directory of the repository:
321
+
322
+ ```shell
323
+ poetry run mypy
324
+ ```
325
+
316
326
  ### Run tests
317
327
 
318
328
  We use [pytest](https://docs.pytest.org/en/8.2.x/) as the testing framework for `refscan`.
@@ -292,6 +292,16 @@ While editing the tool's source code, you can run the tool as you normally would
292
292
  poetry run refscan --help
293
293
  ```
294
294
 
295
+ ### Check types
296
+
297
+ We use [mypy](https://mypy.readthedocs.io/en/stable/) as the static type checker for `refscan`.
298
+
299
+ You can perform static type checking by running the following command from the root directory of the repository:
300
+
301
+ ```shell
302
+ poetry run mypy
303
+ ```
304
+
295
305
  ### Run tests
296
306
 
297
307
  We use [pytest](https://docs.pytest.org/en/8.2.x/) as the testing framework for `refscan`.
@@ -2,7 +2,7 @@
2
2
 
3
3
  [tool.poetry]
4
4
  name = "refscan"
5
- version = "v0.2.4"
5
+ version = "v0.3.0"
6
6
  description = "Command-line program that scans the NMDC MongoDB database for referential integrity violations"
7
7
  authors = []
8
8
  readme = "README.md"
@@ -43,6 +43,10 @@ pytest-cov = "^5.0.0"
43
43
  # We use `mongomock` to test MongoDB interaction without involving a real MongoDB instance.
44
44
  # Docs: https://github.com/mongomock/mongomock
45
45
  mongomock = "^4.3.0"
46
+ # We use `mypy` to perform static type checking.
47
+ # Usage: $ poetry run mypy
48
+ # Docs: https://mypy.readthedocs.io/en/stable/
49
+ mypy = "^1.16.0"
46
50
 
47
51
  [tool.poetry.scripts]
48
52
  # Reference: https://python-poetry.org/docs/pyproject#scripts
@@ -61,6 +65,25 @@ addopts = "--doctest-modules --cov=refscan"
61
65
  # Reference: https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#configuration-format
62
66
  line-length = 120
63
67
 
68
+ [tool.mypy]
69
+ # Make it so, when someone runs `$ poetry run mypy`, these default CLI options are used.
70
+ # Reference: https://mypy.readthedocs.io/en/stable/config_file.html
71
+ files = "."
72
+
73
+ [[tool.mypy.overrides]]
74
+ # Silence the following error:
75
+ # > Skipping analyzing "linkml_runtime": module is installed, but missing library stubs or py.typed marker [import-untyped]
76
+ #
77
+ # Note: We first tried using the following, but it seemed to have no effect on the behavior of mypy when run via `$ poetry run mypy`.
78
+ # ```
79
+ # [mypy-linkml_runtime]
80
+ # ignore_missing_imports = true
81
+ # ```
82
+ # So, we—instead—used the syntax shown on: https://stackoverflow.com/a/74198016
83
+ #
84
+ module = "linkml_runtime"
85
+ ignore_missing_imports = true
86
+
64
87
  [build-system]
65
88
  requires = ["poetry-core"]
66
89
  build-backend = "poetry.core.masonry.api"
@@ -12,7 +12,7 @@ def get_package_metadata(key: str) -> str:
12
12
  metadata_value = ""
13
13
  try:
14
14
  package_metadata = metadata("refscan")
15
- metadata_value = package_metadata.get(key)
15
+ metadata_value = package_metadata.get(key, "")
16
16
  except PackageNotFoundError:
17
17
  pass
18
18
  return metadata_value
@@ -8,10 +8,10 @@ import linkml_runtime
8
8
  from refscan.lib.constants import console
9
9
  from refscan.lib.helpers import (
10
10
  connect_to_database,
11
+ get_collection_name_to_class_names_map,
11
12
  get_collection_names_from_schema,
12
13
  get_lowercase_key,
13
14
  print_section_header,
14
- get_names_of_classes_eligible_for_collection,
15
15
  identify_references,
16
16
  )
17
17
  from refscan.lib.ViolationList import ViolationList
@@ -134,12 +134,7 @@ def scan(
134
134
  console.print(f"Collections described by schema: {len(collection_names)}")
135
135
 
136
136
  # For each collection, determine the names of the classes whose instances can be stored in that collection.
137
- collection_name_to_class_names = {} # example: { "study_set": ["Study"] }
138
- for collection_name in sorted(collection_names):
139
- collection_name_to_class_names[collection_name] = get_names_of_classes_eligible_for_collection(
140
- schema_view=schema_view,
141
- collection_name=collection_name,
142
- )
137
+ collection_name_to_class_names = get_collection_name_to_class_names_map(schema_view=schema_view)
143
138
 
144
139
  # Identify the inter-document references that the schema allows to exist.
145
140
  references = identify_references(
@@ -177,7 +172,6 @@ def scan(
177
172
  db=db,
178
173
  schema_view=schema_view,
179
174
  references=references,
180
- collection_names=collection_names,
181
175
  names_of_source_collections_to_skip=names_of_source_collections_to_skip,
182
176
  user_wants_to_locate_misplaced_documents=user_wants_to_locate_misplaced_documents,
183
177
  verbose=verbose,
@@ -1,6 +1,5 @@
1
1
  from enum import Enum
2
- from pathlib import Path
3
- from typing import Union
2
+ from typing import Union, List, Dict, Any
4
3
  import json
5
4
  import base64
6
5
  from importlib import resources
@@ -10,12 +9,17 @@ import linkml_runtime
10
9
  from refscan import get_package_metadata
11
10
  from refscan.lib.constants import console
12
11
  from refscan.lib.helpers import (
12
+ get_collection_name_to_class_names_map,
13
13
  print_section_header,
14
14
  get_collection_names_from_schema,
15
- get_names_of_classes_eligible_for_collection,
16
15
  identify_references,
17
16
  )
18
17
 
18
+ # Define some type aliases that we can reference later, to simplify the latter type hints.
19
+ # TODO: Update these type hints to not use `Any`.
20
+ NodeType = Dict[str, Dict[str, Any]]
21
+ EdgeType = Dict[str, Dict[str, Any]]
22
+
19
23
 
20
24
  class Subject(str, Enum):
21
25
  r"""The subject (i.e. focal point) of the graph."""
@@ -78,12 +82,7 @@ def graph(
78
82
  console.print(f"Collections described by schema: {len(collection_names)}")
79
83
 
80
84
  # For each collection, determine the names of the classes whose instances can be stored in that collection.
81
- collection_name_to_class_names = {} # example: { "study_set": ["Study"] }
82
- for collection_name in sorted(collection_names):
83
- collection_name_to_class_names[collection_name] = get_names_of_classes_eligible_for_collection(
84
- schema_view=schema_view,
85
- collection_name=collection_name,
86
- )
85
+ collection_name_to_class_names = get_collection_name_to_class_names_map(schema_view=schema_view)
87
86
 
88
87
  # Identify the inter-document references that the schema allows to exist.
89
88
  references = identify_references(
@@ -111,8 +110,8 @@ def graph(
111
110
  #
112
111
  # Reference: https://js.cytoscape.org/#notation/elements-json
113
112
  #
114
- nodes = []
115
- edges = []
113
+ nodes: List[NodeType] = []
114
+ edges: List[EdgeType] = []
116
115
  for r in references:
117
116
  # Get the source and target names depending upon the subject of the graph.
118
117
  source_name = r.source_collection_name if subject == Subject.collection else r.source_class_name
@@ -1,4 +1,4 @@
1
- from typing import List, Optional, Dict
1
+ from typing import List, Optional, Dict, Tuple
2
2
 
3
3
  from pymongo.database import Database
4
4
  from pymongo.client_session import ClientSession
@@ -24,8 +24,8 @@ class Finder:
24
24
  # from source documents that we process _temporally close_ to one another, likely point to target
25
25
  # documents residing _in the same collections_ as one another.
26
26
  #
27
- self.cached_collection_names_where_recently_found = []
28
- self.cache_size = 2 # we'll cache up to 2 collection names
27
+ self.cached_collection_names_where_recently_found: List[str] = []
28
+ self.cache_size: int = 2 # we'll cache up to 2 collection names
29
29
 
30
30
  # Initialize our cache of document `id` presences/absences by collection.
31
31
  #
@@ -118,7 +118,7 @@ class Finder:
118
118
  return self.cached_id_presence_by_collection[collection_name][document_id]
119
119
 
120
120
  def check_whether_document_having_id_exists_among_collections(
121
- self, document_id: str, collection_names: List[str], client_session: ClientSession = None
121
+ self, document_id: str, collection_names: List[str], client_session: Optional[ClientSession] = None
122
122
  ) -> Optional[str]:
123
123
  r"""
124
124
  Checks whether any document in any of the specified collections has the specified value in its `id` field.
@@ -182,3 +182,48 @@ class Finder:
182
182
  )
183
183
 
184
184
  return name_of_collection_containing_target_document
185
+
186
+ def find_documents_having_type_and_value_in_field(
187
+ self,
188
+ collection_name: str,
189
+ type_and_field_name_tuples: List[Tuple[str, str]],
190
+ value: str,
191
+ client_session: Optional[ClientSession] = None,
192
+ ) -> List[dict]:
193
+ r"""
194
+ Finds all documents residing in the specified collection that—for any of the specified
195
+ `type`-and-`field_name` combinations—have both (a) that `type` value and (b) the specified
196
+ `value` value in their `field_name` field. Returns a list of descriptors of those
197
+ documents (each descriptor consists of the document's `_id`, `id`, and `type`).
198
+
199
+ For example, if the `type_and_field_name_tuples` parameter contains the tuple
200
+ `("nmdc:Study", "part_of")`, and the `value` parameter is `"nmdc:sty-00-000001"`,
201
+ then this function will return all documents in the specified collection where both
202
+ (a) the document's `type` field contains the value `"nmdc:Study"` and
203
+ (b) the document's `part_of` field contains the value `"nmdc:sty-00-000001"`.
204
+
205
+ Note: `type_and_field_name_tuples` is a list of tuples, where the first item in each tuple is a
206
+ `type` value (e.g. "nmdc:Study") and the second item is a field name (e.g. "part_of").
207
+
208
+ If a `pymongo.client_session.ClientSession` is specified, this function will access the database within
209
+ the context of that session.
210
+ """
211
+
212
+ # Build the query filter terms based on the `type`-and-`field_name` tuples.
213
+ query_filter_terms = []
214
+ for type_and_field_name_tuple in type_and_field_name_tuples:
215
+ type_value, field_name = type_and_field_name_tuple
216
+ query_filter_term = {"type": type_value, field_name: value}
217
+ query_filter_terms.append(query_filter_term)
218
+
219
+ # Define the overall query filter as a logical "OR" of the individual terms.
220
+ query_filter = {"$or": query_filter_terms}
221
+
222
+ # Only request essential data from the database (as a performance optimization).
223
+ projection = ["_id", "id", "type"]
224
+
225
+ documents = []
226
+ collection = self.db.get_collection(collection_name)
227
+ for document in collection.find(query_filter, projection=projection, session=client_session):
228
+ documents.append(document)
229
+ return documents
@@ -1,4 +1,4 @@
1
- from typing import List, Dict, Union
1
+ from typing import List, Dict, Union, Optional
2
2
  from pathlib import Path
3
3
  from dataclasses import fields, astuple
4
4
  from collections import UserList
@@ -18,6 +18,17 @@ class ReferenceList(UserList):
18
18
  One thing it does is enable sorting via `sorted(the_list)`.
19
19
  """
20
20
 
21
+ # A cached dictionary that maps source class names to a list of the names of
22
+ # the fields of that class that contain references. Initialized to `None` to
23
+ # indicate that it hasn't been computed/cached yet.
24
+ #
25
+ # Example: {"Study": ["part_of"]}
26
+ #
27
+ # TODO: Consider implementing caching via functools's `@cached_property` decorator instead.
28
+ # Reference: https://docs.python.org/3/library/functools.html#functools.cached_property
29
+ #
30
+ _reference_field_names_by_source_class_name: Optional[Dict[str, List[str]]] = None
31
+
21
32
  def get_source_collection_names(self) -> list[str]:
22
33
  """
23
34
  Returns the distinct `source_collection_names` values among all references in the list.
@@ -101,11 +112,16 @@ class ReferenceList(UserList):
101
112
 
102
113
  def get_reference_field_names_by_source_class_name(self) -> Dict[str, List[str]]:
103
114
  r"""
104
- Returns a dictionary that maps source class names to a list of the
105
- names of the fields of that class that contain references.
115
+ Returns a dictionary that maps source class names to a list of the names of
116
+ the fields of that class that contain references.
106
117
 
107
118
  Example: {"Study": ["part_of"]}
108
119
  """
120
+
121
+ # If this dictionary has already been computed by this class, return that cached dictionary.
122
+ if isinstance(self._reference_field_names_by_source_class_name, dict):
123
+ return self._reference_field_names_by_source_class_name
124
+
109
125
  reference_field_names_by_source_class_name: Dict[str, List[str]] = {}
110
126
  for reference in self.data:
111
127
  source_class_name = reference.source_class_name
@@ -119,8 +135,40 @@ class ReferenceList(UserList):
119
135
  if source_field_name not in reference_field_names_by_source_class_name[source_class_name]:
120
136
  reference_field_names_by_source_class_name[source_class_name].append(source_field_name)
121
137
 
138
+ # Cache the dictionary for subsequent invocations of this method.
139
+ self._reference_field_names_by_source_class_name = reference_field_names_by_source_class_name
140
+
122
141
  return reference_field_names_by_source_class_name
123
142
 
143
+ def get_by_target_class_name(self, target_class_name: str) -> "ReferenceList":
144
+ r"""
145
+ Returns a new `ReferenceList` that contains only the references whose target class
146
+ has the specified name.
147
+
148
+ Maintainer note: The return type is written as a string to avoid the error:
149
+ > NameError: name 'ReferenceList' is not defined
150
+ """
151
+
152
+ filtered_references = ReferenceList()
153
+ for reference in self.data:
154
+ if reference.target_class_name == target_class_name:
155
+ filtered_references.append(reference)
156
+ return filtered_references
157
+
158
+ def group_by_source_collection_name(self) -> Dict[str, "ReferenceList"]:
159
+ r"""
160
+ Returns a dictionary that maps source collection names to `ReferenceList` instances
161
+ containing the references that have that source collection name.
162
+ """
163
+
164
+ grouped_references: Dict[str, ReferenceList] = {}
165
+ for reference in self.data:
166
+ if reference.source_collection_name not in grouped_references:
167
+ grouped_references[reference.source_collection_name] = ReferenceList()
168
+ grouped_references[reference.source_collection_name].append(reference)
169
+
170
+ return grouped_references
171
+
124
172
  def as_table(self) -> Table:
125
173
  r"""
126
174
  Returns the references as a `rich.Table` instance.
@@ -1,4 +1,4 @@
1
- from typing import Optional, List
1
+ from typing import Optional, List, Dict
2
2
  from functools import cache
3
3
 
4
4
  from pymongo import MongoClient, timeout
@@ -18,6 +18,8 @@ def connect_to_database(mongo_uri: str, database_name: str, verbose: bool = True
18
18
  mongo_client: MongoClient = MongoClient(host=mongo_uri, directConnection=True)
19
19
 
20
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.")
21
23
  (host, port_number) = mongo_client.address
22
24
 
23
25
  if verbose:
@@ -30,6 +32,7 @@ def connect_to_database(mongo_uri: str, database_name: str, verbose: bool = True
30
32
  return mongo_client
31
33
 
32
34
 
35
+ @cache # memoizes the decorated function
33
36
  def get_collection_names_from_schema(schema_view: SchemaView) -> list[str]:
34
37
  """
35
38
  Returns the names of the slots of the `Database` class that describe database collections.
@@ -53,6 +56,7 @@ def get_collection_names_from_schema(schema_view: SchemaView) -> list[str]:
53
56
  return collection_names
54
57
 
55
58
 
59
+ @cache # memoizes the decorated function
56
60
  def get_names_of_classes_eligible_for_collection(schema_view: SchemaView, collection_name: str) -> list[str]:
57
61
  r"""
58
62
  Returns a list of the names of the classes whose instances can be stored in the specified collection,
@@ -65,6 +69,30 @@ def get_names_of_classes_eligible_for_collection(schema_view: SchemaView, collec
65
69
  return names_of_eligible_classes
66
70
 
67
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
+
68
96
  @cache # memoizes the decorated function
69
97
  def translate_class_uri_into_schema_class_name(schema_view: SchemaView, class_uri: str) -> Optional[str]:
70
98
  r"""
@@ -85,6 +113,22 @@ def translate_class_uri_into_schema_class_name(schema_view: SchemaView, class_ur
85
113
  return schema_class_name
86
114
 
87
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
+
88
132
  def derive_schema_class_name_from_document(schema_view: SchemaView, document: dict) -> Optional[str]:
89
133
  r"""
90
134
  Returns the name of the schema class, if any, of which the specified document claims to represent an instance.
@@ -1,4 +1,4 @@
1
- from typing import List, Optional, Dict
1
+ from typing import List, Optional, Dict, Set, Tuple
2
2
 
3
3
  from pymongo.database import Database
4
4
  from pymongo.client_session import ClientSession
@@ -8,6 +8,9 @@ from linkml_runtime import SchemaView
8
8
  from refscan.lib.Finder import Finder
9
9
  from refscan.lib.helpers import (
10
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,
11
14
  init_progress_bar,
12
15
  )
13
16
  from refscan.lib.ReferenceList import ReferenceList
@@ -16,16 +19,102 @@ from refscan.lib.ViolationList import ViolationList
16
19
  from refscan.lib.constants import console as default_console
17
20
 
18
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
+ # Get the specified document's `id` and derive its schema class name.
56
+ document_id = document["id"]
57
+ document_class_name = derive_schema_class_name_from_document(schema_view, document)
58
+ if document_class_name is None:
59
+ raise ValueError(f"Failed to identify schema class of document having `id`: {document_id}")
60
+
61
+ # Identify potential references to the specified document, then group them by their source collection names.
62
+ potential_references_to_document = references.get_by_target_class_name(document_class_name)
63
+ potential_references_by_source_collection = potential_references_to_document.group_by_source_collection_name()
64
+
65
+ # For each source collection name, check each of its potential references.
66
+ for collection_name, potential_references_in_collection in potential_references_by_source_collection.items():
67
+ # Make a list of all the distinct `class_uri`-and-`field_name` tuples.
68
+ #
69
+ # Note: Since a `Reference` doesn't have a `source_class_uri` attribute (but it does have a
70
+ # `source_class_name` attribute), we derive its `class_uri` from that `source_class_name` attribute.
71
+ #
72
+ class_uri_and_field_name_tuples: Set[Tuple[str, str]] = set()
73
+ for potential_reference in potential_references_in_collection:
74
+ class_uri = translate_schema_class_name_into_class_uri(
75
+ schema_view=schema_view, schema_class_name=potential_reference.source_class_name
76
+ )
77
+ if class_uri is None:
78
+ raise ValueError(
79
+ f"Failed to translate schema class name '{potential_reference.source_class_name}' into class_uri."
80
+ )
81
+ class_uri_and_field_name_tuple = (class_uri, potential_reference.source_field_name)
82
+ class_uri_and_field_name_tuples.add(class_uri_and_field_name_tuple)
83
+
84
+ # For each distinct combination of `class_uri` and `field_name`, check whether any documents in this
85
+ # collection both (a) have a `type` value matching that `class_uri` and (b) have the subject document's
86
+ # `id` value in the specified field.
87
+ referring_documents = finder.find_documents_having_type_and_value_in_field(
88
+ collection_name=collection_name,
89
+ type_and_field_name_tuples=list(class_uri_and_field_name_tuples),
90
+ value=document_id,
91
+ client_session=client_session,
92
+ )
93
+
94
+ # For each referring document, store a descriptor of it.
95
+ for referring_document in referring_documents:
96
+ source_class_name = translate_class_uri_into_schema_class_name(
97
+ schema_view=schema_view,
98
+ class_uri=referring_document["type"],
99
+ )
100
+ document_descriptor = dict(
101
+ source_collection_name=collection_name,
102
+ source_class_name=source_class_name,
103
+ source_document_object_id=referring_document["_id"],
104
+ source_document_id=referring_document["id"],
105
+ )
106
+ referring_document_descriptors.append(document_descriptor)
107
+
108
+ return referring_document_descriptors
109
+
110
+
19
111
  def scan_outgoing_references(
20
112
  document: dict,
21
113
  schema_view: SchemaView,
22
- # TODO: Cache this dictionary, since it is derived from the schema alone.
23
- reference_field_names_by_source_class_name: Dict[str, List[str]],
24
114
  references: ReferenceList,
25
115
  finder: Finder,
26
- collection_names: List[str],
27
116
  source_collection_name: str,
28
- client_session: ClientSession = None,
117
+ client_session: Optional[ClientSession] = None,
29
118
  user_wants_to_locate_misplaced_documents: bool = False,
30
119
  ) -> ViolationList:
31
120
  r"""
@@ -34,19 +123,17 @@ def scan_outgoing_references(
34
123
 
35
124
  :param document: The source document from which the references emanate
36
125
  :param schema_view: A SchemaView bound to the schema
37
- :param reference_field_names_by_source_class_name: Dictionary mapping class names to lists of reference field names
38
126
  :param references: A `ReferenceList` derived from the schema
39
127
  :param finder: A `Finder` bound to the database being scanned
40
- :param user_wants_to_locate_misplaced_documents: Whether the user wants the function to proceed to search illegal
41
- collections after failing to find the referenced document among
42
- all the legal collections
43
128
  :param source_collection_name: Name of collection in which source document resides (this is included in the
44
129
  violation report in an attempt to facilitate investigation of violations)
45
130
  :param client_session: A `pymongo.client_session.ClientSession` instance that, if specified, will be used when
46
131
  searching for referenced documents. If a transaction happens to be pending on that session,
47
132
  the scan will effectively happen on the database as it _would_ exist if that transaction
48
133
  were to be committed.
49
- :param collection_names: List of collection names that are described by the schema
134
+ :param user_wants_to_locate_misplaced_documents: Whether the user wants the function to proceed to search illegal
135
+ collections after failing to find the referenced document among
136
+ all the legal collections
50
137
  """
51
138
 
52
139
  # Initialize a list of violations.
@@ -54,14 +141,20 @@ def scan_outgoing_references(
54
141
 
55
142
  # Get the document's `id` so that we can include it in this script's output.
56
143
  source_document_object_id = document["_id"]
57
- source_document_id = document["id"] if "id" in document else None
144
+ source_document_id: Optional[str] = document["id"] if "id" in document else None
58
145
 
59
146
  # Get the document's schema class name so that we can interpret its fields accordingly.
60
147
  source_class_name = derive_schema_class_name_from_document(schema_view, document)
148
+ if source_class_name is None:
149
+ raise ValueError(f"Failed to derive schema class name from document having `id`: {source_document_id}")
61
150
 
62
151
  # Get the names of that class's fields that can contain references.
152
+ reference_field_names_by_source_class_name = references.get_reference_field_names_by_source_class_name()
63
153
  names_of_reference_fields = reference_field_names_by_source_class_name.get(source_class_name, [])
64
154
 
155
+ # Get the names of collections that are described by the schema.
156
+ collection_names = get_collection_names_from_schema(schema_view=schema_view)
157
+
65
158
  # Check each field that both (a) exists in the document and (b) can contain a reference.
66
159
  for field_name in names_of_reference_fields:
67
160
  if field_name in document:
@@ -100,6 +193,15 @@ def scan_outgoing_references(
100
193
  )
101
194
  )
102
195
 
196
+ # Handle the case where the source document does not have an `id`.
197
+ #
198
+ # Note: As a reminder, according to the NMDC Schema (as of version v11.7.0), documents in the
199
+ # `functional_annotation_agg` collection do not have an `id` field.
200
+ # Reference: https://microbiomedata.github.io/nmdc-schema/FunctionalAnnotationAggMember/
201
+ #
202
+ if not isinstance(source_document_id, str):
203
+ source_document_id = ""
204
+
103
205
  # Instantiate a `Violation` containing information about this referential integrity violation.
104
206
  violation = Violation(
105
207
  source_collection_name=source_collection_name,
@@ -121,7 +223,6 @@ def scan(
121
223
  db: Database,
122
224
  schema_view: SchemaView,
123
225
  references: ReferenceList,
124
- collection_names: List[str],
125
226
  names_of_source_collections_to_skip: List[str],
126
227
  user_wants_to_locate_misplaced_documents: bool = False,
127
228
  console: Console = default_console,
@@ -135,7 +236,6 @@ def scan(
135
236
  :param schema_view: A SchemaView bound to the schema with which that database complies
136
237
  (except that it may not be compliant in terms of referential integrity)
137
238
  :param references: A `ReferenceList` derived from the schema
138
- :param collection_names: List of collection names that are described by the schema
139
239
  :param names_of_source_collections_to_skip: List of source collections to skip
140
240
  :param user_wants_to_locate_misplaced_documents: Whether the user wants the function to proceed to search illegal
141
241
  collections after failing to find the referenced document among
@@ -144,9 +244,6 @@ def scan(
144
244
  :param verbose: Whether you want the function to print a higher-than-normal amount of information to the console
145
245
  """
146
246
 
147
- # Get a dictionary that maps source class names to the names of their fields that can contain references.
148
- reference_field_names_by_source_class_name = references.get_reference_field_names_by_source_class_name()
149
-
150
247
  # Initialize a progress bar.
151
248
  custom_progress = init_progress_bar()
152
249
 
@@ -226,10 +323,8 @@ def scan(
226
323
  violations = scan_outgoing_references(
227
324
  document=document,
228
325
  schema_view=schema_view,
229
- reference_field_names_by_source_class_name=reference_field_names_by_source_class_name,
230
326
  references=references,
231
327
  finder=finder,
232
- collection_names=collection_names,
233
328
  source_collection_name=source_collection_name,
234
329
  user_wants_to_locate_misplaced_documents=user_wants_to_locate_misplaced_documents,
235
330
  )
File without changes
File without changes
File without changes
File without changes