mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
323 lines
12 KiB
Python
323 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Post-Tool Validation Hook for querying-soql skill.
|
||
|
||
This hook runs AFTER Write or Edit tool completes and provides validation
|
||
feedback for SOQL files (*.soql).
|
||
|
||
Integrates:
|
||
1. Static SOQL validation (syntax, best practices)
|
||
2. Live Query Plan Analysis (if org connected)
|
||
|
||
Hook Input (stdin): JSON with tool_input and tool_response
|
||
Hook Output (stdout): JSON with optional output message
|
||
|
||
This hook is ADVISORY - it provides feedback but does not block operations.
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
import json
|
||
|
||
# Add script directory to path for imports
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, SCRIPT_DIR)
|
||
|
||
# Find shared modules (../../shared relative to querying-soql)
|
||
PLUGIN_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) # querying-soql/
|
||
SKILLS_ROOT = os.path.dirname(PLUGIN_ROOT) # sf-skills/
|
||
SHARED_DIR = os.path.join(SKILLS_ROOT, "shared")
|
||
sys.path.insert(0, SHARED_DIR)
|
||
|
||
|
||
def validate_soql_file(file_path: str) -> dict:
|
||
"""
|
||
Validate a .soql file with static analysis and live query plan.
|
||
|
||
Args:
|
||
file_path: Path to .soql file
|
||
|
||
Returns:
|
||
dict with validation results and output message
|
||
"""
|
||
output_parts = []
|
||
file_name = os.path.basename(file_path)
|
||
issues = []
|
||
recommendations = []
|
||
|
||
try:
|
||
# Read file content
|
||
with open(file_path, 'r') as f:
|
||
content = f.read()
|
||
|
||
if not content.strip():
|
||
return {"continue": True}
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# PHASE 1: Static SOQL Validation
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
static_result = validate_soql_static(content)
|
||
issues.extend(static_result.get('issues', []))
|
||
recommendations.extend(static_result.get('recommendations', []))
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# PHASE 2: Live Query Plan Analysis (if org connected)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
live_result = None
|
||
org_name = None
|
||
|
||
try:
|
||
from code_analyzer.live_query_plan import LiveQueryPlanAnalyzer
|
||
|
||
analyzer = LiveQueryPlanAnalyzer()
|
||
if analyzer.is_org_available():
|
||
org_name = analyzer.get_target_org()
|
||
live_result = analyzer.analyze(content)
|
||
|
||
if live_result.success:
|
||
# Add live plan insights as issues/recommendations
|
||
if not live_result.is_selective:
|
||
issues.append({
|
||
'severity': 'WARNING',
|
||
'message': f'Non-selective query (cost: {live_result.relative_cost:.1f})',
|
||
'source': 'LivePlan'
|
||
})
|
||
|
||
# Add notes as recommendations
|
||
for note in live_result.notes:
|
||
recommendations.append(str(note))
|
||
|
||
# Get optimization suggestions
|
||
suggestions = analyzer.get_optimization_suggestions(live_result)
|
||
recommendations.extend(suggestions)
|
||
|
||
except ImportError:
|
||
pass # Live analysis not available
|
||
except Exception as e:
|
||
pass # Don't fail on live analysis errors
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# PHASE 3: Format Output
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
output_parts.append("")
|
||
output_parts.append(f"🔍 SOQL Validation: {file_name}")
|
||
output_parts.append("═" * 55)
|
||
|
||
# Static analysis summary
|
||
if static_result.get('has_where_clause'):
|
||
output_parts.append("✅ Has WHERE clause")
|
||
else:
|
||
output_parts.append("⚠️ Missing WHERE clause")
|
||
|
||
if static_result.get('has_limit'):
|
||
output_parts.append("✅ Has LIMIT clause")
|
||
else:
|
||
output_parts.append("⚠️ Missing LIMIT clause")
|
||
|
||
if static_result.get('has_hardcoded_ids'):
|
||
output_parts.append("⚠️ Contains hardcoded IDs")
|
||
|
||
# Live Query Plan section
|
||
output_parts.append("")
|
||
if live_result and live_result.success:
|
||
output_parts.append(f"🌐 Live Query Plan Analysis")
|
||
output_parts.append(f" Org: {org_name}")
|
||
output_parts.append(f" {live_result.icon} Selective: {live_result.is_selective}")
|
||
output_parts.append(f" 📊 Relative Cost: {live_result.relative_cost:.2f} ({live_result.selectivity_rating})")
|
||
output_parts.append(f" 📈 Operation: {live_result.leading_operation}")
|
||
|
||
if live_result.cardinality > 0:
|
||
output_parts.append(f" 📋 Cardinality: {live_result.cardinality:,} / {live_result.sobject_cardinality:,}")
|
||
|
||
if live_result.notes:
|
||
output_parts.append("")
|
||
output_parts.append(" 📝 Query Plan Notes:")
|
||
for note in live_result.notes[:3]:
|
||
output_parts.append(f" • {str(note)[:70]}")
|
||
elif org_name is None:
|
||
output_parts.append("🌐 Live Query Plan: No org connected")
|
||
output_parts.append(" Run 'sf org login web' to enable live analysis")
|
||
elif live_result and not live_result.success:
|
||
output_parts.append(f"🌐 Live Query Plan: Error")
|
||
output_parts.append(f" {live_result.error[:60]}")
|
||
|
||
# Issues
|
||
if issues:
|
||
output_parts.append("")
|
||
output_parts.append(f"⚠️ Issues ({len(issues)}):")
|
||
severity_icons = {
|
||
'CRITICAL': '🔴', 'HIGH': '🟠', 'MODERATE': '🟡',
|
||
'WARNING': '⚠️', 'LOW': '🔵', 'INFO': 'ℹ️'
|
||
}
|
||
for issue in issues[:5]:
|
||
icon = severity_icons.get(issue.get('severity', 'INFO'), 'ℹ️')
|
||
source = f"[{issue.get('source', '')}]" if issue.get('source') else ""
|
||
output_parts.append(f" {icon} {source} {issue.get('message', '')[:60]}")
|
||
|
||
# Recommendations
|
||
unique_recs = list(dict.fromkeys(recommendations)) # Remove duplicates
|
||
if unique_recs:
|
||
output_parts.append("")
|
||
output_parts.append("💡 Recommendations:")
|
||
for rec in unique_recs[:5]:
|
||
output_parts.append(f" • {rec[:65]}")
|
||
|
||
output_parts.append("═" * 55)
|
||
|
||
return {
|
||
"continue": True,
|
||
"output": "\n".join(output_parts)
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
"continue": True,
|
||
"output": f"⚠️ SOQL validation error: {e}"
|
||
}
|
||
|
||
|
||
def validate_soql_static(content: str) -> dict:
|
||
"""
|
||
Perform static validation on SOQL content.
|
||
|
||
Args:
|
||
content: SOQL query string
|
||
|
||
Returns:
|
||
dict with validation flags and issues
|
||
"""
|
||
import re
|
||
|
||
result = {
|
||
'is_valid': True,
|
||
'has_where_clause': False,
|
||
'has_limit': False,
|
||
'has_order_by': False,
|
||
'has_hardcoded_ids': False,
|
||
'uses_indexed_fields': False,
|
||
'issues': [],
|
||
'recommendations': []
|
||
}
|
||
|
||
# Remove comments
|
||
clean = re.sub(r'--.*$', '', content, flags=re.MULTILINE)
|
||
clean = re.sub(r'//.*$', '', clean, flags=re.MULTILINE)
|
||
clean = re.sub(r'/\*[\s\S]*?\*/', '', clean)
|
||
|
||
# Check for WHERE clause
|
||
result['has_where_clause'] = bool(re.search(r'\bWHERE\b', clean, re.IGNORECASE))
|
||
|
||
# Check for LIMIT
|
||
result['has_limit'] = bool(re.search(r'\bLIMIT\s+\d+', clean, re.IGNORECASE))
|
||
|
||
# Check for ORDER BY
|
||
result['has_order_by'] = bool(re.search(r'\bORDER\s+BY\b', clean, re.IGNORECASE))
|
||
|
||
# Check for hardcoded IDs (15 or 18 char alphanumeric in quotes)
|
||
result['has_hardcoded_ids'] = bool(
|
||
re.search(r"'[a-zA-Z0-9]{15}'", clean) or
|
||
re.search(r"'[a-zA-Z0-9]{18}'", clean)
|
||
)
|
||
|
||
# Check for indexed fields in WHERE
|
||
indexed_fields = ['Id', 'Name', 'OwnerId', 'CreatedDate', 'LastModifiedDate', 'RecordTypeId']
|
||
where_match = re.search(r'\bWHERE\b(.*?)(?:\bORDER\b|\bGROUP\b|\bLIMIT\b|$)', clean, re.IGNORECASE | re.DOTALL)
|
||
if where_match:
|
||
where_clause = where_match.group(1)
|
||
for field in indexed_fields:
|
||
if re.search(rf'\b{field}\b', where_clause, re.IGNORECASE):
|
||
result['uses_indexed_fields'] = True
|
||
break
|
||
|
||
# Syntax validation
|
||
# Check for SELECT without FROM
|
||
if re.search(r'\bSELECT\b', clean, re.IGNORECASE):
|
||
if not re.search(r'\bFROM\b', clean, re.IGNORECASE):
|
||
result['issues'].append({
|
||
'severity': 'HIGH',
|
||
'message': 'SELECT statement missing FROM clause'
|
||
})
|
||
result['is_valid'] = False
|
||
|
||
# Check for SELECT *
|
||
if re.search(r'\bSELECT\s+\*', clean, re.IGNORECASE):
|
||
result['issues'].append({
|
||
'severity': 'HIGH',
|
||
'message': 'SELECT * is not valid in SOQL - specify field names'
|
||
})
|
||
result['is_valid'] = False
|
||
|
||
# Check for == instead of =
|
||
if re.search(r'==', clean):
|
||
result['issues'].append({
|
||
'severity': 'HIGH',
|
||
'message': 'Invalid operator "==" - use "=" in SOQL'
|
||
})
|
||
|
||
# Check for unbalanced parentheses
|
||
if clean.count('(') != clean.count(')'):
|
||
result['issues'].append({
|
||
'severity': 'HIGH',
|
||
'message': 'Unbalanced parentheses'
|
||
})
|
||
|
||
# Add recommendations
|
||
if not result['has_where_clause']:
|
||
result['recommendations'].append('Add WHERE clause for better query selectivity')
|
||
|
||
if not result['has_limit']:
|
||
result['recommendations'].append('Add LIMIT clause to prevent large result sets')
|
||
|
||
if result['has_hardcoded_ids']:
|
||
result['recommendations'].append('Avoid hardcoded IDs - use bind variables instead')
|
||
|
||
if result['has_where_clause'] and not result['uses_indexed_fields']:
|
||
result['recommendations'].append('Add an indexed field (Id, Name, CreatedDate) to WHERE for better performance')
|
||
|
||
return result
|
||
|
||
|
||
def main():
|
||
"""
|
||
Main hook entry point.
|
||
|
||
Reads hook input from stdin, validates SOQL files.
|
||
"""
|
||
try:
|
||
# Read hook input from stdin
|
||
hook_input = json.load(sys.stdin)
|
||
|
||
# Extract file path from tool input
|
||
tool_input = hook_input.get("tool_input", {})
|
||
file_path = tool_input.get("file_path", "")
|
||
|
||
# Check if operation was successful
|
||
tool_response = hook_input.get("tool_response", {})
|
||
if not tool_response.get("success", True):
|
||
print(json.dumps({"continue": True}))
|
||
return 0
|
||
|
||
# Only validate .soql files
|
||
result = {"continue": True}
|
||
|
||
if file_path.lower().endswith(".soql"):
|
||
result = validate_soql_file(file_path)
|
||
|
||
# Output result
|
||
print(json.dumps(result))
|
||
return 0
|
||
|
||
except json.JSONDecodeError:
|
||
print(json.dumps({"continue": True}))
|
||
return 0
|
||
except Exception as e:
|
||
print(json.dumps({
|
||
"continue": True,
|
||
"output": f"⚠️ Hook error: {e}"
|
||
}))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|