eqp-triage.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. #!/usr/bin/env python3
  2. """Triage a SQLite EXPLAIN QUERY PLAN: classify each plan line and suggest a fix.
  3. Usage: eqp-triage.py [--db FILE --sql SQL | --plan-file FILE | -] [OPTIONS]
  4. Input: argv (--db + --sql, or --plan-file), or a plan on stdin. Accepts raw
  5. sqlite3 CLI text, `sqlite3 -json`, or `wrangler d1 execute --json` output.
  6. Output: stdout - findings, one per line: SEVERITY<TAB>CATEGORY<TAB>DETAIL<TAB>FIX
  7. (or the claude-mods.sqlite-ops.eqp/v1 envelope under --json)
  8. Stderr: headers, progress, warnings, errors
  9. Exit: 0 clean, 2 usage, 3 file-not-found, 4 invalid-input/SQL-error,
  10. 5 missing-dep, 10 findings at or above the reporting threshold
  11. Examples:
  12. eqp-triage.py --db app.db --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'"
  13. sqlite3 app.db 'EXPLAIN QUERY PLAN SELECT * FROM t WHERE a=1;' | eqp-triage.py
  14. wrangler d1 execute mydb --remote --json --command "EXPLAIN QUERY PLAN SELECT ..." | eqp-triage.py
  15. eqp-triage.py --db app.db --sql "SELECT ..." --json | jq '.data[]'
  16. eqp-triage.py --plan-file plan.txt --strict # also fail on low-severity findings
  17. """
  18. import argparse
  19. import json
  20. import os
  21. import re
  22. import sys
  23. SCHEMA = "claude-mods.sqlite-ops.eqp/v1"
  24. EXIT_OK = 0
  25. EXIT_USAGE = 2
  26. EXIT_NOT_FOUND = 3
  27. EXIT_VALIDATION = 4
  28. EXIT_MISSING_DEP = 5
  29. EXIT_FINDINGS = 10
  30. SEVERITY_ORDER = {"info": 0, "low": 1, "medium": 2, "high": 3}
  31. # Rules are evaluated in order; the FIRST match wins, so the most specific
  32. # patterns must come first. In particular COVERING INDEX must be tested before
  33. # the bare "SCAN ... USING INDEX" rule, because a covering scan is acceptable
  34. # while a non-covering one usually means the index is not earning its place.
  35. RULES = [
  36. (
  37. re.compile(r"\bSEARCH\b.*\bUSING COVERING INDEX\b", re.I),
  38. "info", "covering-seek",
  39. "Seek answered entirely from the index - the table is never read. Best case.",
  40. ),
  41. (
  42. re.compile(r"\bSEARCH\b.*\bUSING INTEGER PRIMARY KEY\b", re.I),
  43. "info", "rowid-seek",
  44. "Direct rowid lookup. Best case.",
  45. ),
  46. (
  47. re.compile(r"\bSEARCH\b.*\bUSING (?:INDEX|AUTOMATIC)\b", re.I),
  48. "info", "index-seek",
  49. "B-tree seek. Fine. Consider covering the projected columns if the row is wide.",
  50. ),
  51. (
  52. re.compile(r"\bSCAN\b.*\bUSING COVERING INDEX\b", re.I),
  53. "low", "covering-scan",
  54. "Full pass over narrow index entries, table never read. Often the right answer "
  55. "for an unseekable predicate; reduces latency but usually NOT rows-read.",
  56. ),
  57. (
  58. re.compile(r"\bSCAN\b.*\bUSING (?:INDEX|AUTOMATIC (?:COVERING )?INDEX)\b", re.I),
  59. "high", "noncovering-scan",
  60. "Every index entry read AND a table row fetched per hit - the index buys little. "
  61. "Extend it to cover the projected columns (filtered column first, projected "
  62. "second), or drop it.",
  63. ),
  64. (
  65. re.compile(r"\bCORRELATED\b", re.I),
  66. "high", "correlated-subquery",
  67. "Subquery re-executed once per outer row. Rewrite as a JOIN or a windowed "
  68. "aggregate.",
  69. ),
  70. (
  71. re.compile(r"\bSCAN\b", re.I),
  72. "high", "table-scan",
  73. "Full table scan. Add an index matching the WHERE/JOIN, or - if the predicate "
  74. "cannot be seeked (leading-wildcard LIKE, function on the column) - make the scan "
  75. "covering or move to FTS5 trigram.",
  76. ),
  77. (
  78. re.compile(r"\bUSE TEMP B-TREE FOR (?:RIGHT PART OF )?ORDER BY\b", re.I),
  79. "medium", "temp-btree-order",
  80. "Sorting because no index supplies the order. A composite index ending in the "
  81. "sort column removes it. Re-check AFTER any index change - it often clears itself.",
  82. ),
  83. (
  84. re.compile(r"\bUSE TEMP B-TREE FOR GROUP BY\b", re.I),
  85. "medium", "temp-btree-group",
  86. "Grouping without an index supplying the order. Re-check AFTER any index change - "
  87. "adding a covering index frequently removes this on its own.",
  88. ),
  89. (
  90. re.compile(r"\bUSE TEMP B-TREE FOR DISTINCT\b", re.I),
  91. "medium", "temp-btree-distinct",
  92. "De-duplicating in a temp B-tree. An index covering the DISTINCT columns removes it.",
  93. ),
  94. (
  95. re.compile(r"\bUSE TEMP B-TREE\b", re.I),
  96. "medium", "temp-btree",
  97. "A temporary B-tree is being built. Check which clause needs it and whether an "
  98. "index can supply that order.",
  99. ),
  100. ]
  101. # Informational plan lines that are never findings on their own.
  102. BENIGN = re.compile(
  103. r"^\s*(QUERY PLAN|MULTI-INDEX OR|INDEX \d+|BLOOM FILTER|MATERIALIZE|CO-ROUTINE|"
  104. r"LIST SUBQUERY|SCALAR SUBQUERY|USING (?:ROWID SEARCH|INDEX FOR)|RIGHT-JOIN|"
  105. r"MERGE|LEFT-JOIN|COMPOUND QUERY|UNION|EXCEPT|INTERSECT|RECURSIVE)",
  106. re.I,
  107. )
  108. # Strip sqlite3's tree drawing and the legacy "0|0|0|" column prefix.
  109. TREE_PREFIX = re.compile(r"^[\s|`+\-]*")
  110. LEGACY_PREFIX = re.compile(r"^\d+\|\d+\|\d+\|")
  111. # Vocabulary a genuine EQP line uses. Text input is filtered against this so
  112. # that arbitrary text (a stray log, the wrong command's output) is reported as
  113. # invalid input rather than silently triaged as "clean" - a false all-clear is
  114. # the worst possible outcome for a tool whose job is finding problems.
  115. PLAN_VOCAB = re.compile(
  116. r"\b(SCAN|SEARCH|USE TEMP B-TREE|CO-ROUTINE|SUBQUERY|MATERIALIZE|"
  117. r"MULTI-INDEX OR|BLOOM FILTER|COMPOUND QUERY|UNION|EXCEPT|INTERSECT|"
  118. r"RECURSIVE|MERGE|LEFT-JOIN|RIGHT-JOIN|USING (?:INDEX|COVERING|ROWID|"
  119. r"INTEGER PRIMARY KEY)|CORRELATED)\b",
  120. re.I,
  121. )
  122. def warn(message):
  123. """Human-facing output goes to stderr; stdout stays a clean data stream."""
  124. print(message, file=sys.stderr)
  125. def collect_details(node, out):
  126. """Recursively pull every 'detail' string out of decoded JSON.
  127. Handles both `sqlite3 -json` ([{detail: ...}]) and wrangler's
  128. [{results: [{detail: ...}], meta: {...}}] shape without special-casing either.
  129. """
  130. if isinstance(node, dict):
  131. detail = node.get("detail")
  132. if isinstance(detail, str):
  133. out.append(detail)
  134. for value in node.values():
  135. collect_details(value, out)
  136. elif isinstance(node, list):
  137. for value in node:
  138. collect_details(value, out)
  139. def parse_plan(text):
  140. """Return a list of plan detail strings from JSON or raw sqlite3 CLI text."""
  141. stripped = text.strip()
  142. if not stripped:
  143. return []
  144. if stripped[0] in "[{":
  145. try:
  146. details = []
  147. collect_details(json.loads(stripped), details)
  148. if details:
  149. return details
  150. except (ValueError, RecursionError):
  151. pass # not JSON after all - fall through to text parsing
  152. lines = []
  153. for raw in stripped.splitlines():
  154. line = LEGACY_PREFIX.sub("", raw.strip())
  155. line = TREE_PREFIX.sub("", line).strip()
  156. if not line or line.upper() == "QUERY PLAN":
  157. continue
  158. if not PLAN_VOCAB.search(line):
  159. continue # not a plan line - see PLAN_VOCAB
  160. lines.append(line)
  161. return lines
  162. def classify(detail):
  163. """Return (severity, category, fix) for one plan line, or None if benign."""
  164. for pattern, severity, category, fix in RULES:
  165. if pattern.search(detail):
  166. return severity, category, fix
  167. if BENIGN.search(detail):
  168. return None
  169. return None
  170. def run_plan(db_path, sql):
  171. """Run EXPLAIN QUERY PLAN against a database using Python's bundled sqlite3."""
  172. try:
  173. import sqlite3
  174. except ImportError: # pragma: no cover - stdlib module absent is a broken build
  175. warn("error: Python's sqlite3 module is unavailable in this interpreter")
  176. sys.exit(EXIT_MISSING_DEP)
  177. if not os.path.isfile(db_path):
  178. warn("error: database not found: %s" % db_path)
  179. sys.exit(EXIT_NOT_FOUND)
  180. # Read-only URI: this script must never be able to modify the database it
  181. # is asked to analyse, even if handed a statement with side effects.
  182. uri = "file:%s?mode=ro" % db_path.replace("?", "%3f").replace("#", "%23")
  183. try:
  184. conn = sqlite3.connect(uri, uri=True)
  185. except sqlite3.Error as exc:
  186. warn("error: cannot open database: %s" % exc)
  187. sys.exit(EXIT_VALIDATION)
  188. try:
  189. rows = conn.execute("EXPLAIN QUERY PLAN " + sql).fetchall()
  190. except sqlite3.Error as exc:
  191. warn("error: %s" % exc)
  192. sys.exit(EXIT_VALIDATION)
  193. finally:
  194. conn.close()
  195. # EQP rows are (id, parent, notused, detail); detail is always last.
  196. return [str(row[-1]) for row in rows]
  197. def main(argv=None):
  198. parser = argparse.ArgumentParser(
  199. prog="eqp-triage.py",
  200. description="Triage a SQLite EXPLAIN QUERY PLAN and suggest fixes.",
  201. epilog=(
  202. "EXAMPLES:\n"
  203. " eqp-triage.py --db app.db --sql \"SELECT * FROM t WHERE a LIKE '%x%'\"\n"
  204. " sqlite3 app.db 'EXPLAIN QUERY PLAN SELECT * FROM t;' | eqp-triage.py\n"
  205. " wrangler d1 execute db --remote --json --command \"EXPLAIN QUERY PLAN "
  206. "SELECT ...\" | eqp-triage.py\n"
  207. " eqp-triage.py --db app.db --sql 'SELECT ...' --json | jq '.data[]'\n"
  208. ),
  209. formatter_class=argparse.RawDescriptionHelpFormatter,
  210. )
  211. parser.add_argument("stdin_marker", nargs="?", default=None,
  212. help="'-' to read the plan from stdin (the default when piped)")
  213. parser.add_argument("--db", help="SQLite database file to run the plan against")
  214. parser.add_argument("--sql", help="Statement to explain (requires --db)")
  215. parser.add_argument("--plan-file", help="File containing captured plan output")
  216. parser.add_argument("--json", action="store_true",
  217. help="Emit the claude-mods.sqlite-ops.eqp/v1 envelope on stdout")
  218. parser.add_argument("--strict", action="store_true",
  219. help="Exit 10 on low-severity findings too (default: medium+)")
  220. parser.add_argument("--quiet", action="store_true",
  221. help="Suppress stderr headers; findings still go to stdout")
  222. args, extra = parser.parse_known_args(argv)
  223. if extra:
  224. parser.print_usage(sys.stderr)
  225. warn("error: unrecognised arguments: %s" % " ".join(extra))
  226. return EXIT_USAGE
  227. if args.stdin_marker not in (None, "-"):
  228. parser.print_usage(sys.stderr)
  229. warn("error: unexpected positional argument: %s" % args.stdin_marker)
  230. return EXIT_USAGE
  231. if args.sql and not args.db:
  232. warn("error: --sql requires --db")
  233. return EXIT_USAGE
  234. if args.db and not args.sql:
  235. warn("error: --db requires --sql")
  236. return EXIT_USAGE
  237. if args.db and args.plan_file:
  238. warn("error: --db/--sql and --plan-file are mutually exclusive")
  239. return EXIT_USAGE
  240. # --- acquire the plan ---
  241. source = None
  242. if args.db:
  243. details = run_plan(args.db, args.sql)
  244. source = args.db
  245. elif args.plan_file:
  246. if not os.path.isfile(args.plan_file):
  247. warn("error: plan file not found: %s" % args.plan_file)
  248. return EXIT_NOT_FOUND
  249. with open(args.plan_file, "r", encoding="utf-8", errors="replace") as handle:
  250. details = parse_plan(handle.read())
  251. source = args.plan_file
  252. else:
  253. if sys.stdin is None or sys.stdin.isatty():
  254. parser.print_usage(sys.stderr)
  255. warn("error: no input - pass --db/--sql, --plan-file, or pipe a plan on stdin")
  256. return EXIT_USAGE
  257. details = parse_plan(sys.stdin.read())
  258. source = "stdin"
  259. if not details:
  260. warn("error: no EXPLAIN QUERY PLAN lines found in input from %s" % source)
  261. return EXIT_VALIDATION
  262. # --- classify ---
  263. findings = []
  264. for detail in details:
  265. verdict = classify(detail)
  266. if verdict is None:
  267. continue
  268. severity, category, fix = verdict
  269. findings.append({
  270. "severity": severity,
  271. "category": category,
  272. "detail": detail,
  273. "fix": fix,
  274. })
  275. findings.sort(key=lambda f: -SEVERITY_ORDER[f["severity"]])
  276. threshold = SEVERITY_ORDER["low" if args.strict else "medium"]
  277. actionable = [f for f in findings if SEVERITY_ORDER[f["severity"]] >= threshold]
  278. # --- report ---
  279. if args.json:
  280. print(json.dumps({
  281. "data": findings,
  282. "meta": {
  283. "count": len(findings),
  284. "actionable": len(actionable),
  285. "plan_lines": len(details),
  286. "source": source,
  287. "threshold": "low" if args.strict else "medium",
  288. "schema": SCHEMA,
  289. },
  290. }, indent=2))
  291. else:
  292. if not args.quiet:
  293. warn("eqp-triage %d plan line(s) from %s" % (len(details), source))
  294. for finding in findings:
  295. print("%s\t%s\t%s\t%s" % (
  296. finding["severity"].upper(), finding["category"],
  297. finding["detail"], finding["fix"]))
  298. if not args.quiet:
  299. if actionable:
  300. warn(" %d actionable finding(s) at or above %s severity"
  301. % (len(actionable), "low" if args.strict else "medium"))
  302. else:
  303. warn(" no actionable findings")
  304. return EXIT_FINDINGS if actionable else EXIT_OK
  305. if __name__ == "__main__":
  306. sys.exit(main())