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()