Coverage for src/asgi_profiler/fingerprint.py: 100%
25 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
1"""Stable identity for a SQL statement.
3Grouping statements by their literal text is right until it isn't. Two
4executions of the same ORM query differ when the `IN` list is a different
5length, when a savepoint is numbered, or when a `text()` query was written
6with its values inline -- and the profiler then reports one problem as six,
7which is the same as reporting nothing.
9:func:`sql_hash` answers "are these the same statement?" by normalising the
10parts that vary per execution and hashing what is left. The normalisation is
11deliberately conservative, in this order:
131. `IN (1, 2, 3)` and `IN (?, ?, ?)` collapse to `IN (%s)`. This is the one
14 that matters most: a page of ten rows and a page of eleven issue the same
15 query, and without this they are two statements forever.
162. `SAVEPOINT sa_savepoint_3` collapses to `SAVEPOINT %s`. The counter is
17 per-transaction, so every nested block would otherwise be unique.
183. Single-quoted strings, numbers and booleans collapse to `%s`.
20What is deliberately *not* normalised: double-quoted strings, because
21PostgreSQL uses them for identifiers and folding `"users"` into `%s` would
22merge every table in the schema into one statement. Nor are bare placeholders
23rewritten between dialects -- `?` and `%s` never appear in the same process,
24so translating them buys nothing and risks merging statements that differ.
26Numbers are matched on word boundaries, so a table named `users_2024` or a
27column alias `t1` survives: `_` and letters are word characters, which means
28there is no boundary in front of those digits.
29"""
31from __future__ import annotations
33import re
34from functools import lru_cache
35from hashlib import md5
37__all__ = ["is_truncated", "normalise_sql", "sql_hash"]
39#: Bump when the normalisation changes. It is part of the hashed input, so an
40#: old capture and a new one never silently share a fingerprint -- they group
41#: separately, which is visible, rather than merging, which is not.
42SCHEME = "1"
44#: Bound parameters, in every dialect SQLAlchemy emits: `qmark`, `format`,
45#: `numeric` and `named`.
46_PLACEHOLDER = r"(?:%s|\?|\$\d+|:\w+)"
47_NUMBER = r"-?\b(?:[0-9]+\.)?[0-9]+(?:[eE][+-]?[0-9]+)?\b"
48#: `''` is SQL's escape for a literal quote, so it must not end the match.
49_STRING = r"'(?:[^']|'')*'"
50_BOOL = r"\b(?:TRUE|FALSE)\b"
52_IN_ITEM = f"(?:{_PLACEHOLDER}|{_NUMBER}|{_STRING}|{_BOOL})"
53_IN_LIST = re.compile(
54 rf"\bIN\s*\(\s*{_IN_ITEM}(?:\s*,\s*{_IN_ITEM})*\s*\)",
55 re.IGNORECASE,
56)
57_SAVEPOINT = re.compile(
58 r'\bSAVEPOINT\s+(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)',
59 re.IGNORECASE,
60)
61_LITERAL = re.compile(f"{_STRING}|{_NUMBER}|{_BOOL}", re.IGNORECASE)
63#: Appended by `instrument._normalise` when a statement is too long to keep.
64#: A truncated statement cannot be compared honestly -- the part that differs
65#: may be the part that was cut -- so detectors refuse to fingerprint one.
66TRUNCATION_MARKER = "... [truncated,"
69def is_truncated(sql: str) -> bool:
70 """Was this statement stored with its tail cut off?"""
71 return sql.endswith("chars]") and TRUNCATION_MARKER in sql
74def normalise_sql(sql: str) -> str:
75 """The statement with per-execution values replaced by `%s`.
77 Readable on purpose: it is what the viewer shows when it needs to name a
78 group of statements rather than one execution of it.
79 """
80 text = " ".join(sql.split())
81 text = _IN_LIST.sub("IN (%s)", text)
82 text = _SAVEPOINT.sub("SAVEPOINT %s", text)
83 return _LITERAL.sub("%s", text)
86@lru_cache(maxsize=4096)
87def sql_hash(sql: str) -> str:
88 """A short, stable identity for `sql`.
90 Cached because the whole point of the caller is repetition: an N+1 asks
91 this the same question five hundred times in a row, and the cache turns
92 all but the first into a dictionary lookup.
94 Not a security primitive -- md5 is here because it is fast and 64 bits of
95 it is plenty to tell two statements apart within one application.
96 """
97 digest = md5(normalise_sql(sql).encode("utf-8", "replace"), usedforsecurity=False)
98 return f"{SCHEME}-{digest.hexdigest()[:16]}"