From 9e417868152ec2f8eb5137380e6ae622d40163bc Mon Sep 17 00:00:00 2001 From: thisismyurl <122108986+thisismyurl@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:04:42 -0400 Subject: [PATCH] fix: #5348 escape literal % in search SQL before Article.objects.raw() postgres_search() builds its final query by wrapping stringify_queryset() in Article.objects.raw(). stringify_queryset() returns SQL with all params already interpolated via cursor.mogrify(), so any '%' left in the string is literal (e.g. a search term like "50%"). raw() defaults params to () rather than None, so it re-runs %-formatting on that SQL and raised TypeError on terms containing '%'. Double the literal '%' so they survive that pass. Adds a regression test covering a '%'-bearing search term. --- src/submission/models.py | 8 +++++ src/submission/tests/test_article_search.py | 36 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/submission/models.py b/src/submission/models.py index 2b3a281e83..3900deaa47 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -891,6 +891,14 @@ def postgres_search( # distinct fields to match order_by fields inner_sql = self.stringify_queryset(queryset) + # stringify_queryset() returns SQL with every parameter already + # interpolated via cursor.mogrify(), so any '%' left in the string is a + # literal (e.g. from a search term like "50%" or a LIKE pattern). Article + # .objects.raw() defaults params to () rather than None, so it re-runs + # %-formatting on this SQL and chokes on those literal '%'. Double them so + # they survive that pass unchanged. (#5348) + inner_sql = inner_sql.replace("%", "%%") + if "relevance" in sort: # Relevance is not a field but an annotation return Article.objects.raw( diff --git a/src/submission/tests/test_article_search.py b/src/submission/tests/test_article_search.py index bc8237a5f1..e2829c1148 100644 --- a/src/submission/tests/test_article_search.py +++ b/src/submission/tests/test_article_search.py @@ -183,3 +183,39 @@ def test_article_search_title(self): result = [a for a in queryset] self.assertEqual(result, [article]) + + @override_settings(ENABLE_FULL_TEXT_SEARCH=True) + def test_article_search_term_containing_percent(self): + """A search term containing '%' must not raise (#5348). + + stringify_queryset() mogrifies the term into the raw SQL, so a literal + '%' from the term reaches Article.objects.raw(), which re-runs + %-formatting on the string and previously raised. Evaluating the search + should now complete instead of erroring. + """ + from django.db import connection + + if connection.vendor == "sqlite": + # The bug is in postgres_search()'s raw() query; sqlite falls back to + # mysql_search() and never hits the affected code path. + return + + models.Article.objects.create( + journal=self.journal_one, + title="Save 50% on warp-drive systems", + date_published=FROZEN_DATETIME_2020, + stage=models.STAGE_PUBLISHED, + ) + + # Mysql can't search at all without FULLTEXT indexes installed + call_command("generate_search_indexes") + + search_filters = {"title": True} + try: + # Forces the RawQuerySet to execute; before the fix this raised + # TypeError ("not enough arguments for format string"). + result = list(models.Article.objects.search("50%", search_filters)) + except TypeError as exc: + self.fail(f"search with a '%' term raised (regression of #5348): {exc}") + + self.assertIsInstance(result, list)