Repository: adamchainz/django-perf-rec

Issue:
`IN` queries should not distinguish between single and multiple value variants
Currently, if an `IN` used in a query has a single value it's rendered in to the test file as `#`, and if it has multiple it's rendered as `...`. This means that if a test changes from querying for 1 value to querying for `n > 1`, a test failure will be recorded.

Given the nature of `IN` and how it is used for filtering, this difference between 1 and `n > 1` is not a useful distinction to make. It _may_ indicate differences in test behaviour, but that difference is no different to a change from 2 to 3 values in the `IN` clause, which would not be caught by `django-perf-rec`.

My recommendation is to render all `IN` clauses as `IN (...)` even if they only have a single value passed. The alternative would be to render these clauses as `IN (#, #)`, with the same number of values passed in, thus being more specific about the query being used.


Discussion:

Resolving patch:
diff --git a/src/django_perf_rec/sql.py b/src/django_perf_rec/sql.py
--- a/src/django_perf_rec/sql.py
+++ b/src/django_perf_rec/sql.py
@@ -81,6 +81,21 @@ def sql_recursively_simplify(node, hide_columns=True):
         middle = [Token(tokens.Punctuation, " ... ")]
         node.tokens = node.tokens[: i_set + 1] + middle + node.tokens[i_where:]
 
+    # Ensure IN clauses with simple value in always simplify to "..."
+    if node.tokens[0].value == "WHERE":
+        in_token_indices = (i for i, t in enumerate(node.tokens) if t.value == "IN")
+        for in_token_index in in_token_indices:
+            parenthesis = next(
+                t
+                for t in node.tokens[in_token_index + 1 :]
+                if isinstance(t, Parenthesis)
+            )
+            if all(
+                getattr(t, "ttype", "") in sql_deleteable_tokens
+                for t in parenthesis.tokens[1:-1]
+            ):
+                parenthesis.tokens[1:-1] = [Token(tokens.Punctuation, "...")]
+
     # Erase the names of savepoints since they are non-deteriministic
     if hasattr(node, "tokens"):
         # SAVEPOINT x


Test patch:
diff --git a/tests/test_api.perf.yml b/tests/test_api.perf.yml
--- a/tests/test_api.perf.yml
+++ b/tests/test_api.perf.yml
@@ -1,8 +1,8 @@
 RecordTests.test_delete_on_cascade_called_twice:
-- db: DELETE FROM "testapp_book" WHERE "testapp_book"."author_id" IN (#)
-- db: DELETE FROM "testapp_award" WHERE "testapp_award"."author_id" IN (#)
-- db: DELETE FROM "testapp_contract_author" WHERE "testapp_contract_author"."author_id" IN (#)
-- db: DELETE FROM "testapp_author" WHERE "testapp_author"."id" IN (#)
+- db: DELETE FROM "testapp_book" WHERE "testapp_book"."author_id" IN (...)
+- db: DELETE FROM "testapp_award" WHERE "testapp_award"."author_id" IN (...)
+- db: DELETE FROM "testapp_contract_author" WHERE "testapp_contract_author"."author_id" IN (...)
+- db: DELETE FROM "testapp_author" WHERE "testapp_author"."id" IN (...)
 RecordTests.test_multiple_cache_ops:
 - cache|set: foo
 - cache|second|get_many:
diff --git a/tests/test_sql.py b/tests/test_sql.py
--- a/tests/test_sql.py
+++ b/tests/test_sql.py
@@ -270,3 +270,42 @@ def test_strip_raw_query():
             + '"table_b" WHERE "table_b"."id" = # ) = true)'
         )
     )
+
+
+def test_in_single_value():
+    assert (
+        sql_fingerprint("SELECT `f1`, `f2` FROM `b` WHERE `x` IN (1)")
+        == "SELECT ... FROM `b` WHERE `x` IN (...)"
+    )
+
+
+def test_in_multiple_values():
+    assert (
+        sql_fingerprint("SELECT `f1`, `f2` FROM `b` WHERE `x` IN (1, 2, 3)")
+        == "SELECT ... FROM `b` WHERE `x` IN (...)"
+    )
+
+
+def test_in_multiple_clauses():
+    assert (
+        sql_fingerprint(
+            "SELECT `f1`, `f2` FROM `b` WHERE `x` IN (1, 2, 3) AND `y` IN (4, 5, 6)"
+        )
+        == "SELECT ... FROM `b` WHERE `x` IN (...) AND `y` IN (...)"
+    )
+
+
+def test_in_multiple_values_and_clause():
+    assert (
+        sql_fingerprint(
+            "SELECT `f1`, `f2` FROM `b` WHERE `x` IN (1, 2, 3) AND (`y` = 1 OR `y` = 2)"
+        )
+        == "SELECT ... FROM `b` WHERE `x` IN (...) AND (`y` = # OR `y` = #)"
+    )
+
+
+def test_in_subquery():
+    assert (
+        sql_fingerprint("SELECT `f1`, `f2` FROM `b` WHERE `x` IN (SELECT 1)")
+        == "SELECT ... FROM `b` WHERE `x` IN (SELECT #)"
+    )