mongrove 0.1.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.
- mongrove/__init__.py +3 -0
- mongrove/__main__.py +7 -0
- mongrove/cli.py +217 -0
- mongrove/domain/__init__.py +30 -0
- mongrove/domain/connection.py +26 -0
- mongrove/domain/explain.py +208 -0
- mongrove/domain/index.py +36 -0
- mongrove/domain/namespace.py +28 -0
- mongrove/domain/pipeline.py +54 -0
- mongrove/domain/query.py +156 -0
- mongrove/domain/schema.py +26 -0
- mongrove/domain/session.py +89 -0
- mongrove/services/__init__.py +34 -0
- mongrove/services/bson_codec.py +74 -0
- mongrove/services/import_export.py +259 -0
- mongrove/services/mongo_gateway.py +792 -0
- mongrove/services/profile_store.py +148 -0
- mongrove/services/query_history.py +367 -0
- mongrove/services/schema_analyzer.py +185 -0
- mongrove/services/settings_store.py +56 -0
- mongrove/ui/__init__.py +1 -0
- mongrove/ui/app.py +216 -0
- mongrove/ui/commands.py +17 -0
- mongrove/ui/screens/__init__.py +1 -0
- mongrove/ui/screens/aggregation.py +219 -0
- mongrove/ui/screens/browser.py +1126 -0
- mongrove/ui/screens/connection.py +249 -0
- mongrove/ui/screens/document.py +58 -0
- mongrove/ui/screens/document_editor.py +124 -0
- mongrove/ui/screens/explain.py +185 -0
- mongrove/ui/screens/export.py +249 -0
- mongrove/ui/screens/index_confirmation.py +119 -0
- mongrove/ui/screens/index_editor.py +100 -0
- mongrove/ui/screens/indexes.py +346 -0
- mongrove/ui/screens/mutation_confirmation.py +133 -0
- mongrove/ui/screens/query_history.py +289 -0
- mongrove/ui/screens/query_options.py +86 -0
- mongrove/ui/screens/schema.py +179 -0
- mongrove/ui/screens/theme_picker.py +77 -0
- mongrove/ui/styles/app.tcss +567 -0
- mongrove/ui/themes.py +149 -0
- mongrove/ui/widgets/__init__.py +6 -0
- mongrove/ui/widgets/document_table.py +31 -0
- mongrove/ui/widgets/document_viewer.py +72 -0
- mongrove-0.1.0.dist-info/METADATA +315 -0
- mongrove-0.1.0.dist-info/RECORD +50 -0
- mongrove-0.1.0.dist-info/WHEEL +5 -0
- mongrove-0.1.0.dist-info/entry_points.txt +2 -0
- mongrove-0.1.0.dist-info/licenses/LICENSE +21 -0
- mongrove-0.1.0.dist-info/top_level.txt +2 -0
mongrove/__init__.py
ADDED
mongrove/__main__.py
ADDED
mongrove/cli.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Command-line entry point for Mongrove."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Mapping, Sequence
|
|
10
|
+
|
|
11
|
+
from mongrove import __version__
|
|
12
|
+
from mongrove.domain.session import normalize_environment
|
|
13
|
+
from mongrove.ui.app import MongroveApp
|
|
14
|
+
from mongrove.ui.themes import CURATED_THEME_NAMES
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class StartupOptions:
|
|
19
|
+
"""Resolved startup settings after applying CLI-over-environment precedence."""
|
|
20
|
+
|
|
21
|
+
uri: str | None
|
|
22
|
+
profile: str | None
|
|
23
|
+
database: str | None
|
|
24
|
+
collection: str | None
|
|
25
|
+
read_only: bool
|
|
26
|
+
no_history: bool
|
|
27
|
+
config_dir: Path | None
|
|
28
|
+
theme: str | None
|
|
29
|
+
environment: str | None
|
|
30
|
+
allow_production_writes: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
"""Build the command-line parser without starting a terminal UI."""
|
|
35
|
+
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="mongrove",
|
|
38
|
+
description="A keyboard-first MongoDB workspace for the terminal.",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"uri",
|
|
42
|
+
nargs="?",
|
|
43
|
+
help="MongoDB connection URI. Avoid passwords here when possible.",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--profile",
|
|
47
|
+
"--alias",
|
|
48
|
+
dest="profile",
|
|
49
|
+
help="Saved connection alias to preselect.",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--database", help="Database to open after connecting.")
|
|
52
|
+
parser.add_argument("--collection", help="Collection to open after connecting.")
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--read-only",
|
|
55
|
+
action="store_true",
|
|
56
|
+
default=None,
|
|
57
|
+
help="Hide write controls. MongoDB roles remain the security boundary.",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--no-history",
|
|
61
|
+
action="store_true",
|
|
62
|
+
default=None,
|
|
63
|
+
help="Disable local query history for this session.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--config-dir",
|
|
67
|
+
type=Path,
|
|
68
|
+
help="Override the directory containing local Mongrove configuration.",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--theme",
|
|
72
|
+
choices=CURATED_THEME_NAMES,
|
|
73
|
+
metavar="THEME",
|
|
74
|
+
help="Use a curated theme for this launch.",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--environment",
|
|
78
|
+
type=_parse_environment,
|
|
79
|
+
metavar="ENV",
|
|
80
|
+
help="Label this direct connection as development, staging, or production.",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--allow-production-writes",
|
|
84
|
+
action="store_true",
|
|
85
|
+
default=None,
|
|
86
|
+
help="Allow writes for a production target; each mutation still confirms explicitly.",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--version",
|
|
90
|
+
action="version",
|
|
91
|
+
version=f"%(prog)s {__version__}",
|
|
92
|
+
)
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
97
|
+
"""Parse arguments and run the interactive application."""
|
|
98
|
+
|
|
99
|
+
parser = build_parser()
|
|
100
|
+
args = parser.parse_args(argv)
|
|
101
|
+
try:
|
|
102
|
+
options = resolve_startup_options(args)
|
|
103
|
+
except ValueError as error:
|
|
104
|
+
parser.error(str(error))
|
|
105
|
+
app = MongroveApp(
|
|
106
|
+
startup_uri=options.uri,
|
|
107
|
+
startup_profile=options.profile,
|
|
108
|
+
startup_database=options.database,
|
|
109
|
+
startup_collection=options.collection,
|
|
110
|
+
read_only=options.read_only,
|
|
111
|
+
no_history=options.no_history,
|
|
112
|
+
config_dir=options.config_dir,
|
|
113
|
+
theme_name=options.theme,
|
|
114
|
+
environment=options.environment,
|
|
115
|
+
allow_production_writes=options.allow_production_writes,
|
|
116
|
+
)
|
|
117
|
+
app.run()
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_startup_options(
|
|
122
|
+
args: argparse.Namespace,
|
|
123
|
+
environ: Mapping[str, str] | None = None,
|
|
124
|
+
) -> StartupOptions:
|
|
125
|
+
"""Resolve startup values with explicit command-line values taking priority.
|
|
126
|
+
|
|
127
|
+
A direct URI or alias supplied on the command line takes precedence over all
|
|
128
|
+
environment connection selectors. This prevents a shell's stale URI from
|
|
129
|
+
silently changing an explicitly requested target.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
environment = os.environ if environ is None else environ
|
|
133
|
+
if args.uri:
|
|
134
|
+
uri = args.uri
|
|
135
|
+
profile = None
|
|
136
|
+
elif args.profile:
|
|
137
|
+
uri = None
|
|
138
|
+
profile = args.profile
|
|
139
|
+
else:
|
|
140
|
+
uri = _environment_value(environment, "MONGROVE_URI")
|
|
141
|
+
profile = None if uri else _environment_value(environment, "MONGROVE_PROFILE")
|
|
142
|
+
|
|
143
|
+
resolved_environment = (
|
|
144
|
+
args.environment
|
|
145
|
+
if args.environment is not None
|
|
146
|
+
else normalize_environment(_environment_value(environment, "MONGROVE_ENVIRONMENT"))
|
|
147
|
+
)
|
|
148
|
+
theme = args.theme or _environment_value(environment, "MONGROVE_THEME")
|
|
149
|
+
if theme is not None and theme not in CURATED_THEME_NAMES:
|
|
150
|
+
choices = ", ".join(CURATED_THEME_NAMES)
|
|
151
|
+
raise ValueError(f"MONGROVE_THEME must be one of: {choices}.")
|
|
152
|
+
|
|
153
|
+
config_dir = args.config_dir
|
|
154
|
+
if config_dir is None:
|
|
155
|
+
config_dir_value = _environment_value(environment, "MONGROVE_CONFIG_DIR")
|
|
156
|
+
config_dir = Path(config_dir_value) if config_dir_value else None
|
|
157
|
+
|
|
158
|
+
return StartupOptions(
|
|
159
|
+
uri=uri,
|
|
160
|
+
profile=profile,
|
|
161
|
+
database=args.database or _environment_value(environment, "MONGROVE_DATABASE"),
|
|
162
|
+
collection=args.collection or _environment_value(environment, "MONGROVE_COLLECTION"),
|
|
163
|
+
read_only=_resolve_boolean_option(
|
|
164
|
+
args.read_only,
|
|
165
|
+
environment,
|
|
166
|
+
"MONGROVE_READ_ONLY",
|
|
167
|
+
),
|
|
168
|
+
no_history=_resolve_boolean_option(
|
|
169
|
+
args.no_history,
|
|
170
|
+
environment,
|
|
171
|
+
"MONGROVE_NO_HISTORY",
|
|
172
|
+
),
|
|
173
|
+
config_dir=config_dir,
|
|
174
|
+
theme=theme,
|
|
175
|
+
environment=resolved_environment,
|
|
176
|
+
allow_production_writes=_resolve_boolean_option(
|
|
177
|
+
args.allow_production_writes,
|
|
178
|
+
environment,
|
|
179
|
+
"MONGROVE_ALLOW_PRODUCTION_WRITES",
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_environment(value: str) -> str:
|
|
185
|
+
try:
|
|
186
|
+
parsed = normalize_environment(value)
|
|
187
|
+
except ValueError as error:
|
|
188
|
+
raise argparse.ArgumentTypeError(str(error)) from error
|
|
189
|
+
if parsed is None:
|
|
190
|
+
raise argparse.ArgumentTypeError("Environment cannot be empty.")
|
|
191
|
+
return parsed
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _environment_value(environment: Mapping[str, str], name: str) -> str | None:
|
|
195
|
+
value = environment.get(name)
|
|
196
|
+
if value is None:
|
|
197
|
+
return None
|
|
198
|
+
stripped = value.strip()
|
|
199
|
+
return stripped or None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _resolve_boolean_option(
|
|
203
|
+
command_line_value: bool | None,
|
|
204
|
+
environment: Mapping[str, str],
|
|
205
|
+
name: str,
|
|
206
|
+
) -> bool:
|
|
207
|
+
if command_line_value is not None:
|
|
208
|
+
return command_line_value
|
|
209
|
+
value = _environment_value(environment, name)
|
|
210
|
+
if value is None:
|
|
211
|
+
return False
|
|
212
|
+
normalized = value.casefold()
|
|
213
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
214
|
+
return True
|
|
215
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
216
|
+
return False
|
|
217
|
+
raise ValueError(f"{name} must be true or false.")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Domain models and query parsing for Mongrove."""
|
|
2
|
+
|
|
3
|
+
from mongrove.domain.connection import ConnectionInfo, ConnectionProfile
|
|
4
|
+
from mongrove.domain.explain import ExplainFragment, ExplainResult, ExplainWarning
|
|
5
|
+
from mongrove.domain.index import IndexInfo, IndexUsage, IndexUsageReport
|
|
6
|
+
from mongrove.domain.pipeline import AggregationPipeline, PipelineValidationError, parse_pipeline
|
|
7
|
+
from mongrove.domain.query import FindQuery, QueryFormState, QueryValidationError
|
|
8
|
+
from mongrove.domain.session import SessionPolicy, normalize_environment
|
|
9
|
+
from mongrove.domain.schema import SchemaField, SchemaReport
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AggregationPipeline",
|
|
13
|
+
"ConnectionInfo",
|
|
14
|
+
"ConnectionProfile",
|
|
15
|
+
"ExplainFragment",
|
|
16
|
+
"ExplainResult",
|
|
17
|
+
"ExplainWarning",
|
|
18
|
+
"FindQuery",
|
|
19
|
+
"IndexInfo",
|
|
20
|
+
"IndexUsage",
|
|
21
|
+
"IndexUsageReport",
|
|
22
|
+
"PipelineValidationError",
|
|
23
|
+
"QueryFormState",
|
|
24
|
+
"QueryValidationError",
|
|
25
|
+
"SessionPolicy",
|
|
26
|
+
"SchemaField",
|
|
27
|
+
"SchemaReport",
|
|
28
|
+
"normalize_environment",
|
|
29
|
+
"parse_pipeline",
|
|
30
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Connection-facing domain models for Mongrove."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class ConnectionProfile:
|
|
10
|
+
"""A locally saved connection endpoint without secret credentials."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
uri: str
|
|
14
|
+
favorite: bool = False
|
|
15
|
+
default_database: str | None = None
|
|
16
|
+
environment: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ConnectionInfo:
|
|
21
|
+
"""Connection facts collected after a successful server ping."""
|
|
22
|
+
|
|
23
|
+
display_uri: str
|
|
24
|
+
server_version: str | None = None
|
|
25
|
+
topology: str | None = None
|
|
26
|
+
is_writable_primary: bool | None = None
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Best-effort normalization of version-variable MongoDB explain responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from mongrove.domain.pipeline import AggregationPipeline
|
|
10
|
+
from mongrove.domain.query import FindQuery
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ExplainFragment:
|
|
15
|
+
"""One independently normalized planner fragment within an explain response."""
|
|
16
|
+
|
|
17
|
+
path: str
|
|
18
|
+
root_stage: str | None
|
|
19
|
+
stages: tuple[str, ...]
|
|
20
|
+
index_names: tuple[str, ...]
|
|
21
|
+
rejected_plan_count: int | None
|
|
22
|
+
optimized_pipeline: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class ExplainWarning:
|
|
27
|
+
"""Evidence-based planner observation, never an automatic tuning claim."""
|
|
28
|
+
|
|
29
|
+
code: str
|
|
30
|
+
severity: Literal["info", "warning"]
|
|
31
|
+
message: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ExplainResult:
|
|
36
|
+
"""Planner-only explain response with raw BSON-compatible fallback data."""
|
|
37
|
+
|
|
38
|
+
operation: Literal["find", "aggregation"]
|
|
39
|
+
elapsed_ms: int
|
|
40
|
+
fragments: tuple[ExplainFragment, ...]
|
|
41
|
+
warnings: tuple[ExplainWarning, ...]
|
|
42
|
+
raw: dict[str, Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_find_explain(raw: dict[str, Any], query: FindQuery, elapsed_ms: int) -> ExplainResult:
|
|
46
|
+
"""Normalize a planner-only find explain and retain the raw server response."""
|
|
47
|
+
|
|
48
|
+
result = _normalize("find", raw, elapsed_ms)
|
|
49
|
+
warnings = list(result.warnings)
|
|
50
|
+
if query.limit is None:
|
|
51
|
+
warnings.append(
|
|
52
|
+
ExplainWarning(
|
|
53
|
+
code="unbounded-find",
|
|
54
|
+
severity="info",
|
|
55
|
+
message="The logical find query has no limit.",
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return ExplainResult(
|
|
59
|
+
operation=result.operation,
|
|
60
|
+
elapsed_ms=result.elapsed_ms,
|
|
61
|
+
fragments=result.fragments,
|
|
62
|
+
warnings=tuple(warnings),
|
|
63
|
+
raw=result.raw,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize_aggregation_explain(
|
|
68
|
+
raw: dict[str, Any],
|
|
69
|
+
pipeline: AggregationPipeline,
|
|
70
|
+
elapsed_ms: int,
|
|
71
|
+
) -> ExplainResult:
|
|
72
|
+
"""Normalize a planner-only aggregation explain and retain raw response data."""
|
|
73
|
+
|
|
74
|
+
return _normalize("aggregation", raw, elapsed_ms)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _normalize(
|
|
78
|
+
operation: Literal["find", "aggregation"],
|
|
79
|
+
raw: dict[str, Any],
|
|
80
|
+
elapsed_ms: int,
|
|
81
|
+
) -> ExplainResult:
|
|
82
|
+
planners, truncated = _planner_nodes(raw)
|
|
83
|
+
fragments = tuple(_fragment(path, planner, raw) for path, planner in planners)
|
|
84
|
+
warnings: list[ExplainWarning] = []
|
|
85
|
+
stages = {stage for fragment in fragments for stage in fragment.stages}
|
|
86
|
+
if "COLLSCAN" in stages:
|
|
87
|
+
warnings.append(
|
|
88
|
+
ExplainWarning("collection-scan", "warning", "Observed COLLSCAN in the winning plan.")
|
|
89
|
+
)
|
|
90
|
+
if "SORT" in stages:
|
|
91
|
+
warnings.append(
|
|
92
|
+
ExplainWarning("in-memory-sort", "warning", "Observed SORT in the winning plan.")
|
|
93
|
+
)
|
|
94
|
+
if "SHARD_MERGE" in stages:
|
|
95
|
+
warnings.append(
|
|
96
|
+
ExplainWarning("shard-merge", "info", "Observed SHARD_MERGE in the winning plan.")
|
|
97
|
+
)
|
|
98
|
+
rejected = sum(fragment.rejected_plan_count or 0 for fragment in fragments)
|
|
99
|
+
if rejected:
|
|
100
|
+
warnings.append(
|
|
101
|
+
ExplainWarning(
|
|
102
|
+
"rejected-plans",
|
|
103
|
+
"info",
|
|
104
|
+
f"Observed {rejected} rejected planner candidate(s).",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
if any(fragment.optimized_pipeline for fragment in fragments):
|
|
108
|
+
warnings.append(
|
|
109
|
+
ExplainWarning(
|
|
110
|
+
"optimized-pipeline",
|
|
111
|
+
"info",
|
|
112
|
+
"Server reported an optimized aggregation pipeline.",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
if not fragments:
|
|
116
|
+
warnings.append(
|
|
117
|
+
ExplainWarning(
|
|
118
|
+
"unrecognized-shape",
|
|
119
|
+
"info",
|
|
120
|
+
"Plan shape was not normalized; inspect Raw EJSON for server-specific details.",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
if truncated:
|
|
124
|
+
warnings.append(
|
|
125
|
+
ExplainWarning(
|
|
126
|
+
"normalization-truncated",
|
|
127
|
+
"info",
|
|
128
|
+
"Plan normalization reached its safety bound; inspect Raw EJSON for remaining details.",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return ExplainResult(
|
|
132
|
+
operation=operation,
|
|
133
|
+
elapsed_ms=elapsed_ms,
|
|
134
|
+
fragments=fragments,
|
|
135
|
+
warnings=tuple(warnings),
|
|
136
|
+
raw=raw,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _planner_nodes(raw: Mapping[str, Any]) -> tuple[list[tuple[str, Mapping[str, Any]]], bool]:
|
|
141
|
+
nodes: list[tuple[str, Mapping[str, Any]]] = []
|
|
142
|
+
truncated = False
|
|
143
|
+
visited = 0
|
|
144
|
+
|
|
145
|
+
def walk(value: Any, path: str, depth: int) -> None:
|
|
146
|
+
nonlocal truncated, visited
|
|
147
|
+
if depth > 24 or visited >= 1_000 or len(nodes) >= 32:
|
|
148
|
+
truncated = True
|
|
149
|
+
return
|
|
150
|
+
visited += 1
|
|
151
|
+
if isinstance(value, Mapping):
|
|
152
|
+
planner = value.get("queryPlanner")
|
|
153
|
+
if isinstance(planner, Mapping):
|
|
154
|
+
nodes.append((f"{path}.queryPlanner" if path else "queryPlanner", planner))
|
|
155
|
+
for key, child in value.items():
|
|
156
|
+
if key == "queryPlanner":
|
|
157
|
+
continue
|
|
158
|
+
walk(child, f"{path}.{key}" if path else str(key), depth + 1)
|
|
159
|
+
elif isinstance(value, list):
|
|
160
|
+
for index, child in enumerate(value):
|
|
161
|
+
walk(child, f"{path}[{index}]", depth + 1)
|
|
162
|
+
|
|
163
|
+
walk(raw, "", 0)
|
|
164
|
+
return nodes, truncated
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _fragment(path: str, planner: Mapping[str, Any], raw: Mapping[str, Any]) -> ExplainFragment:
|
|
168
|
+
winning = planner.get("winningPlan")
|
|
169
|
+
if not isinstance(winning, Mapping):
|
|
170
|
+
winning = {}
|
|
171
|
+
query_plan = winning.get("queryPlan")
|
|
172
|
+
root = query_plan if isinstance(query_plan, Mapping) else winning
|
|
173
|
+
stages, indexes = _plan_details(root)
|
|
174
|
+
rejected = planner.get("rejectedPlans")
|
|
175
|
+
return ExplainFragment(
|
|
176
|
+
path=path,
|
|
177
|
+
root_stage=stages[0] if stages else None,
|
|
178
|
+
stages=tuple(stages),
|
|
179
|
+
index_names=tuple(indexes),
|
|
180
|
+
rejected_plan_count=len(rejected) if isinstance(rejected, list) else None,
|
|
181
|
+
optimized_pipeline=bool(planner.get("optimizedPipeline") or raw.get("optimizedPipeline")),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _plan_details(root: Mapping[str, Any]) -> tuple[list[str], list[str]]:
|
|
186
|
+
stages: list[str] = []
|
|
187
|
+
indexes: list[str] = []
|
|
188
|
+
children = ("inputStage", "inputStages", "thenStage", "elseStage", "innerStage", "outerStage")
|
|
189
|
+
|
|
190
|
+
def walk(node: Any, depth: int) -> None:
|
|
191
|
+
if depth > 24 or not isinstance(node, Mapping):
|
|
192
|
+
return
|
|
193
|
+
stage = node.get("stage")
|
|
194
|
+
if isinstance(stage, str):
|
|
195
|
+
stages.append(stage)
|
|
196
|
+
index_name = node.get("indexName")
|
|
197
|
+
if isinstance(index_name, str) and index_name not in indexes:
|
|
198
|
+
indexes.append(index_name)
|
|
199
|
+
for key in children:
|
|
200
|
+
child = node.get(key)
|
|
201
|
+
if isinstance(child, list):
|
|
202
|
+
for item in child:
|
|
203
|
+
walk(item, depth + 1)
|
|
204
|
+
else:
|
|
205
|
+
walk(child, depth + 1)
|
|
206
|
+
|
|
207
|
+
walk(root, 0)
|
|
208
|
+
return stages, indexes
|
mongrove/domain/index.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""MongoDB index metadata models independent of PyMongo result objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class IndexInfo:
|
|
11
|
+
"""One server-reported collection index and its complete raw specification."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
keys: tuple[tuple[str, Any], ...]
|
|
15
|
+
unique: bool
|
|
16
|
+
sparse: bool
|
|
17
|
+
hidden: bool
|
|
18
|
+
raw: dict[str, Any]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class IndexUsage:
|
|
23
|
+
"""Best-effort node-local $indexStats usage data for one index."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
operations: int | None
|
|
27
|
+
since: str | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class IndexUsageReport:
|
|
32
|
+
"""Usage availability is explicit so absent privileges never look like zero."""
|
|
33
|
+
|
|
34
|
+
available: bool
|
|
35
|
+
usages: tuple[IndexUsage, ...] = ()
|
|
36
|
+
message: str | None = None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Models used by Mongrove namespace navigation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class CollectionInfo:
|
|
10
|
+
"""A collection or view returned by a MongoDB database."""
|
|
11
|
+
|
|
12
|
+
database: str
|
|
13
|
+
name: str
|
|
14
|
+
kind: str = "collection"
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def namespace(self) -> str:
|
|
18
|
+
return f"{self.database}.{self.name}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class NamespaceTreeItem:
|
|
23
|
+
"""Data attached to a Textual Tree node."""
|
|
24
|
+
|
|
25
|
+
kind: str
|
|
26
|
+
database: str
|
|
27
|
+
collection: str | None = None
|
|
28
|
+
collection_kind: str = "collection"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Validated BSON-aware aggregation pipeline models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from bson import json_util
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PipelineValidationError(ValueError):
|
|
12
|
+
"""Raised when raw editor text cannot become a MongoDB pipeline."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class AggregationPipeline:
|
|
17
|
+
"""Parsed pipeline plus a safety classification of its write stages."""
|
|
18
|
+
|
|
19
|
+
stages: tuple[dict[str, Any], ...]
|
|
20
|
+
write_stages: tuple[str, ...] = ()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def has_write_stage(self) -> bool:
|
|
24
|
+
return bool(self.write_stages)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_pipeline(text: str) -> AggregationPipeline:
|
|
28
|
+
"""Parse a JSON/EJSON array of one-key aggregation stage documents."""
|
|
29
|
+
|
|
30
|
+
if not text.strip():
|
|
31
|
+
raise PipelineValidationError("Pipeline cannot be empty; use [] for no stages.")
|
|
32
|
+
try:
|
|
33
|
+
parsed = json_util.loads(text)
|
|
34
|
+
except Exception as error: # json_util exposes multiple parser exceptions.
|
|
35
|
+
raise PipelineValidationError(
|
|
36
|
+
f"Pipeline must be valid JSON or Extended JSON: {error}"
|
|
37
|
+
) from error
|
|
38
|
+
if not isinstance(parsed, list):
|
|
39
|
+
raise PipelineValidationError("Pipeline must be a JSON array of stage documents.")
|
|
40
|
+
|
|
41
|
+
stages: list[dict[str, Any]] = []
|
|
42
|
+
write_stages: list[str] = []
|
|
43
|
+
for index, stage in enumerate(parsed, start=1):
|
|
44
|
+
if not isinstance(stage, dict) or len(stage) != 1:
|
|
45
|
+
raise PipelineValidationError(
|
|
46
|
+
f"Stage {index} must be a JSON document with exactly one operator."
|
|
47
|
+
)
|
|
48
|
+
operator = next(iter(stage))
|
|
49
|
+
if not isinstance(operator, str) or not operator.startswith("$"):
|
|
50
|
+
raise PipelineValidationError(f"Stage {index} must start with a $ operator.")
|
|
51
|
+
stages.append(stage)
|
|
52
|
+
if operator in {"$out", "$merge"}:
|
|
53
|
+
write_stages.append(operator)
|
|
54
|
+
return AggregationPipeline(tuple(stages), tuple(write_stages))
|