From 5fed7fd718c0693f9648d6a517a69614cf5289c5 Mon Sep 17 00:00:00 2001 From: Diego Date: Tue, 18 Aug 2026 14:08:07 -0300 Subject: [PATCH] Fix special commands being broken in explain mode In PGExecute.run() the explain prefix was applied in an if/elif with the pgspecial branch: if explain_mode: sql = self.explain_prefix() + sql elif pgspecial: ...detect and run special commands... So with explain mode (F5) on, the pgspecial branch was never reached and every input was prefixed with EXPLAIN (...) and sent to the server as SQL. \q, \d, \i, named queries and the bare words exit/quit all came back with `syntax error at or near "\"`, leaving no way to turn explain mode off or to quit the session. Special commands are not SQL, so they are now detected first, regardless of explain mode, and the EXPLAIN prefix is applied further down to statements that fall through as normal SQL. This also fixes `select ... \G` in explain mode: the \G is stripped by the pgspecial branch, which previously never ran. Adds four tests: a special command is dispatched (and never sent to the server), a describe command runs as special, normal SQL is still prefixed, and \G is stripped. --- changelog.rst | 6 +++++ pgcli/pgexecute.py | 11 +++++--- tests/test_pgexecute.py | 56 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/changelog.rst b/changelog.rst index 6e4c47c36..d7e9aa61d 100644 --- a/changelog.rst +++ b/changelog.rst @@ -3,6 +3,12 @@ Upcoming (TBD) Bug fixes: ---------- +* Fix special commands being broken while explain mode (F5) is on. Every input + was prefixed with ``EXPLAIN (...)`` and sent to the server as SQL, including + backslash commands and the bare words ``exit``/``quit``, so ``\q``, ``\d``, + ``\i``, named queries and ``\G`` all failed with ``syntax error at or near + "\"`` and there was no way to leave explain mode or quit. Special commands + are now detected first and the EXPLAIN prefix is applied only to real SQL. * Restore cursor shape behaviour for Emacs mode * Fix ``TypeError: cannot use a string pattern on a bytes-like object`` when completion metadata comes back as bytes (e.g. ``SQL_ASCII`` client encoding). diff --git a/pgcli/pgexecute.py b/pgcli/pgexecute.py index 578f8291d..0f1e3e47f 100644 --- a/pgcli/pgexecute.py +++ b/pgcli/pgexecute.py @@ -373,9 +373,11 @@ def run( if not sql: continue try: - if explain_mode: - sql = self.explain_prefix() + sql - elif pgspecial: + # Try special commands first, regardless of explain mode: they + # are not SQL, so prefixing them with EXPLAIN just sends garbage + # to the server. The EXPLAIN prefix is applied further down, to + # statements that are not special commands. + if pgspecial: # \G is treated specially since we have to set the expanded output. if sql.endswith("\\G"): if not pgspecial.expanded_output: @@ -410,6 +412,9 @@ def run( pass # Not a special command, so execute as normal sql + if explain_mode: + sql = self.explain_prefix() + sql + yield self.execute_normal_sql(sql) + (sql, True, False) except psycopg.DatabaseError as e: _logger.error("sql: %r, error: %r", sql, e) diff --git a/tests/test_pgexecute.py b/tests/test_pgexecute.py index 9a8c942e7..c5fcaa2cd 100644 --- a/tests/test_pgexecute.py +++ b/tests/test_pgexecute.py @@ -755,6 +755,62 @@ def execute(self, *args, **kwargs): self.protocol_message = "Command not supported" +@dbtest +def test_explain_mode_does_not_wrap_special_command(executor): + """A special command is dispatched as a special command in explain mode. + + Prefixing it with EXPLAIN would send it to the server as invalid SQL, which + used to make it impossible to even quit while explain mode was on. + """ + quit_handler = MagicMock() + pgspecial = PGSpecial() + pgspecial.register( + quit_handler, + "\\q", + "\\q", + "Quit pgcli.", + arg_type=NO_QUERY, + case_sensitive=True, + aliases=(":q",), + ) + with patch.object(executor, "execute_normal_sql") as normal_sql: + list(executor.run("\\q", pgspecial=pgspecial, explain_mode=True)) + + quit_handler.assert_called_once() + normal_sql.assert_not_called() + + +@dbtest +def test_explain_mode_runs_describe_as_special(executor, pgspecial): + """A describe command still runs as a special command in explain mode.""" + with patch.object(executor, "execute_normal_sql") as normal_sql: + result = list(executor.run("\\dt", pgspecial=pgspecial, explain_mode=True)) + + normal_sql.assert_not_called() + assert result[0][6] is True # is_special + + +@dbtest +def test_explain_mode_wraps_normal_sql(executor, pgspecial): + """Normal SQL is still prefixed with EXPLAIN in explain mode.""" + with patch.object(executor, "execute_normal_sql", return_value=("", None, None, "")) as normal_sql: + list(executor.run("select 1", pgspecial=pgspecial, explain_mode=True)) + + normal_sql.assert_called_once() + assert normal_sql.call_args.args[0] == executor.explain_prefix() + "select 1" + + +@dbtest +def test_explain_mode_strips_G_suffix(executor, pgspecial): + """`select ... \\G` strips the \\G in explain mode instead of sending it.""" + with patch.object(executor, "execute_normal_sql", return_value=("", None, None, "")) as normal_sql: + list(executor.run("select 1 \\G", pgspecial=pgspecial, explain_mode=True)) + + sent = normal_sql.call_args.args[0] + assert sent == executor.explain_prefix() + "select 1" + assert "\\G" not in sent + + @dbtest def test_exit_without_active_connection(executor): quit_handler = MagicMock()