refscan 0.1.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.
- refscan-0.1.0/PKG-INFO +68 -0
- refscan-0.1.0/README.md +48 -0
- refscan-0.1.0/pyproject.toml +42 -0
- refscan-0.1.0/refscan/__init__.py +0 -0
- refscan-0.1.0/refscan/lib/Finder.py +54 -0
- refscan-0.1.0/refscan/lib/Reference.py +17 -0
- refscan-0.1.0/refscan/lib/ReferenceList.py +146 -0
- refscan-0.1.0/refscan/lib/Violation.py +13 -0
- refscan-0.1.0/refscan/lib/ViolationList.py +23 -0
- refscan-0.1.0/refscan/lib/constants.py +8 -0
- refscan-0.1.0/refscan/lib/helpers.py +123 -0
- refscan-0.1.0/refscan/refscan.py +308 -0
refscan-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: refscan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line program that scans the NMDC MongoDB database for referential integrity violations
|
|
5
|
+
Home-page: https://github.com/microbiomedata/refscan
|
|
6
|
+
Keywords: mongodb,mongo,relationships,reference,database,data,referential integrity,scan
|
|
7
|
+
Requires-Python: >=3.10,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Requires-Dist: linkml-runtime (>=1.7.5,<2.0.0)
|
|
13
|
+
Requires-Dist: pymongo (>=4.7.1,<5.0.0)
|
|
14
|
+
Requires-Dist: rich (>=13.7.1,<14.0.0)
|
|
15
|
+
Requires-Dist: typer (>=0.12.3,<0.13.0)
|
|
16
|
+
Project-URL: Documentation, https://github.com/microbiomedata/refscan
|
|
17
|
+
Project-URL: Repository, https://github.com/microbiomedata/refscan
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# refscan
|
|
21
|
+
|
|
22
|
+
`refscan` is a command-line tool people can use to scan the NMDC MongoDB database
|
|
23
|
+
for referential integrity violations.
|
|
24
|
+
|
|
25
|
+
```mermaid
|
|
26
|
+
%% This is the source code of a Mermaid diagram, which GitHub will render as a diagram.
|
|
27
|
+
%% Note: PyPI does not render Mermaid diagrams, and instead displays their source code.
|
|
28
|
+
%% Reference: https://github.com/pypi/warehouse/issues/13083
|
|
29
|
+
graph LR
|
|
30
|
+
schema[LinkML<br>schema]
|
|
31
|
+
database[(MongoDB<br>database)]
|
|
32
|
+
script[["refscan.py"]]
|
|
33
|
+
violations["List of<br>violations"]
|
|
34
|
+
references["List of<br>references"]:::dashed_border
|
|
35
|
+
schema --> script
|
|
36
|
+
database --> script
|
|
37
|
+
script -.-> references
|
|
38
|
+
script --> violations
|
|
39
|
+
|
|
40
|
+
classDef dashed_border stroke-dasharray: 5 5
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Assumptions
|
|
44
|
+
|
|
45
|
+
`refscan` was designed under some assumptions about the schema and database, including:
|
|
46
|
+
|
|
47
|
+
1. Each source document (i.e. document containing references) has a field named `type`, whose value (a string) is the [class_uri](https://linkml.io/linkml/code/metamodel.html#linkml_runtime.linkml_model.meta.ClassDefinition.class_uri) of the schema class of which the document represents an instance. For example, the `type` field of each document in the `study_set` collection has the value `"nmdc:Study"`.
|
|
48
|
+
|
|
49
|
+
## Development status
|
|
50
|
+
|
|
51
|
+
`refscan` is in early development and its author does not recommend anyone use it for anything without reviewing its code first.
|
|
52
|
+
|
|
53
|
+
## Tips
|
|
54
|
+
|
|
55
|
+
`refscan` requires the user to specify the path to a schema in YAML format. If you have `curl` installed, you can download a YAML file from GitHub by running the following command (after replacing the `{...}` placeholders and customizing the path):
|
|
56
|
+
|
|
57
|
+
```shell
|
|
58
|
+
# Download the raw content of https://github.com/{user_or_org}/{repo}/blob/{branch}/path/to/schema.yaml
|
|
59
|
+
curl -o schema.yaml https://raw.githubusercontent.com/{user_or_org}/{repo}/{branch}/path/to/schema.yaml
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For example:
|
|
63
|
+
|
|
64
|
+
```shell
|
|
65
|
+
# Download the raw content of https://github.com/microbiomedata/berkeley-schema-fy24/blob/main/nmdc_schema/nmdc_materialized_patterns.yaml
|
|
66
|
+
curl -o schema.yaml https://raw.githubusercontent.com/microbiomedata/berkeley-schema-fy24/main/nmdc_schema/nmdc_materialized_patterns.yaml
|
|
67
|
+
```
|
|
68
|
+
|
refscan-0.1.0/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# refscan
|
|
2
|
+
|
|
3
|
+
`refscan` is a command-line tool people can use to scan the NMDC MongoDB database
|
|
4
|
+
for referential integrity violations.
|
|
5
|
+
|
|
6
|
+
```mermaid
|
|
7
|
+
%% This is the source code of a Mermaid diagram, which GitHub will render as a diagram.
|
|
8
|
+
%% Note: PyPI does not render Mermaid diagrams, and instead displays their source code.
|
|
9
|
+
%% Reference: https://github.com/pypi/warehouse/issues/13083
|
|
10
|
+
graph LR
|
|
11
|
+
schema[LinkML<br>schema]
|
|
12
|
+
database[(MongoDB<br>database)]
|
|
13
|
+
script[["refscan.py"]]
|
|
14
|
+
violations["List of<br>violations"]
|
|
15
|
+
references["List of<br>references"]:::dashed_border
|
|
16
|
+
schema --> script
|
|
17
|
+
database --> script
|
|
18
|
+
script -.-> references
|
|
19
|
+
script --> violations
|
|
20
|
+
|
|
21
|
+
classDef dashed_border stroke-dasharray: 5 5
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Assumptions
|
|
25
|
+
|
|
26
|
+
`refscan` was designed under some assumptions about the schema and database, including:
|
|
27
|
+
|
|
28
|
+
1. Each source document (i.e. document containing references) has a field named `type`, whose value (a string) is the [class_uri](https://linkml.io/linkml/code/metamodel.html#linkml_runtime.linkml_model.meta.ClassDefinition.class_uri) of the schema class of which the document represents an instance. For example, the `type` field of each document in the `study_set` collection has the value `"nmdc:Study"`.
|
|
29
|
+
|
|
30
|
+
## Development status
|
|
31
|
+
|
|
32
|
+
`refscan` is in early development and its author does not recommend anyone use it for anything without reviewing its code first.
|
|
33
|
+
|
|
34
|
+
## Tips
|
|
35
|
+
|
|
36
|
+
`refscan` requires the user to specify the path to a schema in YAML format. If you have `curl` installed, you can download a YAML file from GitHub by running the following command (after replacing the `{...}` placeholders and customizing the path):
|
|
37
|
+
|
|
38
|
+
```shell
|
|
39
|
+
# Download the raw content of https://github.com/{user_or_org}/{repo}/blob/{branch}/path/to/schema.yaml
|
|
40
|
+
curl -o schema.yaml https://raw.githubusercontent.com/{user_or_org}/{repo}/{branch}/path/to/schema.yaml
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For example:
|
|
44
|
+
|
|
45
|
+
```shell
|
|
46
|
+
# Download the raw content of https://github.com/microbiomedata/berkeley-schema-fy24/blob/main/nmdc_schema/nmdc_materialized_patterns.yaml
|
|
47
|
+
curl -o schema.yaml https://raw.githubusercontent.com/microbiomedata/berkeley-schema-fy24/main/nmdc_schema/nmdc_materialized_patterns.yaml
|
|
48
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Reference: https://python-poetry.org/docs/pyproject/
|
|
2
|
+
|
|
3
|
+
[tool.poetry]
|
|
4
|
+
name = "refscan"
|
|
5
|
+
version = "v0.1.0"
|
|
6
|
+
description = "Command-line program that scans the NMDC MongoDB database for referential integrity violations"
|
|
7
|
+
authors = []
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
repository = "https://github.com/microbiomedata/refscan"
|
|
10
|
+
homepage = "https://github.com/microbiomedata/refscan"
|
|
11
|
+
documentation = "https://github.com/microbiomedata/refscan"
|
|
12
|
+
keywords = ["mongodb", "mongo", "relationships", "reference", "database", "data", "referential integrity", "scan"]
|
|
13
|
+
|
|
14
|
+
[tool.poetry.dependencies]
|
|
15
|
+
python = "^3.10"
|
|
16
|
+
# We use `linkml-runtime` to facilitate using `nmdc-schema`.
|
|
17
|
+
# Docs: https://linkml.io/linkml/developers/schemaview.html#linkml_runtime.utils.schemaview.SchemaView
|
|
18
|
+
linkml-runtime = "^1.7.5"
|
|
19
|
+
# We use `pymongo` to communicate with MongoDB servers.
|
|
20
|
+
# Docs: (https://pymongo.readthedocs.io/en/stable/
|
|
21
|
+
pymongo = "^4.7.1"
|
|
22
|
+
# We use `rich` to format console output.
|
|
23
|
+
# Docs: https://rich.readthedocs.io/en/stable/index.html
|
|
24
|
+
rich = "^13.7.1"
|
|
25
|
+
# We use `typer` as a CLI framework.
|
|
26
|
+
# Docs: https://typer.tiangolo.com/
|
|
27
|
+
typer = "^0.12.3"
|
|
28
|
+
|
|
29
|
+
[tool.poetry.group.dev.dependencies]
|
|
30
|
+
black = "^24.4.2"
|
|
31
|
+
# We use `pyinstrument` for performance profiling.
|
|
32
|
+
# Usage: $ pyinstrument --from-path refscan --help
|
|
33
|
+
# Docs: https://pyinstrument.readthedocs.io/en/latest/guide.html#profile-a-python-cli-command
|
|
34
|
+
pyinstrument = "^4.6.2"
|
|
35
|
+
|
|
36
|
+
[tool.poetry.scripts]
|
|
37
|
+
# Reference: https://python-poetry.org/docs/pyproject#scripts
|
|
38
|
+
refscan = "refscan.refscan:app"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["poetry-core"]
|
|
42
|
+
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from pymongo.database import Database
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Finder:
|
|
7
|
+
r"""
|
|
8
|
+
A class that can be used to find a document in a MongoDB database,
|
|
9
|
+
in a way that uses past searches to try to speed up future searches.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, database: Database):
|
|
13
|
+
self.db = database
|
|
14
|
+
|
|
15
|
+
# Initialize a variable we can use to keep track of the collection in which we most recently found a document.
|
|
16
|
+
#
|
|
17
|
+
# Note: This will enable us to _start_ searching in that same collection (if it's among the eligible ones)
|
|
18
|
+
# the next time we are trying to find any document.
|
|
19
|
+
#
|
|
20
|
+
self.name_of_most_recently_found_in_collection = None
|
|
21
|
+
|
|
22
|
+
def check_whether_document_having_id_exists_among_collections(
|
|
23
|
+
self,
|
|
24
|
+
document_id: str,
|
|
25
|
+
collection_names: List[str]
|
|
26
|
+
) -> bool:
|
|
27
|
+
r"""
|
|
28
|
+
Checks whether any documents in any of the specified collections
|
|
29
|
+
have the specified value in their `id` field. Returns `True` if
|
|
30
|
+
any does; else returns `False`.
|
|
31
|
+
|
|
32
|
+
References:
|
|
33
|
+
- https://pymongo.readthedocs.io/en/stable/api/pymongo/collection.html#pymongo.collection.Collection.find_one
|
|
34
|
+
"""
|
|
35
|
+
names_of_collections_to_search = collection_names.copy()
|
|
36
|
+
|
|
37
|
+
# If our cached collection name is among the ones eligible to search, move it to the front of the list.
|
|
38
|
+
cached_collection_name = self.name_of_most_recently_found_in_collection # makes a more concise alias
|
|
39
|
+
if cached_collection_name in collection_names:
|
|
40
|
+
names_of_collections_to_search.remove(cached_collection_name)
|
|
41
|
+
names_of_collections_to_search.insert(0, cached_collection_name) # makes it the first item
|
|
42
|
+
|
|
43
|
+
# Search the collections in their current order.
|
|
44
|
+
is_found = False
|
|
45
|
+
query_filter = dict(id=document_id)
|
|
46
|
+
for collection_name in names_of_collections_to_search:
|
|
47
|
+
is_found = self.db.get_collection(collection_name).find_one(query_filter, projection=["_id"]) is not None
|
|
48
|
+
|
|
49
|
+
# If we found the document, cache the collection name and stop searching.
|
|
50
|
+
if is_found:
|
|
51
|
+
self.name_of_most_recently_found_in_collection = collection_name
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
return is_found
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True, order=True)
|
|
5
|
+
class Reference:
|
|
6
|
+
"""
|
|
7
|
+
A generic reference to a document in a collection.
|
|
8
|
+
|
|
9
|
+
Note: `frozen` means the instances are immutable.
|
|
10
|
+
Note: `order` means the instances have methods that help with sorting. For example, an `__eq__` method that
|
|
11
|
+
can be used to compare instances of the class as thought they were tuples of those instances' fields.
|
|
12
|
+
"""
|
|
13
|
+
source_collection_name: str = field() # e.g. "study_set"
|
|
14
|
+
source_class_name: str = field() # e.g. "Study"
|
|
15
|
+
source_field_name: str = field() # e.g. "part_of"
|
|
16
|
+
target_collection_name: str = field() # e.g. "study_set" (reminder: a study can be part of another study)
|
|
17
|
+
target_class_name: str = field() # e.g. "Study"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from typing import List, Dict
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from dataclasses import fields, astuple
|
|
4
|
+
from collections import UserList
|
|
5
|
+
from itertools import groupby
|
|
6
|
+
import csv
|
|
7
|
+
|
|
8
|
+
from rich.table import Table, Column
|
|
9
|
+
|
|
10
|
+
from refscan.lib.Reference import Reference
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReferenceList(UserList):
|
|
14
|
+
"""
|
|
15
|
+
A list of references.
|
|
16
|
+
|
|
17
|
+
Note: `UserList` is a base class that facilitates the implementation of custom list classes.
|
|
18
|
+
One thing it does is enable sorting via `sorted(the_list)`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def get_source_collection_names(self) -> list[str]:
|
|
22
|
+
"""
|
|
23
|
+
Returns the distinct `source_collection_names` values among all references in the list.
|
|
24
|
+
"""
|
|
25
|
+
distinct_source_collection_names = []
|
|
26
|
+
for reference in self.data:
|
|
27
|
+
if reference.source_collection_name not in distinct_source_collection_names:
|
|
28
|
+
distinct_source_collection_names.append(reference.source_collection_name)
|
|
29
|
+
return distinct_source_collection_names
|
|
30
|
+
|
|
31
|
+
def get_source_field_names_of_source_collection(self, collection_name: str) -> list[str]:
|
|
32
|
+
"""
|
|
33
|
+
Returns the distinct source field names of the specified source collection.
|
|
34
|
+
"""
|
|
35
|
+
distinct_source_field_names = []
|
|
36
|
+
for reference in self.data:
|
|
37
|
+
if reference.source_collection_name == collection_name:
|
|
38
|
+
if reference.source_field_name not in distinct_source_field_names:
|
|
39
|
+
distinct_source_field_names.append(reference.source_field_name)
|
|
40
|
+
return distinct_source_field_names
|
|
41
|
+
|
|
42
|
+
def get_target_collection_names(
|
|
43
|
+
self,
|
|
44
|
+
source_class_name: str,
|
|
45
|
+
source_field_name: str,
|
|
46
|
+
) -> list[str]:
|
|
47
|
+
"""
|
|
48
|
+
Returns a list of the names of the collections in which a [target] document referenced by the specified field
|
|
49
|
+
of a [source] document representing an instance of the specified schema class, might exist.
|
|
50
|
+
|
|
51
|
+
TODO: Execution spends a lot of time in this function. Consider replacing it with something faster (e.g. a LUT).
|
|
52
|
+
"""
|
|
53
|
+
target_collection_names = []
|
|
54
|
+
for reference in self.data: # note: in a `UserList`, `self.data` refers to the underlying list data structure
|
|
55
|
+
|
|
56
|
+
# If this reference's source describes the specified source, record the reference's target collection name.
|
|
57
|
+
if reference.source_class_name == source_class_name and reference.source_field_name == source_field_name:
|
|
58
|
+
target_collection_names.append(reference.target_collection_name)
|
|
59
|
+
|
|
60
|
+
distinct_target_collection_names = list(set(target_collection_names))
|
|
61
|
+
return distinct_target_collection_names
|
|
62
|
+
|
|
63
|
+
def get_groups(self, field_names: list[str]) -> list[tuple[str, str, str, str, list[str]]]:
|
|
64
|
+
r"""
|
|
65
|
+
Returns an iterable of groups, where each group has a distinct combination of values in the specified fields.
|
|
66
|
+
|
|
67
|
+
Note: This method can be used to "consolidate" references that have the same source collection name,
|
|
68
|
+
source field name, and target collection name (i.e. ones that only differ by target class name).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def make_group_key(reference: Reference) -> tuple:
|
|
72
|
+
"""Helper function that returns a key that can be used to group references."""
|
|
73
|
+
values = []
|
|
74
|
+
for field_name in field_names:
|
|
75
|
+
if not hasattr(reference, field_name):
|
|
76
|
+
raise ValueError(f"No such field: {field_name}")
|
|
77
|
+
values.append(getattr(reference, field_name))
|
|
78
|
+
return tuple(values)
|
|
79
|
+
|
|
80
|
+
groups = groupby(sorted(self.data), key=make_group_key)
|
|
81
|
+
return groups
|
|
82
|
+
|
|
83
|
+
def dump_to_tsv_file(self, file_path: str | Path) -> None:
|
|
84
|
+
r"""
|
|
85
|
+
Helper function that dumps the references to a TSV file at the specified path.
|
|
86
|
+
"""
|
|
87
|
+
column_names = [field_.name for field_ in fields(Reference)]
|
|
88
|
+
with open(file_path, "w", newline="") as tsv_file:
|
|
89
|
+
writer = csv.writer(tsv_file, delimiter="\t")
|
|
90
|
+
writer.writerow(column_names) # header row
|
|
91
|
+
for reference in self.data:
|
|
92
|
+
writer.writerow(astuple(reference)) # data row
|
|
93
|
+
|
|
94
|
+
def get_reference_field_names_by_source_class_name(self) -> Dict[str, List[str]]:
|
|
95
|
+
r"""
|
|
96
|
+
Returns a dictionary that maps source class names to a list of the
|
|
97
|
+
names of the fields of that class that contain references.
|
|
98
|
+
|
|
99
|
+
Example: {"Study": ["part_of"]}
|
|
100
|
+
"""
|
|
101
|
+
reference_field_names_by_source_class_name: Dict[str, List[str]] = {}
|
|
102
|
+
for reference in self.data:
|
|
103
|
+
source_class_name = reference.source_class_name
|
|
104
|
+
source_field_name = reference.source_field_name
|
|
105
|
+
|
|
106
|
+
# Initialize a dictionary item for this source class, if one doesn't already exist.
|
|
107
|
+
if source_class_name not in reference_field_names_by_source_class_name.keys():
|
|
108
|
+
reference_field_names_by_source_class_name[source_class_name] = []
|
|
109
|
+
|
|
110
|
+
# If the source field name isn't already in this class's list, append it.
|
|
111
|
+
if source_field_name not in reference_field_names_by_source_class_name[source_class_name]:
|
|
112
|
+
reference_field_names_by_source_class_name[source_class_name].append(source_field_name)
|
|
113
|
+
|
|
114
|
+
return reference_field_names_by_source_class_name
|
|
115
|
+
|
|
116
|
+
def as_table(self) -> Table:
|
|
117
|
+
r"""
|
|
118
|
+
Returns the references as a `rich.Table` instance.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
# Make the data rows for the table. Data rows that would have had the same
|
|
122
|
+
# (combination of) the following fields, get consolidated into a single row;
|
|
123
|
+
# and all the target class names are displayed as a list on that row.
|
|
124
|
+
fields_to_group_rows_by = ["source_collection_name",
|
|
125
|
+
"source_class_name",
|
|
126
|
+
"source_field_name",
|
|
127
|
+
"target_collection_name"]
|
|
128
|
+
groups = self.get_groups(fields_to_group_rows_by)
|
|
129
|
+
data_rows: list[tuple[str, str, str, str, str]] = []
|
|
130
|
+
for key, group in groups:
|
|
131
|
+
target_class_names = list(set([ref.target_class_name for ref in group])) # omit duplicate class names
|
|
132
|
+
row = (key[0], key[1], key[2], key[3], ", ".join(target_class_names))
|
|
133
|
+
data_rows.append(row)
|
|
134
|
+
|
|
135
|
+
# Initialize the table, then add the data rows to it.
|
|
136
|
+
table = Table(Column(header="Source collection", footer=f"{len(data_rows)} rows"),
|
|
137
|
+
Column(header="Source class"),
|
|
138
|
+
Column(header="Source field"),
|
|
139
|
+
Column(header="Target collection"),
|
|
140
|
+
Column(header="Target class(es)"),
|
|
141
|
+
title="References",
|
|
142
|
+
show_footer=True)
|
|
143
|
+
for row in data_rows:
|
|
144
|
+
table.add_row(*row)
|
|
145
|
+
|
|
146
|
+
return table
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True, order=True)
|
|
5
|
+
class Violation:
|
|
6
|
+
"""
|
|
7
|
+
A specific reference that lacks integrity.
|
|
8
|
+
"""
|
|
9
|
+
source_collection_name: str = field()
|
|
10
|
+
source_field_name: str = field()
|
|
11
|
+
source_document_object_id: str = field()
|
|
12
|
+
source_document_id: str = field()
|
|
13
|
+
target_id: str = field()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from dataclasses import fields, astuple
|
|
3
|
+
from collections import UserList
|
|
4
|
+
import csv
|
|
5
|
+
|
|
6
|
+
from refscan.lib.Violation import Violation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ViolationList(UserList):
|
|
10
|
+
"""
|
|
11
|
+
A list of violations.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def dump_to_tsv_file(self, file_path: str | Path) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Helper function that dumps the violations to a TSV file at the specified path.
|
|
17
|
+
"""
|
|
18
|
+
column_names = [field_.name for field_ in fields(Violation)]
|
|
19
|
+
with open(file_path, "w", newline="") as tsv_file:
|
|
20
|
+
writer = csv.writer(tsv_file, delimiter="\t")
|
|
21
|
+
writer.writerow(column_names) # header row
|
|
22
|
+
for violation in self.data:
|
|
23
|
+
writer.writerow(astuple(violation)) # data row
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
|
|
3
|
+
# Instantiate a Rich console for fancy console output.
|
|
4
|
+
# Reference: https://rich.readthedocs.io/en/stable/console.html
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
# Note: This is the only schema class name hard-coded into this script.
|
|
8
|
+
DATABASE_CLASS_NAME = "Database"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from functools import cache
|
|
3
|
+
|
|
4
|
+
from pymongo import MongoClient, timeout
|
|
5
|
+
from linkml_runtime import SchemaView
|
|
6
|
+
from rich.progress import (
|
|
7
|
+
Progress,
|
|
8
|
+
TextColumn,
|
|
9
|
+
MofNCompleteColumn,
|
|
10
|
+
BarColumn,
|
|
11
|
+
TimeElapsedColumn,
|
|
12
|
+
TimeRemainingColumn
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from refscan.lib.constants import DATABASE_CLASS_NAME, console
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def connect_to_database(mongo_uri: str, database_name: str, verbose: bool = True) -> MongoClient:
|
|
19
|
+
"""
|
|
20
|
+
Returns a Mongo client. Raises an exception if the database is not accessible.
|
|
21
|
+
"""
|
|
22
|
+
mongo_client: MongoClient = MongoClient(host=mongo_uri, directConnection=True)
|
|
23
|
+
|
|
24
|
+
with (timeout(5)): # if any message exchange takes > 5 seconds, this will raise an exception
|
|
25
|
+
(host, port_number) = mongo_client.address
|
|
26
|
+
|
|
27
|
+
if verbose:
|
|
28
|
+
console.print(f'Connected to MongoDB server: "{host}:{port_number}"')
|
|
29
|
+
|
|
30
|
+
# Check whether the database exists on the MongoDB server.
|
|
31
|
+
if database_name not in mongo_client.list_database_names():
|
|
32
|
+
raise ValueError(f'Database "{database_name}" not found on the MongoDB server.')
|
|
33
|
+
|
|
34
|
+
return mongo_client
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_collection_names_from_schema(
|
|
38
|
+
schema_view: SchemaView
|
|
39
|
+
) -> list[str]:
|
|
40
|
+
"""
|
|
41
|
+
Returns the names of the slots of the `Database` class that describe database collections.
|
|
42
|
+
|
|
43
|
+
:param schema_view: A `SchemaView` instance
|
|
44
|
+
"""
|
|
45
|
+
collection_names = []
|
|
46
|
+
|
|
47
|
+
for slot_name in schema_view.class_slots(DATABASE_CLASS_NAME):
|
|
48
|
+
slot_definition = schema_view.induced_slot(slot_name, DATABASE_CLASS_NAME)
|
|
49
|
+
|
|
50
|
+
# Filter out any hypothetical (future) slots that don't correspond to a collection (e.g. `db_version`).
|
|
51
|
+
if slot_definition.multivalued and slot_definition.inlined_as_list:
|
|
52
|
+
collection_names.append(slot_name)
|
|
53
|
+
|
|
54
|
+
# Filter out duplicate names. This is to work around the following issues in the schema:
|
|
55
|
+
# - https://github.com/microbiomedata/nmdc-schema/issues/1954
|
|
56
|
+
# - https://github.com/microbiomedata/nmdc-schema/issues/1955
|
|
57
|
+
collection_names = list(set(collection_names))
|
|
58
|
+
|
|
59
|
+
return collection_names
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@cache # memoizes the decorated function
|
|
63
|
+
def translate_class_uri_into_schema_class_name(schema_view: SchemaView, class_uri: str) -> Optional[str]:
|
|
64
|
+
r"""
|
|
65
|
+
Returns the name of the schema class that has the specified value as its `class_uri`.
|
|
66
|
+
|
|
67
|
+
Example `"nmdc:Biosample" (a `class_uri` value) -> "Biosample" (a class name)
|
|
68
|
+
|
|
69
|
+
References:
|
|
70
|
+
- https://linkml.io/linkml/developers/schemaview.html#linkml_runtime.utils.schemaview.SchemaView.all_classes
|
|
71
|
+
- https://linkml.io/linkml/code/metamodel.html#linkml_runtime.linkml_model.meta.ClassDefinition.class_uri
|
|
72
|
+
"""
|
|
73
|
+
schema_class_name = None
|
|
74
|
+
all_class_definitions_in_schema = schema_view.all_classes()
|
|
75
|
+
for class_name, class_definition in all_class_definitions_in_schema.items():
|
|
76
|
+
if class_definition.class_uri == class_uri:
|
|
77
|
+
schema_class_name = class_definition.name
|
|
78
|
+
break
|
|
79
|
+
return schema_class_name
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def derive_schema_class_name_from_document(schema_view: SchemaView, document: dict) -> Optional[str]:
|
|
83
|
+
r"""
|
|
84
|
+
Returns the name of the schema class, if any, of which the specified document claims to represent an instance.
|
|
85
|
+
|
|
86
|
+
This function is written under the assumption that the document has a `type` field whose value is the `class_uri`
|
|
87
|
+
belonging to the schema class of which the document represents an instance. Slot definition for such a field:
|
|
88
|
+
https://github.com/microbiomedata/berkeley-schema-fy24/blob/fc2d9600/src/schema/basic_slots.yaml#L420-L436
|
|
89
|
+
"""
|
|
90
|
+
schema_class_name = None
|
|
91
|
+
if "type" in document and isinstance(document["type"], str):
|
|
92
|
+
class_uri = document["type"]
|
|
93
|
+
schema_class_name = translate_class_uri_into_schema_class_name(schema_view, class_uri)
|
|
94
|
+
return schema_class_name
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def init_progress_bar() -> Progress:
|
|
98
|
+
r"""
|
|
99
|
+
Initialize a progress bar that shows the elapsed time, M-of-N completed count, and more.
|
|
100
|
+
|
|
101
|
+
Reference: https://rich.readthedocs.io/en/stable/progress.html?highlight=progress#columns
|
|
102
|
+
"""
|
|
103
|
+
custom_progress = Progress(
|
|
104
|
+
TextColumn("[progress.description]{task.description}"),
|
|
105
|
+
TextColumn("[red]{task.fields[num_violations]}[/red] violations in"),
|
|
106
|
+
MofNCompleteColumn(),
|
|
107
|
+
TextColumn("source documents"),
|
|
108
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
109
|
+
BarColumn(),
|
|
110
|
+
TimeElapsedColumn(),
|
|
111
|
+
TextColumn("elapsed"),
|
|
112
|
+
TimeRemainingColumn(elapsed_when_finished=True),
|
|
113
|
+
TextColumn("{task.fields[remaining_time_label]}"),
|
|
114
|
+
console=console,
|
|
115
|
+
refresh_per_second=1,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return custom_progress
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_lowercase_key(key_value_pair: tuple) -> str:
|
|
122
|
+
r"""Returns the key from a `(key, value)` tuple, in lowercase."""
|
|
123
|
+
return key_value_pair[0].lower()
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from typing_extensions import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
import linkml_runtime
|
|
7
|
+
|
|
8
|
+
from refscan.lib.Finder import Finder
|
|
9
|
+
from refscan.lib.constants import DATABASE_CLASS_NAME, console
|
|
10
|
+
from refscan.lib.helpers import (
|
|
11
|
+
connect_to_database,
|
|
12
|
+
get_collection_names_from_schema,
|
|
13
|
+
derive_schema_class_name_from_document,
|
|
14
|
+
init_progress_bar,
|
|
15
|
+
get_lowercase_key,
|
|
16
|
+
)
|
|
17
|
+
from refscan.lib.Reference import Reference
|
|
18
|
+
from refscan.lib.ReferenceList import ReferenceList
|
|
19
|
+
from refscan.lib.Violation import Violation
|
|
20
|
+
from refscan.lib.ViolationList import ViolationList
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
help="Scans the NMDC MongoDB database for referential integrity violations.",
|
|
24
|
+
add_completion=False, # hides the shell completion options from `--help` output
|
|
25
|
+
rich_markup_mode="markdown", # enables use of Markdown in docstrings and CLI help
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("scan")
|
|
30
|
+
def scan(
|
|
31
|
+
# Reference: https://typer.tiangolo.com/tutorial/parameter-types/path/
|
|
32
|
+
schema_file_path: Annotated[Path, typer.Option(
|
|
33
|
+
"--schema",
|
|
34
|
+
dir_okay=False,
|
|
35
|
+
writable=False,
|
|
36
|
+
readable=True,
|
|
37
|
+
resolve_path=True,
|
|
38
|
+
help="Filesystem path at which the YAML file representing the schema is located.",
|
|
39
|
+
)],
|
|
40
|
+
database_name: Annotated[str, typer.Option(
|
|
41
|
+
help="Name of the database.",
|
|
42
|
+
)] = "nmdc",
|
|
43
|
+
mongo_uri: Annotated[str, typer.Option(
|
|
44
|
+
envvar="MONGO_URI",
|
|
45
|
+
help="Connection string for accessing the MongoDB server. If you have Docker installed, "
|
|
46
|
+
"you can spin up a temporary MongoDB server at the default URI by running: "
|
|
47
|
+
"`$ docker run --rm --detach -p 27017:27017 mongo`",
|
|
48
|
+
)] = "mongodb://localhost:27017",
|
|
49
|
+
verbose: Annotated[bool, typer.Option(
|
|
50
|
+
help="Show verbose output.",
|
|
51
|
+
)] = False,
|
|
52
|
+
# Reference: https://typer.tiangolo.com/tutorial/multiple-values/multiple-options/
|
|
53
|
+
skip_source_collection: Annotated[Optional[List[str]], typer.Option(
|
|
54
|
+
"--skip-source-collection", "--skip",
|
|
55
|
+
help="Name of collection you do not want to search for referring documents. "
|
|
56
|
+
"Option can be used multiple times.",
|
|
57
|
+
)] = None,
|
|
58
|
+
reference_report_file_path: Annotated[Optional[Path], typer.Option(
|
|
59
|
+
"--reference-report",
|
|
60
|
+
dir_okay=False,
|
|
61
|
+
writable=True,
|
|
62
|
+
readable=False,
|
|
63
|
+
resolve_path=True,
|
|
64
|
+
help="Filesystem path at which you want the program to generate its reference report.",
|
|
65
|
+
)] = "references.tsv",
|
|
66
|
+
violation_report_file_path: Annotated[Optional[Path], typer.Option(
|
|
67
|
+
"--violation-report",
|
|
68
|
+
dir_okay=False,
|
|
69
|
+
writable=True,
|
|
70
|
+
readable=False,
|
|
71
|
+
resolve_path=True,
|
|
72
|
+
help="Filesystem path at which you want the program to generate its violation report.",
|
|
73
|
+
)] = "violations.tsv",
|
|
74
|
+
):
|
|
75
|
+
"""
|
|
76
|
+
Scans the NMDC MongoDB database for referential integrity violations.
|
|
77
|
+
"""
|
|
78
|
+
# Instantiate a `linkml_runtime.SchemaView` bound to the specified schema.
|
|
79
|
+
if verbose:
|
|
80
|
+
console.print(f"Schema YAML file: {schema_file_path}")
|
|
81
|
+
schema_view = linkml_runtime.SchemaView(schema_file_path)
|
|
82
|
+
|
|
83
|
+
# Show high-level information about the schema and LinkML runtime.
|
|
84
|
+
console.print(f"Schema version: {schema_view.schema.version}")
|
|
85
|
+
console.print(f"LinkML runtime version: {linkml_runtime.__version__}")
|
|
86
|
+
|
|
87
|
+
# Make a more self-documenting alias for the CLI option that can be specified multiple times.
|
|
88
|
+
names_of_source_collections_to_skip: list[str] = [] if skip_source_collection is None else skip_source_collection
|
|
89
|
+
|
|
90
|
+
# Get a list of collection names (technically, `Database` slot names) from the schema.
|
|
91
|
+
# e.g. ["study_set", ...]
|
|
92
|
+
collection_names = get_collection_names_from_schema(schema_view)
|
|
93
|
+
console.print(f"Collections described by schema: {len(collection_names)}")
|
|
94
|
+
|
|
95
|
+
# For each collection, determine the names of the classes whose instances can be stored in that collection.
|
|
96
|
+
collection_name_to_class_names = {} # example: { "study_set": ["Study"] }
|
|
97
|
+
for collection_name in sorted(collection_names):
|
|
98
|
+
slot_definition = schema_view.induced_slot(collection_name, DATABASE_CLASS_NAME)
|
|
99
|
+
name_of_eligible_class = slot_definition.range
|
|
100
|
+
names_of_eligible_classes = schema_view.class_descendants(name_of_eligible_class) # includes own class name
|
|
101
|
+
collection_name_to_class_names[collection_name] = names_of_eligible_classes
|
|
102
|
+
|
|
103
|
+
# Initialize the list of references. A reference is effectively a "foreign key" (i.e. a pointer).
|
|
104
|
+
references = ReferenceList()
|
|
105
|
+
|
|
106
|
+
# For each class whose instances can be stored in each collection, determine which of its slots can be a reference.
|
|
107
|
+
sorted_collection_names_to_class_names = sorted(collection_name_to_class_names.items(), key=get_lowercase_key)
|
|
108
|
+
for collection_name, class_names in sorted_collection_names_to_class_names:
|
|
109
|
+
for class_name in class_names:
|
|
110
|
+
for slot_name in schema_view.class_slots(class_name):
|
|
111
|
+
|
|
112
|
+
# Get the slot definition in the context of its use on this particular class.
|
|
113
|
+
slot_definition = schema_view.induced_slot(slot_name=slot_name, class_name=class_name)
|
|
114
|
+
|
|
115
|
+
# Determine the slot's "effective" range, by taking into account its `any_of` constraints (if defined).
|
|
116
|
+
#
|
|
117
|
+
# Note: The `any_of` constraints constrain the slot's "effective" range beyond that described by the
|
|
118
|
+
# induced slot definition's `range` attribute. `SchemaView` does not seem to provide the result
|
|
119
|
+
# of applying those additional constraints, so we do it manually here (if any are defined).
|
|
120
|
+
#
|
|
121
|
+
# Reference: https://github.com/orgs/linkml/discussions/2101#discussion-6625646
|
|
122
|
+
#
|
|
123
|
+
names_of_eligible_target_classes: list[str] = []
|
|
124
|
+
if "any_of" in slot_definition and len(slot_definition.any_of) > 0: # use the `any_of` attribute
|
|
125
|
+
for slot_expression in slot_definition.any_of:
|
|
126
|
+
if slot_expression.range in schema_view.all_classes():
|
|
127
|
+
own_and_descendant_class_names = schema_view.class_descendants(slot_expression.range)
|
|
128
|
+
names_of_eligible_target_classes.extend(own_and_descendant_class_names)
|
|
129
|
+
else: # use the `range` attribute
|
|
130
|
+
if slot_definition.range not in schema_view.all_classes(): # if it's not a class name, abort
|
|
131
|
+
continue
|
|
132
|
+
else:
|
|
133
|
+
# Get the specified class name and the names of all classes that inherit from it.
|
|
134
|
+
own_and_descendant_class_names = schema_view.class_descendants(slot_definition.range)
|
|
135
|
+
names_of_eligible_target_classes.extend(own_and_descendant_class_names)
|
|
136
|
+
|
|
137
|
+
# Remove duplicate class names.
|
|
138
|
+
names_of_eligible_target_classes = list(set(names_of_eligible_target_classes))
|
|
139
|
+
|
|
140
|
+
# For each of those classes whose instances can be stored in any collection, catalog a reference.
|
|
141
|
+
for name_of_eligible_target_class in names_of_eligible_target_classes:
|
|
142
|
+
for target_collection_name, class_names_in_collection in collection_name_to_class_names.items():
|
|
143
|
+
if name_of_eligible_target_class in class_names_in_collection:
|
|
144
|
+
reference = Reference(source_collection_name=collection_name,
|
|
145
|
+
source_class_name=class_name,
|
|
146
|
+
source_field_name=slot_name,
|
|
147
|
+
target_collection_name=target_collection_name,
|
|
148
|
+
target_class_name=name_of_eligible_target_class)
|
|
149
|
+
references.append(reference)
|
|
150
|
+
|
|
151
|
+
console.print(f"Slot-to-ID references described by schema: {len(references)}")
|
|
152
|
+
|
|
153
|
+
# Create a reference report in TSV format.
|
|
154
|
+
console.print(f"Writing reference report: {reference_report_file_path}")
|
|
155
|
+
references.dump_to_tsv_file(file_path=reference_report_file_path)
|
|
156
|
+
|
|
157
|
+
# Display a table of references.
|
|
158
|
+
if verbose:
|
|
159
|
+
console.print(references.as_table())
|
|
160
|
+
|
|
161
|
+
# Get a dictionary that maps source class names to the names of their fields that can contain references.
|
|
162
|
+
reference_field_names_by_source_class_name = references.get_reference_field_names_by_source_class_name()
|
|
163
|
+
|
|
164
|
+
# Initialize a progress bar.
|
|
165
|
+
custom_progress = init_progress_bar()
|
|
166
|
+
|
|
167
|
+
# Connect to the MongoDB server and verify the database is accessible.
|
|
168
|
+
mongo_client = connect_to_database(mongo_uri, database_name)
|
|
169
|
+
|
|
170
|
+
db = mongo_client.get_database(database_name)
|
|
171
|
+
|
|
172
|
+
# Make a finder bound to this database.
|
|
173
|
+
# Note: A finder is a wrapper around a database that adds some caching that speeds up searches in some situations.
|
|
174
|
+
finder = Finder(database=db)
|
|
175
|
+
|
|
176
|
+
source_collections_and_their_violations: dict[str, ViolationList] = {}
|
|
177
|
+
with custom_progress as progress:
|
|
178
|
+
|
|
179
|
+
# Filter out the collections that the schema says can contain references, but that don't exist in the database.
|
|
180
|
+
source_collection_names_in_db = []
|
|
181
|
+
for collection_name in references.get_source_collection_names():
|
|
182
|
+
if db.get_collection(collection_name) is None:
|
|
183
|
+
console.print(f"🤷 [orange]Database lacks collection:[/orange] {collection_name}")
|
|
184
|
+
else:
|
|
185
|
+
source_collection_names_in_db.append(collection_name)
|
|
186
|
+
|
|
187
|
+
# Process each collection, checking for referential integrity violations;
|
|
188
|
+
# using the reference catalog created earlier to know which collections can
|
|
189
|
+
# contain "referrers" (documents), which of their slots can contain references (fields),
|
|
190
|
+
# and which collections can contain the referred-to "referees" (documents).
|
|
191
|
+
for source_collection_name in sorted(source_collection_names_in_db):
|
|
192
|
+
|
|
193
|
+
# If this source collection is one of the ones the user wanted to skip, skip it now.
|
|
194
|
+
if source_collection_name in names_of_source_collections_to_skip:
|
|
195
|
+
console.print(f"⚠️ [orange][bold]Skipping source collection:[/bold][/orange] {source_collection_name}")
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
collection = db.get_collection(source_collection_name)
|
|
199
|
+
|
|
200
|
+
# Prepare the query we will use to fetch documents from this collection. The documents we will fetch are
|
|
201
|
+
# those that have _any_ of the fields (of classes whose instances are allowed to reside in this collection)
|
|
202
|
+
# that the schema allows to contain a reference to an instance.
|
|
203
|
+
source_field_names = references.get_source_field_names_of_source_collection(source_collection_name)
|
|
204
|
+
or_terms = [{field_name: {'$exists': True}} for field_name in source_field_names]
|
|
205
|
+
query_filter = {'$or': or_terms}
|
|
206
|
+
if verbose:
|
|
207
|
+
console.print(f"{query_filter=}")
|
|
208
|
+
|
|
209
|
+
# Ensure the fields we fetch include:
|
|
210
|
+
# - "id" (so we can produce a more user-friendly report later)
|
|
211
|
+
# - "type" (so we can map the document to a schema class)
|
|
212
|
+
additional_field_names_for_projection = []
|
|
213
|
+
if "id" not in source_field_names:
|
|
214
|
+
additional_field_names_for_projection.append("id")
|
|
215
|
+
if "type" not in source_field_names:
|
|
216
|
+
additional_field_names_for_projection.append("type")
|
|
217
|
+
query_projection = source_field_names + additional_field_names_for_projection
|
|
218
|
+
if verbose:
|
|
219
|
+
console.print(f"{query_projection=}")
|
|
220
|
+
|
|
221
|
+
# Set up the progress bar for the task of scanning those documents.
|
|
222
|
+
num_relevant_documents = collection.count_documents(query_filter)
|
|
223
|
+
task_id = progress.add_task(f"{source_collection_name}",
|
|
224
|
+
total=num_relevant_documents,
|
|
225
|
+
num_violations=0,
|
|
226
|
+
remaining_time_label="remaining")
|
|
227
|
+
|
|
228
|
+
# Advance the progress bar by 0 (this makes it so that, even if there are 0 relevant documents, the progress
|
|
229
|
+
# bar does not continue incrementing its "elapsed time" even after a subsequent task has begun).
|
|
230
|
+
progress.update(task_id, advance=0)
|
|
231
|
+
|
|
232
|
+
# Initialize the violation list for this collection.
|
|
233
|
+
source_collections_and_their_violations[source_collection_name] = ViolationList()
|
|
234
|
+
|
|
235
|
+
# Process each relevant document.
|
|
236
|
+
for document in collection.find(query_filter, projection=query_projection):
|
|
237
|
+
|
|
238
|
+
# Get the document's `id` so that we can include it in this script's output.
|
|
239
|
+
source_document_object_id = document["_id"]
|
|
240
|
+
source_document_id = document["id"] if "id" in document else None
|
|
241
|
+
|
|
242
|
+
# Get the document's schema class name so that we can interpret its fields accordingly.
|
|
243
|
+
source_class_name = derive_schema_class_name_from_document(schema_view, document)
|
|
244
|
+
|
|
245
|
+
# Get the names of that class's fields that can contain references.
|
|
246
|
+
names_of_reference_fields = reference_field_names_by_source_class_name.get(source_class_name, [])
|
|
247
|
+
|
|
248
|
+
# Check each field that both (a) exists in the document and (b) can contain a reference.
|
|
249
|
+
for field_name in names_of_reference_fields:
|
|
250
|
+
if field_name in document:
|
|
251
|
+
# Determine which collections can contain the referenced document, based upon
|
|
252
|
+
# the schema class of which this source document is an instance.
|
|
253
|
+
target_collection_names = references.get_target_collection_names(
|
|
254
|
+
source_class_name=source_class_name,
|
|
255
|
+
source_field_name=field_name,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Handle both the multi-value (array) and the single-value (scalar) case,
|
|
259
|
+
# normalizing the value or values into a list of values in either case.
|
|
260
|
+
if type(document[field_name]) is list:
|
|
261
|
+
target_ids = document[field_name]
|
|
262
|
+
else:
|
|
263
|
+
target_id = document[field_name]
|
|
264
|
+
target_ids = [target_id] # makes a one-item list
|
|
265
|
+
|
|
266
|
+
for target_id in target_ids:
|
|
267
|
+
target_exists = finder.check_whether_document_having_id_exists_among_collections(
|
|
268
|
+
collection_names=target_collection_names,
|
|
269
|
+
document_id=target_id
|
|
270
|
+
)
|
|
271
|
+
if not target_exists:
|
|
272
|
+
violation = Violation(source_collection_name=source_collection_name,
|
|
273
|
+
source_field_name=field_name,
|
|
274
|
+
source_document_object_id=source_document_object_id,
|
|
275
|
+
source_document_id=source_document_id,
|
|
276
|
+
target_id=target_id)
|
|
277
|
+
source_collections_and_their_violations[source_collection_name].append(violation)
|
|
278
|
+
if verbose:
|
|
279
|
+
console.print(f"Failed to find document having `id` '{target_id}' "
|
|
280
|
+
f"among collections: {target_collection_names}. "
|
|
281
|
+
f"{violation=}")
|
|
282
|
+
|
|
283
|
+
# Advance the progress bar to account for the current document's contribution to the violations count.
|
|
284
|
+
progress.update(task_id,
|
|
285
|
+
advance=1,
|
|
286
|
+
num_violations=len(source_collections_and_their_violations[source_collection_name]))
|
|
287
|
+
|
|
288
|
+
# Update the progress bar to indicate the current task is complete.
|
|
289
|
+
progress.update(task_id, remaining_time_label="done")
|
|
290
|
+
|
|
291
|
+
# Close the connection to the MongoDB server.
|
|
292
|
+
mongo_client.close()
|
|
293
|
+
|
|
294
|
+
# Create a violation report in TSV format — for all collections combined.
|
|
295
|
+
all_violations = ViolationList()
|
|
296
|
+
for collection_name, violations in sorted(source_collections_and_their_violations.items(), key=get_lowercase_key):
|
|
297
|
+
console.print(f"Number of violations in {collection_name}: {len(violations)}")
|
|
298
|
+
all_violations.extend(violations)
|
|
299
|
+
if verbose:
|
|
300
|
+
console.print(violations)
|
|
301
|
+
|
|
302
|
+
console.print(f"Total violations: {len(all_violations)}")
|
|
303
|
+
console.print(f"Writing violation report: {violation_report_file_path}")
|
|
304
|
+
all_violations.dump_to_tsv_file(file_path=violation_report_file_path)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
app()
|