query_counts.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. import json
  2. import os
  3. import sys
  4. import threading
  5. from contextlib import contextmanager
  6. from pathlib import Path
  7. try:
  8. import fcntl
  9. except ImportError:
  10. fcntl = None
  11. from django.apps import apps as django_apps
  12. from django.db import connection
  13. from django.test.utils import CaptureQueriesContext
  14. __all__ = (
  15. 'assert_expected_query_count',
  16. )
  17. UPDATE_ENV_VAR = 'UPDATE_QUERY_COUNTS'
  18. BASELINE_FILENAME = 'query_counts.json'
  19. _loaded_baselines = {}
  20. _lock = threading.Lock()
  21. def _is_update_mode():
  22. return bool(os.environ.get(UPDATE_ENV_VAR))
  23. def _is_parallel_test_run():
  24. # Heuristic: inspects sys.argv for Django's --parallel flag. This is
  25. # sufficient for the project's standard `manage.py test` invocations but
  26. # will not detect parallelism introduced by other test runners.
  27. for arg in sys.argv:
  28. if arg == '--parallel' or arg.startswith('--parallel='):
  29. return True
  30. return False
  31. def _baseline_path(app_label):
  32. app_config = django_apps.get_app_config(app_label)
  33. return Path(app_config.path) / 'tests' / BASELINE_FILENAME
  34. def _load_baseline(app_label):
  35. with _lock:
  36. if app_label in _loaded_baselines:
  37. return _loaded_baselines[app_label]
  38. path = _baseline_path(app_label)
  39. if path.exists():
  40. with path.open() as f:
  41. data = json.load(f)
  42. else:
  43. data = {}
  44. _loaded_baselines[app_label] = data
  45. return data
  46. def _record_update(app_label, key, count):
  47. # Write the baseline file synchronously rather than buffering until process
  48. # exit, so updates are not lost if the runner terminates via os._exit() or
  49. # a signal. An OS-level exclusive lock (where available) protects against
  50. # concurrent processes — e.g. two simultaneous update-mode invocations —
  51. # clobbering one another's writes.
  52. with _lock:
  53. path = _baseline_path(app_label)
  54. path.parent.mkdir(parents=True, exist_ok=True)
  55. with path.open('a+') as f:
  56. if fcntl is not None:
  57. fcntl.flock(f.fileno(), fcntl.LOCK_EX)
  58. f.seek(0)
  59. content = f.read()
  60. existing = json.loads(content) if content else {}
  61. existing[key] = count
  62. f.seek(0)
  63. f.truncate()
  64. json.dump(existing, f, indent=2, sort_keys=True)
  65. f.write('\n')
  66. @contextmanager
  67. def assert_expected_query_count(test_case, name):
  68. """
  69. Assert that the wrapped block performs the number of SQL queries recorded
  70. in the per-app baseline file (`<app>/tests/query_counts.json`).
  71. The baseline key is `<model_name>:<name>`, derived from `test_case.model`.
  72. When the `UPDATE_QUERY_COUNTS` environment variable is set, the assertion
  73. is skipped and the observed count is written back to the baseline file
  74. immediately. Update mode requires serial test execution (no --parallel).
  75. """
  76. model = test_case.model
  77. app_label = model._meta.app_label
  78. model_name = model._meta.model_name
  79. key = f'{model_name}:{name}'
  80. if _is_update_mode():
  81. if _is_parallel_test_run():
  82. raise RuntimeError(
  83. f"{UPDATE_ENV_VAR}=1 cannot be combined with --parallel; "
  84. f"re-run serially to regenerate query-count baselines."
  85. )
  86. ctx = CaptureQueriesContext(connection)
  87. with ctx:
  88. yield
  89. _record_update(app_label, key, len(ctx.captured_queries))
  90. return
  91. baseline = _load_baseline(app_label)
  92. if key not in baseline:
  93. test_case.fail(
  94. f"No query-count baseline recorded for {app_label}/{key}. "
  95. f"Re-run with {UPDATE_ENV_VAR}=1 (serially) to record it."
  96. )
  97. expected = baseline[key]
  98. ctx = CaptureQueriesContext(connection)
  99. with ctx:
  100. yield
  101. actual = len(ctx.captured_queries)
  102. if actual != expected:
  103. sample = '\n'.join(
  104. f" {i + 1}. {q['sql'][:240]}"
  105. for i, q in enumerate(ctx.captured_queries)
  106. )
  107. test_case.fail(
  108. f"Query count for {app_label}/{key} changed: "
  109. f"expected {expected}, got {actual}. "
  110. f"If this change is intentional, re-run with {UPDATE_ENV_VAR}=1 "
  111. f"to update the baseline.\nObserved queries:\n{sample}"
  112. )