Files
TimeTracker/app/utils/query_optimization.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

134 lines
3.9 KiB
Python

"""
Database query optimization utilities.
Helps identify and fix N+1 query problems.
"""
from typing import List, Optional, Type
from sqlalchemy import inspect
from sqlalchemy.orm import Query, joinedload, selectinload, subqueryload
from app import db
def eager_load_relations(query: Query, model_class: Type, relations: List[str], strategy: str = "joined") -> Query:
"""
Eagerly load relations to prevent N+1 queries.
Args:
query: SQLAlchemy query
model_class: Model class
relations: List of relation names to load
strategy: Loading strategy ('joined', 'selectin', 'subquery')
Returns:
Query with eager loading options
"""
loader_map = {"joined": joinedload, "selectin": selectinload, "subquery": subqueryload}
loader_func = loader_map.get(strategy, joinedload)
for relation in relations:
if hasattr(model_class, relation):
query = query.options(loader_func(getattr(model_class, relation)))
return query
def get_model_relations(model_class: Type) -> List[str]:
"""
Get all relation names for a model.
Args:
model_class: SQLAlchemy model class
Returns:
List of relation attribute names
"""
inspector = inspect(model_class)
return [rel.key for rel in inspector.relationships]
def optimize_list_query(query: Query, model_class: Type, common_relations: Optional[List[str]] = None) -> Query:
"""
Optimize a list query by eagerly loading common relations.
Args:
query: SQLAlchemy query
model_class: Model class
common_relations: Optional list of relations to always load
Returns:
Optimized query
"""
if common_relations:
return eager_load_relations(query, model_class, common_relations)
# Auto-detect common relations (relationships that are likely to be accessed)
all_relations = get_model_relations(model_class)
# Common patterns: user, project, client, task, etc.
common_patterns = ["user", "project", "client", "task", "assignee", "creator"]
relations_to_load = [rel for rel in all_relations if any(pattern in rel.lower() for pattern in common_patterns)]
if relations_to_load:
return eager_load_relations(query, model_class, relations_to_load)
return query
def batch_load_relations(items: List[Type], relation_name: str, model_class: Type) -> None:
"""
Batch load a relation for a list of items (prevents N+1).
Note: This is a helper for cases where eager loading wasn't possible.
Prefer using eager_load_relations in the query instead.
Args:
items: List of model instances
relation_name: Name of relation to load
model_class: Model class
"""
if not items:
return
# Get IDs
ids = [item.id for item in items]
# Load all related items in one query
relation = getattr(model_class, relation_name)
related_items = (
db.session.query(relation.property.mapper.class_).filter(relation.property.mapper.class_.id.in_(ids)).all()
)
# This is a simplified example - in practice, you'd need to map them back
class QueryProfiler:
"""Helper class to profile and optimize queries"""
@staticmethod
def count_queries(func):
"""Decorator to count database queries in a function"""
from functools import wraps
from sqlalchemy import event
from sqlalchemy.engine import Engine
@wraps(func)
def wrapper(*args, **kwargs):
queries = []
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
queries.append(statement)
event.listen(Engine, "before_cursor_execute", before_cursor_execute)
try:
result = func(*args, **kwargs)
return result, len(queries)
finally:
event.remove(Engine, "before_cursor_execute", before_cursor_execute)
return wrapper