From 9448bdba750224abb3647cd05e864f6c9b096324 Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Tue, 14 Mar 2023 15:58:01 +0300 Subject: [PATCH 01/10] Remove unhelpful comments --- debug_toolbar/panels/sql/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index 0fbba3e90..c8be12618 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -12,7 +12,6 @@ class BoldKeywordFilter: """sqlparse filter to bold SQL keywords""" def process(self, stream): - """Process the token stream""" for token_type, value in stream: is_keyword = token_type in T.Keyword if is_keyword: @@ -55,7 +54,7 @@ def get_filter_stack(prettify, aligned_indent): stack.stmtprocess.append( sqlparse.filters.AlignedIndentFilter(char=" ", n="
") ) - stack.preprocess.append(BoldKeywordFilter()) # add our custom filter + stack.preprocess.append(BoldKeywordFilter()) stack.postprocess.append(sqlparse.filters.SerializerUnicode()) # tokens -> strings return stack From ee98c58659ec358d5062a7455d9f452021273f5b Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Tue, 14 Mar 2023 18:53:29 +0300 Subject: [PATCH 02/10] Use Python's built-in html.escape() Because the token values escaped by BoldKeywordFilter are simply intermediate values and are not directly included in HTML templates, use Python's html.escape() instead of django.utils.html.escape() to eliminate the overhead of converting the token values to SafeString. Also pass quote=False when calling escape() since the token values will not be used in quoted attributes. --- debug_toolbar/panels/sql/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index c8be12618..a17308bb8 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -1,8 +1,8 @@ import re from functools import lru_cache +from html import escape import sqlparse -from django.utils.html import escape from sqlparse import tokens as T from debug_toolbar import settings as dt_settings @@ -16,7 +16,7 @@ def process(self, stream): is_keyword = token_type in T.Keyword if is_keyword: yield T.Text, "" - yield token_type, escape(value) + yield token_type, escape(value, quote=False) if is_keyword: yield T.Text, "" From cd9a189c81fc70978d5afcf69eddcba4b4395071 Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Thu, 2 Jun 2022 17:26:51 +0300 Subject: [PATCH 03/10] Replace sqlparse.filters.SerializerUnicode() usage sqlparse's SerializerUnicode filter does a bunch of fancy whitespace processing which isn't needed because the resulting string will just be inserted into HTML. Replace with a simple EscapedStringSerializer that does nothing but convert the Statement to a properly-escaped string. In the process stop the escaping within BoldKeywordFilter to have a cleaner separation of concerns: BoldKeywordFilter now only handles marking up keywords as bold, while escaping is explicitly handled by the EscapedStringSerializer. --- debug_toolbar/panels/sql/utils.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index a17308bb8..cef2330bb 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -15,10 +15,27 @@ def process(self, stream): for token_type, value in stream: is_keyword = token_type in T.Keyword if is_keyword: - yield T.Text, "" - yield token_type, escape(value, quote=False) + yield T.Other, "" + yield token_type, value if is_keyword: - yield T.Text, "" + yield T.Other, "" + + +def escaped_value(token): + # Don't escape T.Whitespace tokens because AlignedIndentFilter inserts its tokens as + # T.Whitesapce, and in our case those tokens are actually HTML. + if token.ttype in (T.Other, T.Whitespace): + return token.value + return escape(token.value, quote=False) + + +class EscapedStringSerializer: + """sqlparse post-processor to convert a Statement into a string escaped for + inclusion in HTML .""" + + @staticmethod + def process(stmt): + return "".join(escaped_value(token) for token in stmt.flatten()) def reformat_sql(sql, with_toggle=False): @@ -55,7 +72,7 @@ def get_filter_stack(prettify, aligned_indent): sqlparse.filters.AlignedIndentFilter(char=" ", n="
") ) stack.preprocess.append(BoldKeywordFilter()) - stack.postprocess.append(sqlparse.filters.SerializerUnicode()) # tokens -> strings + stack.postprocess.append(EscapedStringSerializer()) # Statement -> str return stack From 496c97ddb8e529c821d4b083c47f239b8c73605d Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Thu, 2 Jun 2022 17:28:26 +0300 Subject: [PATCH 04/10] Replace select-list elision implementation Instead of using a regex to elide the select list in the simplified representation of an SQL query, use an sqlparse filter to elide the select list as a preprocessing step. The result ends up being about 10% faster. --- debug_toolbar/panels/sql/utils.py | 62 ++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index cef2330bb..cdba66364 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -1,4 +1,3 @@ -import re from functools import lru_cache from html import escape @@ -8,6 +7,38 @@ from debug_toolbar import settings as dt_settings +class ElideSelectListsFilter: + """sqlparse filter to elide the select list in SELECT ... FROM clauses""" + + def process(self, stream): + for token_type, value in stream: + yield token_type, value + if token_type in T.Keyword and value.upper() == "SELECT": + yield from self.elide_until_from(stream) + + @staticmethod + def elide_until_from(stream): + select_list_characters = 0 + select_list_tokens = [] + for token_type, value in stream: + if token_type in T.Keyword and value.upper() == "FROM": + # Do not elide a select list of 12 characters or fewer to preserve + # SELECT COUNT(*) FROM ... + # and + # SELECT (1) AS `a` FROM ... + # queries. + if select_list_characters <= 12: + yield from select_list_tokens + else: + # U+2022: Unicode character 'BULLET' + yield T.Other, " \u2022\u2022\u2022 " + yield token_type, value + break + if select_list_characters <= 12: + select_list_characters += len(value) + select_list_tokens.append((token_type, value)) + + class BoldKeywordFilter: """sqlparse filter to bold SQL keywords""" @@ -39,35 +70,37 @@ def process(stmt): def reformat_sql(sql, with_toggle=False): - formatted = parse_sql(sql, aligned_indent=True) + formatted = parse_sql(sql) if not with_toggle: return formatted - simple = simplify(parse_sql(sql, aligned_indent=False)) - uncollapsed = f'{simple}' + simplified = parse_sql(sql, simplify=True) + uncollapsed = f'{simplified}' collapsed = f'{formatted}' return collapsed + uncollapsed -def parse_sql(sql, aligned_indent=False): +def parse_sql(sql, *, simplify=False): return _parse_sql( sql, - dt_settings.get_config()["PRETTIFY_SQL"], - aligned_indent, + prettify=dt_settings.get_config()["PRETTIFY_SQL"], + simplify=simplify, ) @lru_cache(maxsize=128) -def _parse_sql(sql, pretty, aligned_indent): - stack = get_filter_stack(pretty, aligned_indent) +def _parse_sql(sql, *, prettify, simplify): + stack = get_filter_stack(prettify=prettify, simplify=simplify) return "".join(stack.run(sql)) @lru_cache(maxsize=None) -def get_filter_stack(prettify, aligned_indent): +def get_filter_stack(*, prettify, simplify): stack = sqlparse.engine.FilterStack() if prettify: stack.enable_grouping() - if aligned_indent: + if simplify: + stack.preprocess.append(ElideSelectListsFilter()) + else: stack.stmtprocess.append( sqlparse.filters.AlignedIndentFilter(char=" ", n="
") ) @@ -76,13 +109,6 @@ def get_filter_stack(prettify, aligned_indent): return stack -simplify_re = re.compile(r"SELECT (...........*?) FROM") - - -def simplify(sql): - return simplify_re.sub(r"SELECT ••• FROM", sql) - - def contrasting_color_generator(): """ Generate contrasting colors by varying most significant bit of RGB first, From 3b881fb98d370467e90240e1adc1f308dfc317b8 Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Thu, 30 Mar 2023 13:59:26 +0300 Subject: [PATCH 05/10] Use better heuristic for select list elision Instead of only eliding select lists longer than 12 characters, now only elide select lists that contain a dot (from a column expression like `table_name`.`column_name`). The motivation for this is that as of Django 1.10, using .count() on a queryset generates SELECT COUNT(*) AS `__count` FROM ... instead of SELECT COUNT(*) FROM ... queries. This change prevents the new form from being elided. --- debug_toolbar/panels/sql/utils.py | 21 ++++++++++++--------- tests/panels/test_sql.py | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index cdba66364..3950dafdc 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -18,25 +18,28 @@ def process(self, stream): @staticmethod def elide_until_from(stream): - select_list_characters = 0 - select_list_tokens = [] + has_dot = False + saved_tokens = [] for token_type, value in stream: if token_type in T.Keyword and value.upper() == "FROM": - # Do not elide a select list of 12 characters or fewer to preserve - # SELECT COUNT(*) FROM ... + # Do not elide a select lists that do not contain dots (used to separate + # table names from column names) in order to preserve + # SELECT COUNT(*) AS `__count` FROM ... # and # SELECT (1) AS `a` FROM ... # queries. - if select_list_characters <= 12: - yield from select_list_tokens + if not has_dot: + yield from saved_tokens else: # U+2022: Unicode character 'BULLET' yield T.Other, " \u2022\u2022\u2022 " yield token_type, value break - if select_list_characters <= 12: - select_list_characters += len(value) - select_list_tokens.append((token_type, value)) + if not has_dot: + if token_type in T.Punctuation and value == ".": + has_dot = True + else: + saved_tokens.append((token_type, value)) class BoldKeywordFilter: diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 13e3625ba..6e1a50197 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -495,6 +495,21 @@ def test_prettify_sql(self): self.assertEqual(len(self.panel._queries), 1) self.assertEqual(pretty_sql, self.panel._queries[-1]["sql"]) + def test_simplification(self): + """ + Test case to validate that select lists for .count() and .exist() queries do not + get elided, but other select lists do. + """ + User.objects.count() + User.objects.exists() + list(User.objects.values_list("id")) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + self.assertEqual(len(self.panel._queries), 3) + self.assertNotIn("\u2022", self.panel._queries[0]["sql"]) + self.assertNotIn("\u2022", self.panel._queries[1]["sql"]) + self.assertIn("\u2022", self.panel._queries[2]["sql"]) + @override_settings( DEBUG=True, ) From ef9cfbb149cc9cda075d5abf1b5a6f5c36fcbd51 Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Tue, 14 Mar 2023 22:32:49 +0300 Subject: [PATCH 06/10] Only elide top-level select lists If a query has subselects in its WHERE clause, do not elide the select lists in those subselects. --- debug_toolbar/panels/sql/utils.py | 11 ++++++--- tests/panels/test_sql.py | 38 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index 3950dafdc..3c99facf5 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -8,13 +8,18 @@ class ElideSelectListsFilter: - """sqlparse filter to elide the select list in SELECT ... FROM clauses""" + """sqlparse filter to elide the select list from top-level SELECT ... FROM clauses, + if present""" def process(self, stream): + allow_elision = True for token_type, value in stream: yield token_type, value - if token_type in T.Keyword and value.upper() == "SELECT": - yield from self.elide_until_from(stream) + if token_type in T.Keyword: + keyword = value.upper() + if allow_elision and keyword == "SELECT": + yield from self.elide_until_from(stream) + allow_elision = keyword in ["EXCEPT", "INTERSECT", "UNION"] @staticmethod def elide_until_from(stream): diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 6e1a50197..a597d4c11 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -510,6 +510,44 @@ def test_simplification(self): self.assertNotIn("\u2022", self.panel._queries[1]["sql"]) self.assertIn("\u2022", self.panel._queries[2]["sql"]) + def test_top_level_simplification(self): + """ + Test case to validate that top-level select lists get elided, but other select + lists for subselects do not. + """ + list(User.objects.filter(id__in=User.objects.filter(is_staff=True))) + list(User.objects.filter(id__lt=20).union(User.objects.filter(id__gt=10))) + if connection.vendor != "mysql": + list( + User.objects.filter(id__lt=20).intersection( + User.objects.filter(id__gt=10) + ) + ) + list( + User.objects.filter(id__lt=20).difference( + User.objects.filter(id__gt=10) + ) + ) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + if connection.vendor != "mysql": + self.assertEqual(len(self.panel._queries), 4) + else: + self.assertEqual(len(self.panel._queries), 2) + # WHERE ... IN SELECT ... queries should have only one elided select list + self.assertEqual(self.panel._queries[0]["sql"].count("SELECT"), 4) + self.assertEqual(self.panel._queries[0]["sql"].count("\u2022"), 3) + # UNION queries should have two elidid select lists + self.assertEqual(self.panel._queries[1]["sql"].count("SELECT"), 4) + self.assertEqual(self.panel._queries[1]["sql"].count("\u2022"), 6) + if connection.vendor != "mysql": + # INTERSECT queries should have two elidid select lists + self.assertEqual(self.panel._queries[2]["sql"].count("SELECT"), 4) + self.assertEqual(self.panel._queries[2]["sql"].count("\u2022"), 6) + # EXCEPT queries should have two elidid select lists + self.assertEqual(self.panel._queries[3]["sql"].count("SELECT"), 4) + self.assertEqual(self.panel._queries[3]["sql"].count("\u2022"), 6) + @override_settings( DEBUG=True, ) From f45e1366888380da30c7d0fbfebc235d4772a395 Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Wed, 29 Mar 2023 16:28:40 +0300 Subject: [PATCH 07/10] Apply BoldKeywordFilter after AlignedIndentFilter The "" tokens inserted by the BoldKeywordFilter were causing the AlignedIndentFilter to apply excessive indentation to queries which used CASE statements. Fix by rewriting BoldIndentFilter as a statement filter rather than a preprocess filter, and applying after AlignedIndentFilter. --- debug_toolbar/panels/sql/utils.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index 3c99facf5..cbe275ff3 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -50,14 +50,21 @@ def elide_until_from(stream): class BoldKeywordFilter: """sqlparse filter to bold SQL keywords""" - def process(self, stream): - for token_type, value in stream: - is_keyword = token_type in T.Keyword - if is_keyword: - yield T.Other, "" - yield token_type, value - if is_keyword: - yield T.Other, "" + def process(self, stmt): + idx = 0 + while idx < len(stmt.tokens): + token = stmt[idx] + if token.is_keyword: + stmt.insert_before(idx, sqlparse.sql.Token(T.Other, "")) + stmt.insert_after( + idx + 1, + sqlparse.sql.Token(T.Other, ""), + skip_ws=False, + ) + idx += 2 + elif token.is_group: + self.process(token) + idx += 1 def escaped_value(token): @@ -112,7 +119,7 @@ def get_filter_stack(*, prettify, simplify): stack.stmtprocess.append( sqlparse.filters.AlignedIndentFilter(char=" ", n="
") ) - stack.preprocess.append(BoldKeywordFilter()) + stack.stmtprocess.append(BoldKeywordFilter()) stack.postprocess.append(EscapedStringSerializer()) # Statement -> str return stack From 255efeb6da23ab2ad381426b059eab3462cfa0fe Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Wed, 29 Mar 2023 16:43:48 +0300 Subject: [PATCH 08/10] Only enable SQL grouping for AlignedIndentFilter When formatting SQL statements using sqparse, grouping only affects the output when AlignedIndentFilter is applied. --- debug_toolbar/panels/sql/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index cbe275ff3..c47c19142 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -111,11 +111,11 @@ def _parse_sql(sql, *, prettify, simplify): @lru_cache(maxsize=None) def get_filter_stack(*, prettify, simplify): stack = sqlparse.engine.FilterStack() - if prettify: - stack.enable_grouping() if simplify: stack.preprocess.append(ElideSelectListsFilter()) else: + if prettify: + stack.enable_grouping() stack.stmtprocess.append( sqlparse.filters.AlignedIndentFilter(char=" ", n="
") ) From 375181212f59f6d0ac5894dea5bb09f04e7b96cd Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Wed, 29 Mar 2023 17:08:04 +0300 Subject: [PATCH 09/10] Eliminate intermediate _parse_sql() method By using a settings_changed signal receiver to clear the query caching, the parse_sql() and _parse_sql() functions can be merged and the check for the "PRETTIFY_SQL" setting can be moved back inside the get_filter_stack() function. --- debug_toolbar/panels/sql/utils.py | 25 +++++++++---------- tests/panels/test_sql.py | 40 ++++++++++++++----------------- 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/debug_toolbar/panels/sql/utils.py b/debug_toolbar/panels/sql/utils.py index c47c19142..efd7c1637 100644 --- a/debug_toolbar/panels/sql/utils.py +++ b/debug_toolbar/panels/sql/utils.py @@ -2,6 +2,8 @@ from html import escape import sqlparse +from django.dispatch import receiver +from django.test.signals import setting_changed from sqlparse import tokens as T from debug_toolbar import settings as dt_settings @@ -94,27 +96,19 @@ def reformat_sql(sql, with_toggle=False): return collapsed + uncollapsed -def parse_sql(sql, *, simplify=False): - return _parse_sql( - sql, - prettify=dt_settings.get_config()["PRETTIFY_SQL"], - simplify=simplify, - ) - - @lru_cache(maxsize=128) -def _parse_sql(sql, *, prettify, simplify): - stack = get_filter_stack(prettify=prettify, simplify=simplify) +def parse_sql(sql, *, simplify=False): + stack = get_filter_stack(simplify=simplify) return "".join(stack.run(sql)) @lru_cache(maxsize=None) -def get_filter_stack(*, prettify, simplify): +def get_filter_stack(*, simplify): stack = sqlparse.engine.FilterStack() if simplify: stack.preprocess.append(ElideSelectListsFilter()) else: - if prettify: + if dt_settings.get_config()["PRETTIFY_SQL"]: stack.enable_grouping() stack.stmtprocess.append( sqlparse.filters.AlignedIndentFilter(char=" ", n="
") @@ -124,6 +118,13 @@ def get_filter_stack(*, prettify, simplify): return stack +@receiver(setting_changed) +def clear_caches(*, setting, **kwargs): + if setting == "DEBUG_TOOLBAR_CONFIG": + parse_sql.cache_clear() + get_filter_stack.cache_clear() + + def contrasting_color_generator(): """ Generate contrasting colors by varying most significant bit of RGB first, diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index a597d4c11..7b3452935 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -14,7 +14,6 @@ from django.test.utils import override_settings import debug_toolbar.panels.sql.tracking as sql_tracking -from debug_toolbar import settings as dt_settings try: import psycopg @@ -458,42 +457,39 @@ def test_regression_infinite_recursion(self): # ensure the stacktrace is populated self.assertTrue(len(query["stacktrace"]) > 0) - @override_settings( - DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": True}, - ) def test_prettify_sql(self): """ Test case to validate that the PRETTIFY_SQL setting changes the output of the sql when it's toggled. It does not validate what it does though. """ - list(User.objects.filter(username__istartswith="spam")) - - response = self.panel.process_request(self.request) - self.panel.generate_stats(self.request, response) - pretty_sql = self.panel._queries[-1]["sql"] - self.assertEqual(len(self.panel._queries), 1) + with override_settings(DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": True}): + list(User.objects.filter(username__istartswith="spam")) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + pretty_sql = self.panel._queries[-1]["sql"] + self.assertEqual(len(self.panel._queries), 1) # Reset the queries self.panel._queries = [] # Run it again, but with prettify off. Verify that it's different. - dt_settings.get_config()["PRETTIFY_SQL"] = False - list(User.objects.filter(username__istartswith="spam")) - response = self.panel.process_request(self.request) - self.panel.generate_stats(self.request, response) - self.assertEqual(len(self.panel._queries), 1) - self.assertNotEqual(pretty_sql, self.panel._queries[-1]["sql"]) + with override_settings(DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": False}): + list(User.objects.filter(username__istartswith="spam")) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + self.assertEqual(len(self.panel._queries), 1) + self.assertNotEqual(pretty_sql, self.panel._queries[-1]["sql"]) self.panel._queries = [] # Run it again, but with prettify back on. # This is so we don't have to check what PRETTIFY_SQL does exactly, # but we know it's doing something. - dt_settings.get_config()["PRETTIFY_SQL"] = True - list(User.objects.filter(username__istartswith="spam")) - response = self.panel.process_request(self.request) - self.panel.generate_stats(self.request, response) - self.assertEqual(len(self.panel._queries), 1) - self.assertEqual(pretty_sql, self.panel._queries[-1]["sql"]) + with override_settings(DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": True}): + list(User.objects.filter(username__istartswith="spam")) + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + self.assertEqual(len(self.panel._queries), 1) + self.assertEqual(pretty_sql, self.panel._queries[-1]["sql"]) def test_simplification(self): """ From e34ec83be2c36eb526b65f8519900f1eb5270914 Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Thu, 30 Mar 2023 15:11:05 +0300 Subject: [PATCH 10/10] Amend change log --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 167bef554..efa84fa45 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -20,6 +20,9 @@ Pending is rendered, so that the correct values will be displayed in the rendered stack trace, as they may have changed between the time the stack trace was captured and when it is rendered. +* Improved SQL statement formatting performance. Additionally, fixed the + indentation of ``CASE`` statements and stopped simplifying ``.count()`` + queries. 3.8.1 (2022-12-03) ------------------