phy-db-index-advisor
Static analysis tool that reads your ORM query patterns and predicts which database columns are missing indexes — before a slow query alert fires in production. Works by counting how often each column appears in .filter(), .where(), .order_by(), and JOIN conditions across your entire codebase, then cross-referencing model definitions to suppress columns already indexed.
Why This Exists
- 80% of production slow queries stem from missing indexes on columns used in WHERE clauses
User.objects.filter(email=email) running 1,000× per minute causes full table scans
- Existing linters don't know your query patterns;
EXPLAIN ANALYZE only catches issues after the fact
- This skill finds them before deployment
What It Detects
Query Patterns Scanned
| Access Pattern |
Why It Matters |
| WHERE / filter() |
Full table scan without index — O(n) per query |
| ORDER BY / order_by() |
Sort without index reads all rows then sorts in memory |
| JOIN ON column |
Nested-loop join without index is O(n²) |
| UNIQUE constraint candidates |
Columns with unique=True queries need unique indexes |
Supported ORMs
| ORM |
Language |
Patterns Detected |
| Django ORM |
Python |
.filter(col=), .get(col=), .exclude(col=), .order_by('col'), Meta.ordering |
| SQLAlchemy |
Python |
.filter(Model.col ==), .filter_by(col=), .order_by(col), join(Model, on=) |
| Peewee |
Python |
.where(Model.col ==), .order_by(Model.col) |
| TypeORM |
TypeScript |
.where("t.col = :val"), findBy({col:}), .orderBy("t.col"), @JoinColumn({name: 'col'}) |
| Prisma |
TypeScript |
where: { col: }, orderBy: { col: }, include: { relation: } |
| Sequelize |
TypeScript/JS |
where: { col: }, order: [['col', 'ASC']] |
| GORM |
Go |
.Where("col = ?"), .Order("col"), .Joins("JOIN ... ON col") |
| ActiveRecord |
Ruby |
.where(col:), .find_by(col:), .order(:col), .joins() |
Existing Index Detection (Suppression)
The scanner reads existing index definitions so it doesn't recommend indexes that already exist:
| ORM |
Where Indexes Are Found |
| Django |
db_index=True on field, Meta.indexes, Meta.unique_together |
| SQLAlchemy |
Column(index=True), Column(unique=True), Index(...) objects |
| TypeORM |
@Index() decorator, @Column({index: true}), @Unique() |
| Prisma |
@@index([col]), @@unique([col]), @unique on field |
| GORM |
gorm:"index", gorm:"uniqueIndex" struct tags |
| ActiveRecord |
add_index in migrations, index: true in column definition |
| SQL migrations |
CREATE INDEX, CREATE UNIQUE INDEX statements |
Implementation
#!/usr/bin/env python3
"""
phy-db-index-advisor — ORM query pattern analyzer for missing indexes
Usage: python3 advisor.py [path] [--json] [--min-count N]
"""
import argparse
import json
import os
import re
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
# ─── Data structures ─────────────────────────────────────────────────────────
@dataclass
class QueryHit:
file: str
line: int
pattern: str
orm: str
access_type: str # WHERE, ORDER_BY, JOIN
@dataclass
class ColumnReport:
table_hint: str # Guessed model/table name
column: str
where_count: int = 0
order_count: int = 0
join_count: int = 0
files: set = field(default_factory=set)
hits: list = field(default_factory=list)
already_indexed: bool = False
@property
def total_count(self) -> int:
return self.where_count + self.order_count + self.join_count
@property
def priority(self) -> str:
if self.already_indexed:
return "INDEXED"
if self.where_count >= 10 or self.total_count >= 15:
return "CRITICAL"
if self.where_count >= 5 or self.total_count >= 8:
return "HIGH"
if self.total_count >= 3:
return "MEDIUM"
return "LOW"
# ─── Query pattern registry ───────────────────────────────────────────────────
# (orm_name, access_type, regex, model_group_idx, col_group_idx)
QUERY_PATTERNS = [
# ── Django ORM ──
("Django", "WHERE",
re.compile(r'\.(?:filter|get|exclude|count|exists)\s*\([^)]*?(\w+)__?\w*\s*='),
None, 1),
("Django", "WHERE",
re.compile(r'\.(?:filter|get|exclude)\s*\(\s*(\w+)\s*='),
None, 1),
("Django", "ORDER_BY",
re.compile(r'\.order_by\s*\(\s*['"-](\w+)['"]\s*\)'),
None, 1),
("Django", "ORDER_BY",
re.compile(r'ordering\s*=\s*\[[^\]]*?['"](\w+)['"]'),
None, 1),
# ── SQLAlchemy ──
("SQLAlchemy", "WHERE",
re.compile(r'\.filter\s*\(\s*(\w+)\.(\w+)\s*=='),
1, 2),
("SQLAlchemy", "WHERE",
re.compile(r'\.filter_by\s*\([^)]*?(\w+)\s*='),
None, 1),
("SQLAlchemy", "ORDER_BY",
re.compile(r'\.order_by\s*\(\s*(\w+)\.(\w+)'),
1, 2),
("SQLAlchemy", "ORDER_BY",
re.compile(r'\.order_by\s*\(\s*(?:asc|desc)\s*\(\s*(\w+)\.(\w+)'),
1, 2),
# ── TypeORM ──
("TypeORM", "WHERE",
re.compile(r'where\s*:\s*\{[^}]*?(\w+)\s*:'),
None, 1),
("TypeORM", "WHERE",
re.compile(r'\.where\s*\(\s*['"`](?:\w+\.)?(\w+)\s*(?:=|LIKE|IN|>|<)'),
None, 1),
("TypeORM", "ORDER_BY",
re.compile(r'\.orderBy\s*\(\s*['"`](?:\w+\.)?(\w+)['"`]'),
None, 1),
("TypeORM", "ORDER_BY",
re.compile(r'orderBy\s*:\s*\{[^}]*?(\w+)\s*:'),
None, 1),
# ── Prisma ──
("Prisma", "WHERE",
re.compile(r'where\s*:\s*\{[^}]*?(\w+)\s*:'),
None, 1),
("Prisma", "ORDER_BY",
re.compile(r'orderBy\s*:\s*\{[^}]*?(\w+)\s*:'),
None, 1),
# ── Sequelize ──
("Sequelize", "WHERE",
re.compile(r'where\s*:\s*\{[^}]*?(\w+)\s*:'),
None, 1),
("Sequelize", "ORDER_BY",
re.compile(r'order\s*:\s*\[\s*\[\s*['"`](\w+)['"`]'),
None, 1),
# ── GORM ──
("GORM", "WHERE",
re.compile(r'\.(?:Where|Find|First|Last)\s*\([^,)]*?['"`](?:\w+\.)?(\w+)\s*(?:=|LIKE|IN|>|<|\?)'),
None, 1),
("GORM", "ORDER_BY",
re.compile(r'\.Order\s*\(\s*['"`](\w+)'),
None, 1),
("GORM", "JOIN",
re.compile(r'\.Joins\s*\([^)]*?ON\s+\w+\.(\w+)\s*=\s*\w+\.(\w+)'),
None, 1),
# ── ActiveRecord (Ruby) ──
("ActiveRecord", "WHERE",
re.compile(r'\.where\s*\(\s*(\w+):\s*'),
None, 1),
("ActiveRecord", "WHERE",
re.compile(r'\.find_by\s*\(\s*(\w+):\s*'),
None, 1),
("ActiveRecord", "ORDER_BY",
re.compile(r'\.order\s*\(\s*:(\w+)\s*\)'),
None, 1),
("ActiveRecord", "ORDER_BY",
re.compile(r'\.order\s*\(\s*['"](\w+)'),
None, 1),
]
# ─── Existing index detection ─────────────────────────────────────────────────
EXISTING_INDEX_PATTERNS = [
# Django
re.compile(r'(\w+)\s*=\s*\w+Field\s*\([^)]*\bdb_index\s*=\s*True'),
re.compile(r'(\w+)\s*=\s*\w+Field\s*\([^)]*\bunique\s*=\s*True'),
re.compile(r'models\.Index\s*\(\s*fields\s*=\s*\[([^\]]+)\]'),
# SQLAlchemy
re.compile(r'Column\s*\([^)]*\bindex\s*=\s*True[^)]*\).*?#.*?(\w+)'),
re.compile(r'(\w+)\s*=\s*Column\s*\([^)]*\bindex\s*=\s*True'),
re.compile(r'(\w+)\s*=\s*Column\s*\([^)]*\bunique\s*=\s*True'),
re.compile(r'Index\s*\(\s*['"`]\w+['"`]\s*,\s*\w+\.(\w+)'),
# TypeORM
re.compile(r'@(?:Index|Unique|Column)\s*\([^)]*\bindex\s*:\s*true'),
re.compile(r'@Column\s*\([^)]*\bunique\s*:\s*true[^)]*\)\s*\w+\s*:\s*\w+\s*(\w+)'),
# Prisma
re.compile(r'@@index\s*\(\s*\[([^\]]+)\]'),
re.compile(r'@@unique\s*\(\s*\[([^\]]+)\]'),
re.compile(r'(\w+)\s+\w+\s+@unique'),
# GORM
re.compile(r'(\w+)\s+\w+\s+`[^`]*gorm:"[^"]*(?:index|uniqueIndex)[^"]*"`'),
# SQL migrations
re.compile(r'CREATE\s+(?:UNIQUE\s+)?INDEX\s+\w+\s+ON\s+\w+\s*\(([^)]+)\)', re.IGNORECASE),
re.compile(r'add_index\s+:\w+\s*,\s*:(\w+)'), # ActiveRecord migration
]
# Columns to always skip (noise)
SKIP_COLUMNS = {
"id", "pk", "uuid", "created_at", "updated_at", "deleted_at",
"created_by", "updated_by", "None", "null", "true", "false",
"True", "False", "self", "cls", "this", "kwargs", "args",
}
SKIP_DIRS = {".git", "node_modules", "vendor", "__pycache__", ".venv", "venv",
"dist", "build", "target", "migrations", "alembic"}
FILE_EXTS = {".py", ".ts", ".js", ".rb", ".go"}
def collect_existing_indexes(root: Path) -> set[str]:
"""Return set of column names already indexed."""
indexed = set()
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fname in filenames:
fpath = Path(dirpath) / fname
if fpath.suffix.lower() not in FILE_EXTS | {".sql", ".rb"}:
continue
try:
text = fpath.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for pat in EXISTING_INDEX_PATTERNS:
for m in pat.finditer(text):
for grp in m.groups():
if grp:
for col in re.split(r'[\s,'"`]+', grp):
col = col.strip().strip('"'`')
if col:
indexed.add(col.lower())
return indexed
def scan_queries(root: Path) -> dict[str, ColumnReport]:
"""Scan all source files and collect query patterns per column."""
col_reports: dict[str, ColumnReport] = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fname in filenames:
fpath = Path(dirpath) / fname
if fpath.suffix.lower() not in FILE_EXTS:
continue
# Skip test files
if any(x in fname.lower() for x in ("test", "spec", "mock", "fixture")):
continue
try:
lines = fpath.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
full_text = "\n".join(lines)
rel_path = os.path.relpath(str(fpath))
for (orm, access_type, pat, model_grp, col_grp) in QUERY_PATTERNS:
for m in pat.finditer(full_text):
try:
col = m.group(col_grp)
except IndexError:
continue
if not col or col.lower() in SKIP_COLUMNS:
continue
if len(col) < 2 or not col.replace("_", "").isalpha():
continue
model = None
if model_grp:
try:
model = m.group(model_grp)
except IndexError:
pass
lineno = full_text[:m.start()].count("\n") + 1
key = col.lower()
if key not in col_reports:
col_reports[key] = ColumnReport(
table_hint=model or "",
column=col,
)
report = col_reports[key]
if model and not report.table_hint:
report.table_hint = model
report.files.add(rel_path)
report.hits.append(QueryHit(rel_path, lineno, m.group(0)[:80], orm, access_type))
if access_type == "WHERE":
report.where_count += 1
elif access_type == "ORDER_BY":
report.order_count += 1
elif access_type == "JOIN":
report.join_count += 1
return col_reports
def format_report(reports: list[ColumnReport], existing_indexes: set[str]) -> str:
# Mark already-indexed
for r in reports:
if r.column.lower() in existing_indexes:
r.already_indexed = True
# Filter to only non-indexed, min 3 total hits
actionable = [r for r in reports if not r.already_indexed and r.total_count >= 3]
actionable.sort(key=lambda x: x.total_count, reverse=True)
priority_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
actionable.sort(key=lambda x: priority_order.get(x.priority, 4))
already_indexed = [r for r in reports if r.already_indexed and r.total_count >= 3]
lines = [
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
" DB INDEX ADVISOR — Missing Index Analysis",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
f" Columns queried: {len(reports)}",
f" Missing indexes: {len(actionable)} ({sum(1 for r in actionable if r.priority=='CRITICAL')} CRITICAL)",
f" Already indexed: {len(already_indexed)} (suppressed)",
"",
]
icons = {"CRITICAL": "🔴", "HIGH": "🟠", "MEDIUM": "🟡", "LOW": "⚪"}
current_priority = None
for r in actionable:
p = r.priority
if p != current_priority:
current_priority = p
lines.append(f"\n{icons.get(p, '⚪')} {p}")
lines.append("")
table = r.table_hint or "?"
breakdown = []
if r.where_count:
breakdown.append(f"WHERE×{r.where_count}")
if r.order_count:
breakdown.append(f"ORDER_BY×{r.order_count}")
if r.join_count:
breakdown.append(f"JOIN×{r.join_count}")
# Show top 3 call sites
sample_files = sorted(r.files)[:3]
samples_str = ", ".join(sample_files)
if len(r.files) > 3:
samples_str += f" (+{len(r.files)-3} more)"
lines += [
f" {table}.{r.column} [{' | '.join(breakdown)}] across {len(r.files)} file(s)",
f" Files: {samples_str}",
f" SQL: CREATE INDEX idx_{table.lower()}_{r.column.lower()} ON {table.lower()} ({r.column});",
f" Django: {r.column} = models.{r.column.title()}Field(..., db_index=True)",
f" SQLAlchemy: {r.column} = Column(String, index=True)",
f" Prisma: @@index([{r.column}])",
"",
]
if not actionable:
lines.append(" ✅ No missing indexes detected (all queried columns are already indexed)")
lines.append("")
if already_indexed:
lines.append(f" ✅ Already indexed ({len(already_indexed)} columns): "
+ ", ".join(r.column for r in already_indexed[:8])
+ ("..." if len(already_indexed) > 8 else ""))
lines.append("")
critical_count = sum(1 for r in actionable if r.priority == "CRITICAL")
lines += [
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
f" CI gate: {'exit 1 — missing critical indexes' if critical_count else 'exit 0'}",
" Runtime verification: EXPLAIN ANALYZE your most frequent queries",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
]
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="DB index advisor — finds missing indexes from ORM patterns")
parser.add_argument("path", nargs="?", default=".", help="Root directory to scan")
parser.add_argument("--json", action="store_true", help="JSON output")
parser.add_argument("--min-count", type=int, default=3,
help="Minimum query count to report (default: 3)")
parser.add_argument("--ci", action="store_true", help="Exit 1 if CRITICAL indexes missing")
args = parser.parse_args()
root = Path(args.path).resolve()
existing_indexes = collect_existing_indexes(root)
col_reports_dict = scan_queries(root)
reports = list(col_reports_dict.values())
for r in reports:
if r.column.lower() in existing_indexes:
r.already_indexed = True
actionable = sorted(
[r for r in reports if not r.already_indexed and r.total_count >= args.min_count],
key=lambda x: x.total_count,
reverse=True,
)
if args.json:
import dataclasses
output = []
for r in actionable:
d = dataclasses.asdict(r)
d["priority"] = r.priority
d["total_count"] = r.total_count
d["files"] = list(r.files)
output.append(d)
print(json.dumps(output, indent=2))
else:
print(format_report(reports, existing_indexes))
if args.ci:
has_critical = any(r.priority == "CRITICAL" for r in actionable)
sys.exit(1 if has_critical else 0)
if __name__ == "__main__":
main()
Usage
# Scan current project
python3 advisor.py
# Scan a specific path
python3 advisor.py ~/projects/myapp
# Only show columns queried 5+ times
python3 advisor.py --min-count 5
# CI fail-gate (exits 1 if CRITICAL missing indexes found)
python3 advisor.py --ci
# JSON output for dashboard/ticketing
python3 advisor.py --json | jq '[.[] | select(.priority == "CRITICAL")]'
# GitHub Actions
- name: DB Index Advisor
run: python3 .claude/skills/phy-db-index-advisor/advisor.py --ci
Sample Output
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DB INDEX ADVISOR — Missing Index Analysis
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Columns queried: 24
Missing indexes: 6 (2 CRITICAL)
Already indexed: 8 (suppressed)
🔴 CRITICAL
User.email [WHERE×28] across 7 file(s)
Files: api/auth.py, api/users.py, services/notifications.py (+4 more)
SQL: CREATE INDEX idx_user_email ON user (email);
Django: email = models.EmailField(..., db_index=True)
SQLAlchemy: email = Column(String, index=True)
Prisma: @@index([email])
Order.user_id [WHERE×19 | JOIN×6] across 5 file(s)
Files: api/orders.py, services/billing.py, reports/revenue.py (+2 more)
SQL: CREATE INDEX idx_order_user_id ON order (user_id);
Django: user_id = models.ForeignKey(..., db_index=True)
SQLAlchemy: user_id = Column(Integer, ForeignKey('user.id'), index=True)
Prisma: @@index([userId])
🟠 HIGH
Product.category_id [WHERE×12 | ORDER_BY×4] across 4 file(s)
SQL: CREATE INDEX idx_product_category_id ON product (category_id);
Session.token [WHERE×9] across 3 file(s)
SQL: CREATE INDEX idx_session_token ON session (token);
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CI gate: exit 1 — missing critical indexes
Runtime verification: EXPLAIN ANALYZE your most frequent queries
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Relationship to phy-sql-explainer
| Skill |
Input |
Output |
When to Use |
| phy-db-index-advisor |
Source code (ORM patterns) |
Missing index recommendations |
Pre-deployment: catch before slow queries appear |
| phy-sql-explainer |
EXPLAIN ANALYZE output |
Query plan diagnosis |
Post-deployment: diagnose an existing slow query |
Use both: this skill prevents index gaps from being deployed; phy-sql-explainer diagnoses what got through anyway.
Limitations & False Positives
- Dynamic columns:
.filter(**kwargs) cannot be statically analyzed — run with --min-count 5 to focus on confirmed hot paths
- FK indexes: Most databases (PostgreSQL, MySQL) do NOT automatically index foreign keys — this skill will flag them correctly
- Primary keys:
id is always excluded (databases auto-index primary keys)
- Composite indexes: When two columns always appear together in WHERE, a composite index may outperform two single-column indexes — manual review recommended
Companion Skills
| Skill |
Use Together For |
phy-sql-explainer |
Pre + post deployment DB performance sweep |
phy-db-migration-auditor |
Safe migration before applying index additions |
phy-concurrency-audit |
Race conditions + missing indexes both cause data integrity failures |
1---2name: phy-db-index-advisor3description: Database index advisor that statically analyzes ORM query patterns to predict missing indexes before they become production bottlenecks. Scans SQLAlchemy, Django ORM, TypeORM, Prisma, GORM, ActiveRecord, and Sequelize code for columns used in WHERE/filter, ORDER BY, and JOIN conditions. Cross-references existing model index definitions and migration files to suppress already-indexed columns. Ranks recommendations by query frequency and outputs ready-to-run CREATE INDEX SQL + per-ORM migration snippets. Zero competitors on ClawHub — not a single db-index-advisor SKILL.md in 13,700+ files.4license: Apache-2.05---67# phy-db-index-advisor89Static analysis tool that reads your **ORM query patterns** and predicts which database columns are missing indexes — before a slow query alert fires in production. Works by counting how often each column appears in `.filter()`, `.where()`, `.order_by()`, and JOIN conditions across your entire codebase, then cross-referencing model definitions to suppress columns already indexed.1011## Why This Exists1213- 80% of production slow queries stem from missing indexes on columns used in WHERE clauses14- `User.objects.filter(email=email)` running 1,000× per minute causes full table scans15- Existing linters don't know your query patterns; `EXPLAIN ANALYZE` only catches issues after the fact16- This skill finds them **before deployment**1718## What It Detects1920### Query Patterns Scanned21| Access Pattern | Why It Matters |22|---------------|----------------|23| **WHERE / filter()** | Full table scan without index — O(n) per query |24| **ORDER BY / order_by()** | Sort without index reads all rows then sorts in memory |25| **JOIN ON column** | Nested-loop join without index is O(n²) |26| **UNIQUE constraint candidates** | Columns with `unique=True` queries need unique indexes |2728### Supported ORMs29| ORM | Language | Patterns Detected |30|-----|----------|-------------------|31| **Django ORM** | Python | `.filter(col=)`, `.get(col=)`, `.exclude(col=)`, `.order_by('col')`, `Meta.ordering` |32| **SQLAlchemy** | Python | `.filter(Model.col ==)`, `.filter_by(col=)`, `.order_by(col)`, `join(Model, on=)` |33| **Peewee** | Python | `.where(Model.col ==)`, `.order_by(Model.col)` |34| **TypeORM** | TypeScript | `.where("t.col = :val")`, `findBy({col:})`, `.orderBy("t.col")`, `@JoinColumn({name: 'col'})` |35| **Prisma** | TypeScript | `where: { col: }`, `orderBy: { col: }`, `include: { relation: }` |36| **Sequelize** | TypeScript/JS | `where: { col: }`, `order: [['col', 'ASC']]` |37| **GORM** | Go | `.Where("col = ?")`, `.Order("col")`, `.Joins("JOIN ... ON col")` |38| **ActiveRecord** | Ruby | `.where(col:)`, `.find_by(col:)`, `.order(:col)`, `.joins()` |3940### Existing Index Detection (Suppression)41The scanner reads existing index definitions so it doesn't recommend indexes that already exist:4243| ORM | Where Indexes Are Found |44|-----|------------------------|45| Django | `db_index=True` on field, `Meta.indexes`, `Meta.unique_together` |46| SQLAlchemy | `Column(index=True)`, `Column(unique=True)`, `Index(...)` objects |47| TypeORM | `@Index()` decorator, `@Column({index: true})`, `@Unique()` |48| Prisma | `@@index([col])`, `@@unique([col])`, `@unique` on field |49| GORM | `gorm:"index"`, `gorm:"uniqueIndex"` struct tags |50| ActiveRecord | `add_index` in migrations, `index: true` in column definition |51| SQL migrations | `CREATE INDEX`, `CREATE UNIQUE INDEX` statements |5253## Implementation5455```python56#!/usr/bin/env python357"""58phy-db-index-advisor — ORM query pattern analyzer for missing indexes59Usage: python3 advisor.py [path] [--json] [--min-count N]60"""61import argparse62import json63import os64import re65import sys66from collections import defaultdict67from dataclasses import dataclass, field68from pathlib import Path69from typing import Optional7071# ─── Data structures ─────────────────────────────────────────────────────────7273@dataclass74class QueryHit:75 file: str76 line: int77 pattern: str78 orm: str79 access_type: str # WHERE, ORDER_BY, JOIN8081@dataclass82class ColumnReport:83 table_hint: str # Guessed model/table name84 column: str85 where_count: int = 086 order_count: int = 087 join_count: int = 088 files: set = field(default_factory=set)89 hits: list = field(default_factory=list)90 already_indexed: bool = False9192 @property93 def total_count(self) -> int:94 return self.where_count + self.order_count + self.join_count9596 @property97 def priority(self) -> str:98 if self.already_indexed:99 return "INDEXED"100 if self.where_count >= 10 or self.total_count >= 15:101 return "CRITICAL"102 if self.where_count >= 5 or self.total_count >= 8:103 return "HIGH"104 if self.total_count >= 3:105 return "MEDIUM"106 return "LOW"107108# ─── Query pattern registry ───────────────────────────────────────────────────109110# (orm_name, access_type, regex, model_group_idx, col_group_idx)111QUERY_PATTERNS = [112 # ── Django ORM ──113 ("Django", "WHERE",114 re.compile(r'\.(?:filter|get|exclude|count|exists)\s*\([^)]*?(\w+)__?\w*\s*='),115 None, 1),116 ("Django", "WHERE",117 re.compile(r'\.(?:filter|get|exclude)\s*\(\s*(\w+)\s*='),118 None, 1),119 ("Django", "ORDER_BY",120 re.compile(r'\.order_by\s*\(\s*['"-](\w+)['"]\s*\)'),121 None, 1),122 ("Django", "ORDER_BY",123 re.compile(r'ordering\s*=\s*\[[^\]]*?['"](\w+)['"]'),124 None, 1),125126 # ── SQLAlchemy ──127 ("SQLAlchemy", "WHERE",128 re.compile(r'\.filter\s*\(\s*(\w+)\.(\w+)\s*=='),129 1, 2),130 ("SQLAlchemy", "WHERE",131 re.compile(r'\.filter_by\s*\([^)]*?(\w+)\s*='),132 None, 1),133 ("SQLAlchemy", "ORDER_BY",134 re.compile(r'\.order_by\s*\(\s*(\w+)\.(\w+)'),135 1, 2),136 ("SQLAlchemy", "ORDER_BY",137 re.compile(r'\.order_by\s*\(\s*(?:asc|desc)\s*\(\s*(\w+)\.(\w+)'),138 1, 2),139140 # ── TypeORM ──141 ("TypeORM", "WHERE",142 re.compile(r'where\s*:\s*\{[^}]*?(\w+)\s*:'),143 None, 1),144 ("TypeORM", "WHERE",145 re.compile(r'\.where\s*\(\s*['"`](?:\w+\.)?(\w+)\s*(?:=|LIKE|IN|>|<)'),146 None, 1),147 ("TypeORM", "ORDER_BY",148 re.compile(r'\.orderBy\s*\(\s*['"`](?:\w+\.)?(\w+)['"`]'),149 None, 1),150 ("TypeORM", "ORDER_BY",151 re.compile(r'orderBy\s*:\s*\{[^}]*?(\w+)\s*:'),152 None, 1),153154 # ── Prisma ──155 ("Prisma", "WHERE",156 re.compile(r'where\s*:\s*\{[^}]*?(\w+)\s*:'),157 None, 1),158 ("Prisma", "ORDER_BY",159 re.compile(r'orderBy\s*:\s*\{[^}]*?(\w+)\s*:'),160 None, 1),161162 # ── Sequelize ──163 ("Sequelize", "WHERE",164 re.compile(r'where\s*:\s*\{[^}]*?(\w+)\s*:'),165 None, 1),166 ("Sequelize", "ORDER_BY",167 re.compile(r'order\s*:\s*\[\s*\[\s*['"`](\w+)['"`]'),168 None, 1),169170 # ── GORM ──171 ("GORM", "WHERE",172 re.compile(r'\.(?:Where|Find|First|Last)\s*\([^,)]*?['"`](?:\w+\.)?(\w+)\s*(?:=|LIKE|IN|>|<|\?)'),173 None, 1),174 ("GORM", "ORDER_BY",175 re.compile(r'\.Order\s*\(\s*['"`](\w+)'),176 None, 1),177 ("GORM", "JOIN",178 re.compile(r'\.Joins\s*\([^)]*?ON\s+\w+\.(\w+)\s*=\s*\w+\.(\w+)'),179 None, 1),180181 # ── ActiveRecord (Ruby) ──182 ("ActiveRecord", "WHERE",183 re.compile(r'\.where\s*\(\s*(\w+):\s*'),184 None, 1),185 ("ActiveRecord", "WHERE",186 re.compile(r'\.find_by\s*\(\s*(\w+):\s*'),187 None, 1),188 ("ActiveRecord", "ORDER_BY",189 re.compile(r'\.order\s*\(\s*:(\w+)\s*\)'),190 None, 1),191 ("ActiveRecord", "ORDER_BY",192 re.compile(r'\.order\s*\(\s*['"](\w+)'),193 None, 1),194]195196# ─── Existing index detection ─────────────────────────────────────────────────197198EXISTING_INDEX_PATTERNS = [199 # Django200 re.compile(r'(\w+)\s*=\s*\w+Field\s*\([^)]*\bdb_index\s*=\s*True'),201 re.compile(r'(\w+)\s*=\s*\w+Field\s*\([^)]*\bunique\s*=\s*True'),202 re.compile(r'models\.Index\s*\(\s*fields\s*=\s*\[([^\]]+)\]'),203 # SQLAlchemy204 re.compile(r'Column\s*\([^)]*\bindex\s*=\s*True[^)]*\).*?#.*?(\w+)'),205 re.compile(r'(\w+)\s*=\s*Column\s*\([^)]*\bindex\s*=\s*True'),206 re.compile(r'(\w+)\s*=\s*Column\s*\([^)]*\bunique\s*=\s*True'),207 re.compile(r'Index\s*\(\s*['"`]\w+['"`]\s*,\s*\w+\.(\w+)'),208 # TypeORM209 re.compile(r'@(?:Index|Unique|Column)\s*\([^)]*\bindex\s*:\s*true'),210 re.compile(r'@Column\s*\([^)]*\bunique\s*:\s*true[^)]*\)\s*\w+\s*:\s*\w+\s*(\w+)'),211 # Prisma212 re.compile(r'@@index\s*\(\s*\[([^\]]+)\]'),213 re.compile(r'@@unique\s*\(\s*\[([^\]]+)\]'),214 re.compile(r'(\w+)\s+\w+\s+@unique'),215 # GORM216 re.compile(r'(\w+)\s+\w+\s+`[^`]*gorm:"[^"]*(?:index|uniqueIndex)[^"]*"`'),217 # SQL migrations218 re.compile(r'CREATE\s+(?:UNIQUE\s+)?INDEX\s+\w+\s+ON\s+\w+\s*\(([^)]+)\)', re.IGNORECASE),219 re.compile(r'add_index\s+:\w+\s*,\s*:(\w+)'), # ActiveRecord migration220]221222# Columns to always skip (noise)223SKIP_COLUMNS = {224 "id", "pk", "uuid", "created_at", "updated_at", "deleted_at",225 "created_by", "updated_by", "None", "null", "true", "false",226 "True", "False", "self", "cls", "this", "kwargs", "args",227}228229SKIP_DIRS = {".git", "node_modules", "vendor", "__pycache__", ".venv", "venv",230 "dist", "build", "target", "migrations", "alembic"}231232FILE_EXTS = {".py", ".ts", ".js", ".rb", ".go"}233234def collect_existing_indexes(root: Path) -> set[str]:235 """Return set of column names already indexed."""236 indexed = set()237 for dirpath, dirnames, filenames in os.walk(root):238 dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]239 for fname in filenames:240 fpath = Path(dirpath) / fname241 if fpath.suffix.lower() not in FILE_EXTS | {".sql", ".rb"}:242 continue243 try:244 text = fpath.read_text(encoding="utf-8", errors="replace")245 except OSError:246 continue247 for pat in EXISTING_INDEX_PATTERNS:248 for m in pat.finditer(text):249 for grp in m.groups():250 if grp:251 for col in re.split(r'[\s,'"`]+', grp):252 col = col.strip().strip('"'`')253 if col:254 indexed.add(col.lower())255 return indexed256257def scan_queries(root: Path) -> dict[str, ColumnReport]:258 """Scan all source files and collect query patterns per column."""259 col_reports: dict[str, ColumnReport] = {}260261 for dirpath, dirnames, filenames in os.walk(root):262 dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]263 for fname in filenames:264 fpath = Path(dirpath) / fname265 if fpath.suffix.lower() not in FILE_EXTS:266 continue267 # Skip test files268 if any(x in fname.lower() for x in ("test", "spec", "mock", "fixture")):269 continue270 try:271 lines = fpath.read_text(encoding="utf-8", errors="replace").splitlines()272 except OSError:273 continue274275 full_text = "\n".join(lines)276 rel_path = os.path.relpath(str(fpath))277278 for (orm, access_type, pat, model_grp, col_grp) in QUERY_PATTERNS:279 for m in pat.finditer(full_text):280 try:281 col = m.group(col_grp)282 except IndexError:283 continue284 if not col or col.lower() in SKIP_COLUMNS:285 continue286 if len(col) < 2 or not col.replace("_", "").isalpha():287 continue288289 model = None290 if model_grp:291 try:292 model = m.group(model_grp)293 except IndexError:294 pass295296 lineno = full_text[:m.start()].count("\n") + 1297 key = col.lower()298299 if key not in col_reports:300 col_reports[key] = ColumnReport(301 table_hint=model or "",302 column=col,303 )304 report = col_reports[key]305 if model and not report.table_hint:306 report.table_hint = model307 report.files.add(rel_path)308 report.hits.append(QueryHit(rel_path, lineno, m.group(0)[:80], orm, access_type))309310 if access_type == "WHERE":311 report.where_count += 1312 elif access_type == "ORDER_BY":313 report.order_count += 1314 elif access_type == "JOIN":315 report.join_count += 1316317 return col_reports318319def format_report(reports: list[ColumnReport], existing_indexes: set[str]) -> str:320 # Mark already-indexed321 for r in reports:322 if r.column.lower() in existing_indexes:323 r.already_indexed = True324325 # Filter to only non-indexed, min 3 total hits326 actionable = [r for r in reports if not r.already_indexed and r.total_count >= 3]327 actionable.sort(key=lambda x: x.total_count, reverse=True)328329 priority_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}330 actionable.sort(key=lambda x: priority_order.get(x.priority, 4))331332 already_indexed = [r for r in reports if r.already_indexed and r.total_count >= 3]333334 lines = [335 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",336 " DB INDEX ADVISOR — Missing Index Analysis",337 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",338 f" Columns queried: {len(reports)}",339 f" Missing indexes: {len(actionable)} ({sum(1 for r in actionable if r.priority=='CRITICAL')} CRITICAL)",340 f" Already indexed: {len(already_indexed)} (suppressed)",341 "",342 ]343344 icons = {"CRITICAL": "🔴", "HIGH": "🟠", "MEDIUM": "🟡", "LOW": "⚪"}345 current_priority = None346 for r in actionable:347 p = r.priority348 if p != current_priority:349 current_priority = p350 lines.append(f"\n{icons.get(p, '⚪')} {p}")351 lines.append("")352353 table = r.table_hint or "?"354 breakdown = []355 if r.where_count:356 breakdown.append(f"WHERE×{r.where_count}")357 if r.order_count:358 breakdown.append(f"ORDER_BY×{r.order_count}")359 if r.join_count:360 breakdown.append(f"JOIN×{r.join_count}")361362 # Show top 3 call sites363 sample_files = sorted(r.files)[:3]364 samples_str = ", ".join(sample_files)365 if len(r.files) > 3:366 samples_str += f" (+{len(r.files)-3} more)"367368 lines += [369 f" {table}.{r.column} [{' | '.join(breakdown)}] across {len(r.files)} file(s)",370 f" Files: {samples_str}",371 f" SQL: CREATE INDEX idx_{table.lower()}_{r.column.lower()} ON {table.lower()} ({r.column});",372 f" Django: {r.column} = models.{r.column.title()}Field(..., db_index=True)",373 f" SQLAlchemy: {r.column} = Column(String, index=True)",374 f" Prisma: @@index([{r.column}])",375 "",376 ]377378 if not actionable:379 lines.append(" ✅ No missing indexes detected (all queried columns are already indexed)")380 lines.append("")381382 if already_indexed:383 lines.append(f" ✅ Already indexed ({len(already_indexed)} columns): "384 + ", ".join(r.column for r in already_indexed[:8])385 + ("..." if len(already_indexed) > 8 else ""))386 lines.append("")387388 critical_count = sum(1 for r in actionable if r.priority == "CRITICAL")389 lines += [390 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",391 f" CI gate: {'exit 1 — missing critical indexes' if critical_count else 'exit 0'}",392 " Runtime verification: EXPLAIN ANALYZE your most frequent queries",393 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",394 ]395 return "\n".join(lines)396397def main():398 parser = argparse.ArgumentParser(description="DB index advisor — finds missing indexes from ORM patterns")399 parser.add_argument("path", nargs="?", default=".", help="Root directory to scan")400 parser.add_argument("--json", action="store_true", help="JSON output")401 parser.add_argument("--min-count", type=int, default=3,402 help="Minimum query count to report (default: 3)")403 parser.add_argument("--ci", action="store_true", help="Exit 1 if CRITICAL indexes missing")404 args = parser.parse_args()405406 root = Path(args.path).resolve()407 existing_indexes = collect_existing_indexes(root)408 col_reports_dict = scan_queries(root)409 reports = list(col_reports_dict.values())410411 for r in reports:412 if r.column.lower() in existing_indexes:413 r.already_indexed = True414415 actionable = sorted(416 [r for r in reports if not r.already_indexed and r.total_count >= args.min_count],417 key=lambda x: x.total_count,418 reverse=True,419 )420421 if args.json:422 import dataclasses423 output = []424 for r in actionable:425 d = dataclasses.asdict(r)426 d["priority"] = r.priority427 d["total_count"] = r.total_count428 d["files"] = list(r.files)429 output.append(d)430 print(json.dumps(output, indent=2))431 else:432 print(format_report(reports, existing_indexes))433434 if args.ci:435 has_critical = any(r.priority == "CRITICAL" for r in actionable)436 sys.exit(1 if has_critical else 0)437438if __name__ == "__main__":439 main()440```441442## Usage443444```bash445# Scan current project446python3 advisor.py447448# Scan a specific path449python3 advisor.py ~/projects/myapp450451# Only show columns queried 5+ times452python3 advisor.py --min-count 5453454# CI fail-gate (exits 1 if CRITICAL missing indexes found)455python3 advisor.py --ci456457# JSON output for dashboard/ticketing458python3 advisor.py --json | jq '[.[] | select(.priority == "CRITICAL")]'459460# GitHub Actions461- name: DB Index Advisor462 run: python3 .claude/skills/phy-db-index-advisor/advisor.py --ci463```464465## Sample Output466467```468━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━469 DB INDEX ADVISOR — Missing Index Analysis470━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━471 Columns queried: 24472 Missing indexes: 6 (2 CRITICAL)473 Already indexed: 8 (suppressed)474475🔴 CRITICAL476477 User.email [WHERE×28] across 7 file(s)478 Files: api/auth.py, api/users.py, services/notifications.py (+4 more)479 SQL: CREATE INDEX idx_user_email ON user (email);480 Django: email = models.EmailField(..., db_index=True)481 SQLAlchemy: email = Column(String, index=True)482 Prisma: @@index([email])483484 Order.user_id [WHERE×19 | JOIN×6] across 5 file(s)485 Files: api/orders.py, services/billing.py, reports/revenue.py (+2 more)486 SQL: CREATE INDEX idx_order_user_id ON order (user_id);487 Django: user_id = models.ForeignKey(..., db_index=True)488 SQLAlchemy: user_id = Column(Integer, ForeignKey('user.id'), index=True)489 Prisma: @@index([userId])490491🟠 HIGH492493 Product.category_id [WHERE×12 | ORDER_BY×4] across 4 file(s)494 SQL: CREATE INDEX idx_product_category_id ON product (category_id);495496 Session.token [WHERE×9] across 3 file(s)497 SQL: CREATE INDEX idx_session_token ON session (token);498499━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━500 CI gate: exit 1 — missing critical indexes501 Runtime verification: EXPLAIN ANALYZE your most frequent queries502━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━503```504505## Relationship to `phy-sql-explainer`506507| Skill | Input | Output | When to Use |508|-------|-------|--------|-------------|509| **phy-db-index-advisor** | Source code (ORM patterns) | Missing index recommendations | Pre-deployment: catch before slow queries appear |510| **phy-sql-explainer** | EXPLAIN ANALYZE output | Query plan diagnosis | Post-deployment: diagnose an existing slow query |511512Use **both**: this skill prevents index gaps from being deployed; `phy-sql-explainer` diagnoses what got through anyway.513514## Limitations & False Positives515516- **Dynamic columns**: `.filter(**kwargs)` cannot be statically analyzed — run with `--min-count 5` to focus on confirmed hot paths517- **FK indexes**: Most databases (PostgreSQL, MySQL) do NOT automatically index foreign keys — this skill will flag them correctly518- **Primary keys**: `id` is always excluded (databases auto-index primary keys)519- **Composite indexes**: When two columns always appear together in WHERE, a composite index may outperform two single-column indexes — manual review recommended520521## Companion Skills522523| Skill | Use Together For |524|-------|-----------------|525| `phy-sql-explainer` | Pre + post deployment DB performance sweep |526| `phy-db-migration-auditor` | Safe migration before applying index additions |527| `phy-concurrency-audit` | Race conditions + missing indexes both cause data integrity failures |