mdbkit 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.
mdbkit/explain.py ADDED
@@ -0,0 +1,244 @@
1
+ """Explain-plan analyzer for `mdbkit explain`.
2
+
3
+ Reads a saved explain output (JSON produced by
4
+ `db.coll.find(...).explain("executionStats")` in mongosh, or exported from
5
+ Compass) and answers the question every DBA asks: *why is this query slow,
6
+ and what would fix it?*
7
+
8
+ Handles classic and SBE (6.0+) plan shapes, aggregate explains ($cursor),
9
+ and sharded winning plans. Reuses the same deterministic advisor as
10
+ `mdbkit advise`, so the recommendation logic is identical everywhere.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from typing import List, Optional, Tuple
18
+
19
+ from .advisor import Recommendation, advise
20
+ from .analysis import QueryAggregator
21
+ from .parser import LogEntry
22
+
23
+
24
+ @dataclass
25
+ class ExplainReport:
26
+ ns: str
27
+ stage_chain: List[str]
28
+ collscan: bool
29
+ blocking_sort: bool
30
+ indexes_used: List[str]
31
+ n_returned: int
32
+ keys_examined: int
33
+ docs_examined: int
34
+ exec_ms: int
35
+ rejected_plans: int
36
+ verdicts: List[str] = field(default_factory=list)
37
+ recommendation: Optional[Recommendation] = None
38
+
39
+ def to_dict(self) -> dict:
40
+ return {
41
+ "ns": self.ns,
42
+ "stages": self.stage_chain,
43
+ "collscan": self.collscan,
44
+ "blockingSort": self.blocking_sort,
45
+ "indexesUsed": self.indexes_used,
46
+ "nReturned": self.n_returned,
47
+ "keysExamined": self.keys_examined,
48
+ "docsExamined": self.docs_examined,
49
+ "executionTimeMillis": self.exec_ms,
50
+ "rejectedPlans": self.rejected_plans,
51
+ "verdicts": self.verdicts,
52
+ "recommendation": self.recommendation.to_dict() if self.recommendation else None,
53
+ }
54
+
55
+
56
+ def _collect_stages(plan, out: List[dict], depth: int = 0):
57
+ """Walk a winning plan tree collecting stage docs (classic, SBE, sharded)."""
58
+ if depth > 20 or not isinstance(plan, dict):
59
+ return
60
+ if plan.get("stage"):
61
+ out.append(plan)
62
+ for key in ("queryPlan", "winningPlan", "inputStage", "innerStage",
63
+ "outerStage", "thenStage", "elseStage"):
64
+ if key in plan:
65
+ _collect_stages(plan[key], out, depth + 1)
66
+ for key in ("inputStages", "shards"):
67
+ subs = plan.get(key)
68
+ if isinstance(subs, list):
69
+ for sub in subs:
70
+ _collect_stages(sub, out, depth + 1)
71
+
72
+
73
+ def _find_query_planner(doc: dict) -> Tuple[dict, dict]:
74
+ """Locate queryPlanner + executionStats in find or aggregate explains."""
75
+ if "queryPlanner" in doc:
76
+ return doc.get("queryPlanner") or {}, doc.get("executionStats") or {}
77
+ for stage in doc.get("stages", []) or []:
78
+ if not isinstance(stage, dict):
79
+ continue
80
+ cursor = stage.get("$cursor") or stage.get("$geoNearCursor")
81
+ if isinstance(cursor, dict) and "queryPlanner" in cursor:
82
+ return cursor.get("queryPlanner") or {}, cursor.get("executionStats") or {}
83
+ return {}, {}
84
+
85
+
86
+ def load_explain(path: str) -> dict:
87
+ with open(path, "r", encoding="utf-8") as fh:
88
+ text = fh.read()
89
+ try:
90
+ doc = json.loads(text)
91
+ except json.JSONDecodeError as exc:
92
+ raise ValueError(
93
+ "This file is not valid JSON. Legacy mongo-shell explain output "
94
+ "contains constructors like NumberLong(...) that are not JSON; "
95
+ "re-export with mongosh (e.g. "
96
+ "`EJSON.stringify(db.coll.find(...).explain('executionStats'))`) "
97
+ "or use Compass's JSON export."
98
+ ) from exc
99
+ if isinstance(doc, list) and doc:
100
+ doc = doc[0]
101
+ if not isinstance(doc, dict):
102
+ raise ValueError("Expected a single explain JSON document.")
103
+ return doc
104
+
105
+
106
+ def analyze_explain(doc: dict, indexes=None, schema=None) -> ExplainReport:
107
+ qp, es_inner = _find_query_planner(doc)
108
+ es = doc.get("executionStats") or es_inner or {}
109
+ winning = qp.get("winningPlan") or {}
110
+
111
+ stages: List[dict] = []
112
+ _collect_stages(winning, stages)
113
+ chain = [s.get("stage", "?") for s in stages]
114
+
115
+ collscan = any(s.get("stage") == "COLLSCAN" for s in stages)
116
+ blocking_sort = any(s.get("stage") == "SORT" for s in stages)
117
+ fetch = any(s.get("stage") == "FETCH" for s in stages)
118
+
119
+ indexes_used: List[str] = []
120
+ for s in stages:
121
+ if s.get("stage") in ("IXSCAN", "DISTINCT_SCAN", "COUNT_SCAN"):
122
+ name = s.get("indexName") or json.dumps(s.get("keyPattern", {}))
123
+ if name and name not in indexes_used:
124
+ indexes_used.append(name)
125
+
126
+ n_returned = int(es.get("nReturned", 0) or 0)
127
+ keys_examined = int(es.get("totalKeysExamined", 0) or 0)
128
+ docs_examined = int(es.get("totalDocsExamined", 0) or 0)
129
+ exec_ms = int(es.get("executionTimeMillis", 0) or 0)
130
+ rejected = len(qp.get("rejectedPlans") or [])
131
+
132
+ verdicts: List[str] = []
133
+ if collscan:
134
+ verdicts.append(
135
+ f"Full collection scan: examined {docs_examined:,} documents to "
136
+ f"return {n_returned:,}. An index on the filter fields would let "
137
+ "MongoDB skip straight to matching documents."
138
+ )
139
+ if blocking_sort:
140
+ verdicts.append(
141
+ "In-memory (blocking) SORT stage: results are sorted after "
142
+ "retrieval instead of being read from an index in order. Sorts "
143
+ "over 100MB abort the query unless allowDiskUse is set."
144
+ )
145
+ if not collscan and indexes_used:
146
+ if n_returned and docs_examined > 10 * n_returned:
147
+ verdicts.append(
148
+ f"Index used ({', '.join(indexes_used)}) but it is weakly "
149
+ f"selective here: {docs_examined:,} docs fetched for "
150
+ f"{n_returned:,} returned. The index may match the filter "
151
+ "only partially."
152
+ )
153
+ elif keys_examined and n_returned and keys_examined > 10 * n_returned:
154
+ verdicts.append(
155
+ f"Index scanned many more keys ({keys_examined:,}) than "
156
+ f"documents returned ({n_returned:,}); check field order vs "
157
+ "the ESR guideline."
158
+ )
159
+ else:
160
+ verdicts.append(
161
+ f"Index used efficiently ({', '.join(indexes_used)}): "
162
+ f"keysExamined={keys_examined:,}, "
163
+ f"docsExamined={docs_examined:,}, nReturned={n_returned:,}."
164
+ )
165
+ if indexes_used and not fetch and docs_examined == 0 and n_returned:
166
+ verdicts.append(
167
+ "Covered query: answered entirely from the index without touching "
168
+ "documents. This is as good as it gets."
169
+ )
170
+ if rejected:
171
+ verdicts.append(
172
+ f"{rejected} alternative plan(s) were considered and rejected — "
173
+ "if plan choice flaps, look at rejectedPlans in the raw output."
174
+ )
175
+ if not verdicts:
176
+ verdicts.append("No obvious pathology detected in this plan.")
177
+
178
+ # Reuse the advisor for a recommendation when the plan needs help.
179
+ recommendation = None
180
+ command = doc.get("command") if isinstance(doc.get("command"), dict) else {}
181
+ ns = qp.get("namespace") or (command.get("$db", "") + "." +
182
+ str(command.get("find") or
183
+ command.get("aggregate") or "")).strip(".")
184
+ if (collscan or blocking_sort or
185
+ (n_returned and docs_examined > 10 * n_returned)) and command:
186
+ entry = LogEntry(
187
+ ts=None, severity="I", component="COMMAND", msg_id=51803,
188
+ ctx="explain", msg="Slow query",
189
+ attr={
190
+ "ns": ns,
191
+ "command": command,
192
+ "planSummary": "COLLSCAN" if collscan else "IXSCAN",
193
+ "docsExamined": docs_examined,
194
+ "nreturned": n_returned,
195
+ "hasSortStage": blocking_sort,
196
+ "durationMillis": exec_ms,
197
+ },
198
+ )
199
+ agg = QueryAggregator()
200
+ agg.consume(entry)
201
+ recs = advise(agg.results(), indexes=indexes, schema=schema)
202
+ if recs:
203
+ recommendation = recs[0]
204
+
205
+ return ExplainReport(
206
+ ns=ns or "(unknown)",
207
+ stage_chain=chain,
208
+ collscan=collscan,
209
+ blocking_sort=blocking_sort,
210
+ indexes_used=indexes_used,
211
+ n_returned=n_returned,
212
+ keys_examined=keys_examined,
213
+ docs_examined=docs_examined,
214
+ exec_ms=exec_ms,
215
+ rejected_plans=rejected,
216
+ verdicts=verdicts,
217
+ recommendation=recommendation,
218
+ )
219
+
220
+
221
+ def render_explain(report: ExplainReport) -> str:
222
+ parts = ["== mdbkit explain ==", ""]
223
+ parts.append(f"namespace : {report.ns}")
224
+ parts.append(f"plan : {' -> '.join(report.stage_chain) or '(no stages found)'}")
225
+ if report.indexes_used:
226
+ parts.append(f"index(es) : {', '.join(report.indexes_used)}")
227
+ parts.append(
228
+ f"stats : nReturned={report.n_returned:,} "
229
+ f"keysExamined={report.keys_examined:,} "
230
+ f"docsExamined={report.docs_examined:,} "
231
+ f"time={report.exec_ms:,} ms"
232
+ )
233
+ parts.append("")
234
+ for v in report.verdicts:
235
+ parts.append(f"* {v}")
236
+ rec = report.recommendation
237
+ if rec:
238
+ parts.append("")
239
+ parts.append(f"candidate index ({rec.confidence.upper()} confidence): "
240
+ f"{rec.candidate_str()}")
241
+ for c in rec.caveats:
242
+ parts.append(f" caveat: {c}")
243
+ parts.append(f" validate: {rec.validation}")
244
+ return "\n".join(parts)
mdbkit/filtering.py ADDED
@@ -0,0 +1,55 @@
1
+ """`mdbkit filter` — the mlogfilter successor.
2
+
3
+ Streams matching raw logv2 lines to stdout so output stays valid JSON logs
4
+ and can be chained: `mdbkit filter f.log --slow 500 | mdbkit queries -`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from typing import Optional
11
+
12
+ from .parser import LogEntry
13
+
14
+
15
+ class Filter:
16
+ def __init__(
17
+ self,
18
+ component: Optional[str] = None,
19
+ severity: Optional[str] = None,
20
+ namespace: Optional[str] = None,
21
+ slow_ms: Optional[int] = None,
22
+ ts_from: Optional[datetime] = None,
23
+ ts_to: Optional[datetime] = None,
24
+ msg_contains: Optional[str] = None,
25
+ ):
26
+ self.component = component.upper() if component else None
27
+ self.severity = severity.upper() if severity else None
28
+ self.namespace = namespace
29
+ self.slow_ms = slow_ms
30
+ self.ts_from = ts_from
31
+ self.ts_to = ts_to
32
+ self.msg_contains = msg_contains.lower() if msg_contains else None
33
+
34
+ def matches(self, entry: LogEntry) -> bool:
35
+ if self.component and entry.component.upper() != self.component:
36
+ return False
37
+ if self.severity and entry.severity.upper() != self.severity:
38
+ return False
39
+ if self.namespace and entry.attr.get("ns") != self.namespace:
40
+ return False
41
+ if self.slow_ms is not None:
42
+ if int(entry.attr.get("durationMillis", -1) or -1) < self.slow_ms:
43
+ return False
44
+ if self.ts_from and (entry.ts is None or entry.ts < self.ts_from):
45
+ return False
46
+ if self.ts_to and (entry.ts is None or entry.ts > self.ts_to):
47
+ return False
48
+ if self.msg_contains and self.msg_contains not in entry.msg.lower():
49
+ return False
50
+ return True
51
+
52
+
53
+ def parse_when(value: str) -> datetime:
54
+ """Parse a --from/--to value (ISO 8601, 'Z' allowed)."""
55
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
mdbkit/parser.py ADDED
@@ -0,0 +1,149 @@
1
+ """Parser for MongoDB structured JSON logs (logv2, MongoDB 4.4+).
2
+
3
+ Every log line since MongoDB 4.4 is a single JSON document:
4
+
5
+ {"t":{"$date":"..."},"s":"I","c":"COMMAND","id":51803,"ctx":"conn42",
6
+ "msg":"Slow query","attr":{...}}
7
+
8
+ This module parses those lines defensively: real-world logs contain
9
+ truncated lines, interleaved plain-text output, and rotated .gz files.
10
+ Everything here is offline and dependency-free by design.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import gzip
16
+ import io
17
+ import json
18
+ import sys
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime
21
+ from typing import Iterator, Optional
22
+
23
+ # Well-known logv2 message ids we care about.
24
+ ID_SLOW_QUERY = 51803
25
+ ID_CONN_ACCEPTED = 22943
26
+ ID_CONN_ENDED = 22944
27
+ ID_CLIENT_METADATA = 51800
28
+ ID_STARTUP = 4615611
29
+ ID_BUILD_INFO = 23403
30
+
31
+
32
+ @dataclass
33
+ class LogEntry:
34
+ """One parsed logv2 line."""
35
+
36
+ ts: Optional[datetime]
37
+ severity: str
38
+ component: str
39
+ msg_id: int
40
+ ctx: str
41
+ msg: str
42
+ attr: dict = field(default_factory=dict)
43
+ raw: str = ""
44
+
45
+ @property
46
+ def is_slow_query(self) -> bool:
47
+ return self.msg_id == ID_SLOW_QUERY or self.msg == "Slow query"
48
+
49
+
50
+ @dataclass
51
+ class ParseStats:
52
+ """Bookkeeping for how much of the file we understood."""
53
+
54
+ total_lines: int = 0
55
+ parsed: int = 0
56
+ unparsed: int = 0
57
+ first_ts: Optional[datetime] = None
58
+ last_ts: Optional[datetime] = None
59
+
60
+ @property
61
+ def unparsed_ratio(self) -> float:
62
+ return self.unparsed / self.total_lines if self.total_lines else 0.0
63
+
64
+
65
+ def _parse_ts(value) -> Optional[datetime]:
66
+ """Parse a logv2 timestamp: {"$date": "ISO"} or {"$date": {"$numberLong": ms}}."""
67
+ if isinstance(value, dict):
68
+ value = value.get("$date", value)
69
+ if isinstance(value, dict): # {"$numberLong": "..."} (epoch millis)
70
+ millis = value.get("$numberLong")
71
+ if millis is not None:
72
+ try:
73
+ return datetime.fromtimestamp(int(millis) / 1000.0).astimezone()
74
+ except (ValueError, OSError):
75
+ return None
76
+ if isinstance(value, str):
77
+ try:
78
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
79
+ except ValueError:
80
+ return None
81
+ return None
82
+
83
+
84
+ def parse_line(line: str) -> Optional[LogEntry]:
85
+ """Parse one line. Returns None for anything that isn't a logv2 JSON doc."""
86
+ line = line.strip()
87
+ if not line or not line.startswith("{"):
88
+ return None
89
+ try:
90
+ doc = json.loads(line)
91
+ except (json.JSONDecodeError, ValueError):
92
+ return None
93
+ if not isinstance(doc, dict) or "s" not in doc or "c" not in doc:
94
+ return None
95
+ return LogEntry(
96
+ ts=_parse_ts(doc.get("t")),
97
+ severity=str(doc.get("s", "")),
98
+ component=str(doc.get("c", "")),
99
+ msg_id=int(doc.get("id", 0) or 0),
100
+ ctx=str(doc.get("ctx", "")),
101
+ msg=str(doc.get("msg", "")),
102
+ attr=doc.get("attr") or {},
103
+ raw=line,
104
+ )
105
+
106
+
107
+ def open_log(path: str) -> io.TextIOBase:
108
+ """Open a log file, stdin ('-'), or a rotated .gz transparently."""
109
+ if path == "-":
110
+ return sys.stdin
111
+ if path.endswith(".gz"):
112
+ return io.TextIOWrapper(gzip.open(path, "rb"), encoding="utf-8", errors="replace")
113
+ # Sniff gzip magic bytes even without the extension.
114
+ with open(path, "rb") as probe:
115
+ magic = probe.read(2)
116
+ if magic == b"\x1f\x8b":
117
+ return io.TextIOWrapper(gzip.open(path, "rb"), encoding="utf-8", errors="replace")
118
+ return open(path, "r", encoding="utf-8", errors="replace")
119
+
120
+
121
+ def iter_entries(path: str, stats: Optional[ParseStats] = None) -> Iterator[LogEntry]:
122
+ """Stream LogEntry objects from a file, tracking parse stats if given."""
123
+ handle = open_log(path)
124
+ try:
125
+ for line in handle:
126
+ if stats is not None:
127
+ stats.total_lines += 1
128
+ entry = parse_line(line)
129
+ if entry is None:
130
+ if stats is not None and line.strip():
131
+ stats.unparsed += 1
132
+ continue
133
+ if stats is not None:
134
+ stats.parsed += 1
135
+ if entry.ts is not None:
136
+ if stats.first_ts is None:
137
+ stats.first_ts = entry.ts
138
+ stats.last_ts = entry.ts
139
+ yield entry
140
+ finally:
141
+ if handle is not sys.stdin:
142
+ handle.close()
143
+
144
+
145
+ PRE_44_HINT = (
146
+ "Most lines in this file are not structured JSON. This looks like a "
147
+ "pre-4.4 MongoDB log (plain text format). mdbkit targets MongoDB 4.4+ "
148
+ "structured logs; for older logs, the original mtools still works."
149
+ )
mdbkit/render.py ADDED
@@ -0,0 +1,141 @@
1
+ """Plain-text rendering. No third-party dependencies, pipe-friendly output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import List, Sequence
7
+
8
+ from .advisor import Recommendation
9
+ from .analysis import ConnectionReport, LogSummary, ShapeStats
10
+ from .parser import ParseStats
11
+
12
+
13
+ def dump_json(obj) -> str:
14
+ return json.dumps(obj, indent=2, default=str)
15
+
16
+
17
+ def _table(headers: Sequence[str], rows: List[Sequence[str]]) -> str:
18
+ widths = [len(h) for h in headers]
19
+ for row in rows:
20
+ for i, cell in enumerate(row):
21
+ widths[i] = max(widths[i], len(str(cell)))
22
+ fmt = " ".join(f"{{:<{w}}}" for w in widths)
23
+ lines = [fmt.format(*headers), fmt.format(*("-" * w for w in widths))]
24
+ lines += [fmt.format(*(str(c) for c in row)) for row in rows]
25
+ return "\n".join(lines)
26
+
27
+
28
+ def _ms(v: float) -> str:
29
+ if v >= 60_000:
30
+ return f"{v/60_000:.1f}m"
31
+ if v >= 1_000:
32
+ return f"{v/1_000:.1f}s"
33
+ return f"{int(v)}ms"
34
+
35
+
36
+ def render_parse_stats(stats: ParseStats) -> str:
37
+ span = ""
38
+ if stats.first_ts and stats.last_ts:
39
+ span = f" span: {stats.first_ts.isoformat()} -> {stats.last_ts.isoformat()}"
40
+ return (
41
+ f"lines: {stats.total_lines:,} parsed: {stats.parsed:,} "
42
+ f"unparsed: {stats.unparsed:,}{span}"
43
+ )
44
+
45
+
46
+ def render_summary(summary: LogSummary, stats: ParseStats) -> str:
47
+ parts = ["== mdbkit loginfo ==", render_parse_stats(stats), ""]
48
+ parts.append(f"server version(s): {', '.join(summary.versions) or 'not found in log'}")
49
+ if summary.host_info:
50
+ parts.append(f"host: {', '.join(dict.fromkeys(summary.host_info))}")
51
+ parts.append(f"restarts/startups seen: {summary.startups}")
52
+ parts.append(f"connections accepted: {summary.connections_accepted:,}")
53
+ parts.append(
54
+ f"slow queries logged: {summary.slow_queries:,}"
55
+ + (f" (slowest {_ms(summary.slowest_ms)})" if summary.slow_queries else "")
56
+ )
57
+ parts.append(f"warnings: {summary.warnings:,} errors: {summary.errors:,}")
58
+ parts.append("")
59
+ top = summary.component_counts.most_common(8)
60
+ if top:
61
+ parts.append(_table(["component", "lines"], [(c, f"{n:,}") for c, n in top]))
62
+ return "\n".join(parts)
63
+
64
+
65
+ def render_queries(results: List[ShapeStats], stats: ParseStats) -> str:
66
+ parts = ["== mdbkit queries (slow query shapes) ==", render_parse_stats(stats), ""]
67
+ if not results:
68
+ parts.append("No slow queries found. (mongod logs operations exceeding "
69
+ "slowms, default 100 ms; lower slowms or enable profiling "
70
+ "to capture more.)")
71
+ return "\n".join(parts)
72
+ rows = []
73
+ for s in results:
74
+ flags = []
75
+ if s.collscan:
76
+ flags.append("COLLSCAN")
77
+ if s.in_memory_sort:
78
+ flags.append("SORT")
79
+ rows.append((
80
+ s.shape.ns,
81
+ s.shape.operation,
82
+ s.count,
83
+ _ms(s.total_ms),
84
+ _ms(s.mean_ms),
85
+ _ms(s.max_ms),
86
+ f"{s.scan_ratio:.0f}:1" if s.n_returned else "-",
87
+ ",".join(flags) or "-",
88
+ s.shape.pretty()[:70],
89
+ ))
90
+ parts.append(_table(
91
+ ["namespace", "op", "count", "total", "mean", "max", "scan", "flags", "shape"],
92
+ rows,
93
+ ))
94
+ parts.append("")
95
+ parts.append("scan = docsExamined per returned doc. COLLSCAN/SORT flags mean an "
96
+ "index is likely missing; run `mdbkit advise` for candidates.")
97
+ return "\n".join(parts)
98
+
99
+
100
+ def render_connections(report: ConnectionReport, stats: ParseStats) -> str:
101
+ parts = ["== mdbkit connections ==", render_parse_stats(stats), ""]
102
+ d = report.to_dict()
103
+ parts.append(
104
+ f"accepted: {d['totalAccepted']:,} ended: {d['totalEnded']:,} "
105
+ f"peak concurrent (as logged): {d['peakConnectionCount']:,}"
106
+ )
107
+ if d["byIp"]:
108
+ parts.append("")
109
+ parts.append(_table(
110
+ ["source ip", "accepted", "ended"],
111
+ [(r["ip"], r["accepted"], r["ended"]) for r in d["byIp"][:20]],
112
+ ))
113
+ if d["appNames"]:
114
+ parts.append("")
115
+ parts.append(_table(["appName", "handshakes"], list(d["appNames"].items())[:15]))
116
+ return "\n".join(parts)
117
+
118
+
119
+ def render_recommendations(recs: List[Recommendation], stats: ParseStats) -> str:
120
+ parts = ["== mdbkit advise (candidate indexes) ==", render_parse_stats(stats), ""]
121
+ if not recs:
122
+ parts.append("No index candidates: no slow query shapes showed COLLSCAN, "
123
+ "in-memory sorts, or high scan ratios. Good sign — or the "
124
+ "log window is too quiet to judge.")
125
+ return "\n".join(parts)
126
+ for i, rec in enumerate(recs, 1):
127
+ parts.append(f"[{i}] {rec.ns} — confidence: {rec.confidence.upper()}")
128
+ parts.append(f" query shape : {rec.shape}")
129
+ parts.append(f" candidate : {rec.candidate_str()}")
130
+ if rec.covered_by:
131
+ parts.append(f" NOTE : may already be covered by existing "
132
+ f"index '{rec.covered_by}' — investigate before creating")
133
+ for e in rec.evidence:
134
+ parts.append(f" evidence : {e}")
135
+ for c in rec.caveats:
136
+ parts.append(f" caveat : {c}")
137
+ parts.append(f" validate : {rec.validation}")
138
+ parts.append("")
139
+ parts.append("These are CANDIDATES, not commands. Review, test on staging, and "
140
+ "watch write latency and index build impact before production.")
141
+ return "\n".join(parts)
mdbkit/scripts.py ADDED
@@ -0,0 +1,69 @@
1
+ """mongosh export scripts, printed by `mdbkit export-script`.
2
+
3
+ mdbkit never connects to a database. Instead it prints small mongosh
4
+ scripts the operator runs themselves, producing JSON files that can be
5
+ fed back via --schema / --indexes. The operator sees exactly what runs.
6
+ """
7
+
8
+ SCHEMA_SCRIPT = r"""// mdbkit schema export -- run with:
9
+ // mongosh --quiet "mongodb://HOST/DB" this_file.js > schema.json
10
+ // Samples documents per collection to learn field paths and types.
11
+ // Reads only; writes nothing; literal values are NOT exported (types only).
12
+ const SAMPLE = 100; // docs sampled per collection
13
+ const MAX_DEPTH = 3; // nested path depth
14
+ const out = { db: db.getName(), generatedAt: new Date().toISOString(),
15
+ sampleSize: SAMPLE, collections: {} };
16
+
17
+ function typeName(v) {
18
+ if (v === null) return "null";
19
+ if (Array.isArray(v)) return "array";
20
+ if (v instanceof Date) return "date";
21
+ if (v && v._bsontype === "ObjectId") return "objectId";
22
+ if (typeof v === "object") return "object";
23
+ if (typeof v === "number") return Number.isInteger(v) ? "int" : "double";
24
+ return typeof v; // string, boolean -> bool below
25
+ }
26
+
27
+ function record(fields, path, v, depth) {
28
+ let t = typeName(v);
29
+ if (t === "boolean") t = "bool";
30
+ if (!fields[path]) fields[path] = { types: new Set(), count: 0 };
31
+ fields[path].types.add(t);
32
+ fields[path].count += 1;
33
+ if (depth >= MAX_DEPTH) return;
34
+ if (t === "object") {
35
+ for (const k of Object.keys(v)) record(fields, path + "." + k, v[k], depth + 1);
36
+ } else if (t === "array" && v.length > 0 && typeof v[0] === "object"
37
+ && v[0] !== null && !Array.isArray(v[0])) {
38
+ for (const k of Object.keys(v[0])) record(fields, path + "." + k, v[0][k], depth + 1);
39
+ }
40
+ }
41
+
42
+ db.getCollectionNames().filter(c => !c.startsWith("system.")).forEach(coll => {
43
+ const fields = {};
44
+ let n = 0;
45
+ db.getCollection(coll).aggregate([{ $sample: { size: SAMPLE } }]).forEach(doc => {
46
+ n += 1;
47
+ for (const k of Object.keys(doc)) record(fields, k, doc[k], 1);
48
+ });
49
+ const serialized = {};
50
+ for (const [path, info] of Object.entries(fields)) {
51
+ serialized[path] = { types: Array.from(info.types).sort(),
52
+ presence: n ? Math.round(100 * info.count / n) / 100 : 0 };
53
+ }
54
+ out.collections[coll] = { sampleSize: n, fields: serialized };
55
+ });
56
+
57
+ print(JSON.stringify(out));
58
+ """
59
+
60
+ INDEXES_SCRIPT = r"""// mdbkit index export -- run with:
61
+ // mongosh --quiet "mongodb://HOST/DB" this_file.js > indexes.json
62
+ // Exports index metadata only (getIndexes). Reads nothing else.
63
+ const out = { db: db.getName(), generatedAt: new Date().toISOString(),
64
+ collections: {} };
65
+ db.getCollectionNames().filter(c => !c.startsWith("system.")).forEach(coll => {
66
+ out.collections[coll] = db.getCollection(coll).getIndexes();
67
+ });
68
+ print(JSON.stringify(out));
69
+ """