From 7d04ea06cd163df327e501cf62933a75e8ae0fe0 Mon Sep 17 00:00:00 2001 From: DiegoDAF Date: Fri, 5 Dec 2025 15:35:56 -0300 Subject: [PATCH 1/4] Add support for -f/--file option to execute SQL from files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for the -f/--file option to pgcli, similar to psql's behavior. Users can now execute SQL commands from files and exit immediately after execution. Features: - Single file execution: pgcli -f file.sql - Multiple files: pgcli -f file1.sql -f file2.sql - Long form: pgcli --file file.sql - Files are executed sequentially - Pager is automatically disabled in file mode - Proper error handling and exit codes Tests included for all scenarios. Made with ❤️ and 🤖 Claude Code Co-Authored-By: Claude --- changelog.rst | 3 + pgcli/main.py | 41 ++++++++- tests/features/file_option.feature | 33 +++++++ tests/features/steps/file_option.py | 138 ++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 tests/features/file_option.feature create mode 100644 tests/features/steps/file_option.py diff --git a/changelog.rst b/changelog.rst index 96eefd747..31e09c7cb 100644 --- a/changelog.rst +++ b/changelog.rst @@ -9,6 +9,9 @@ Features: * Support dsn specific init-command in the config file * Add suggestion when setting the search_path * Allow per dsn_alias ssh tunnel selection +* Add support for executing SQL commands from file and exit. + * Command line option `-f` or `--file`. + * Multiple files can be specified. Internal: --------- diff --git a/pgcli/main.py b/pgcli/main.py index 0b4b64f59..98dc41817 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -911,6 +911,32 @@ def _check_ongoing_transaction_and_allow_quitting(self): def run_cli(self): logger = self.logger + # Handle file mode (-f flag) - similar to psql behavior + # Multiple -f options are executed sequentially + if hasattr(self, 'input_files') and self.input_files: + try: + for input_file in self.input_files: + logger.debug("Reading commands from file: %s", input_file) + with open(input_file, 'r', encoding='utf-8') as f: + file_content = f.read() + + # Execute the entire file content as a single command + # This matches psql behavior where the file is treated as one unit + if file_content.strip(): + logger.debug("Executing commands from file: %s", input_file) + self.handle_watch_command(file_content) + + except PgCliQuitError: + # Normal exit from quit command + sys.exit(0) + except Exception as e: + logger.error("Error executing command: %s", e) + logger.error("traceback: %r", traceback.format_exc()) + click.secho(str(e), err=True, fg="red") + sys.exit(1) + # Exit successfully after executing all commands + sys.exit(0) + history_file = self.config["main"]["history_file"] if history_file == "default": history_file = config_location() + "history" @@ -1278,7 +1304,8 @@ def is_too_tall(self, lines): return len(lines) >= (self.prompt_app.output.get_size().rows - 4) def echo_via_pager(self, text, color=None): - if self.pgspecial.pager_config == PAGER_OFF or self.watch_command: + # Disable pager for -f/--file mode and \watch command + if self.pgspecial.pager_config == PAGER_OFF or self.watch_command or (hasattr(self, 'input_files') and self.input_files): click.echo(text, color=color) elif self.pgspecial.pager_config == PAGER_LONG_OUTPUT and self.table_format != "csv": lines = text.split("\n") @@ -1426,6 +1453,14 @@ def echo_via_pager(self, text, color=None): type=str, help="SQL statement to execute after connecting.", ) +@click.option( + "-f", + "--file", + "input_files", + multiple=True, + type=click.Path(exists=True, readable=True, dir_okay=False), + help="execute commands from file, then exit. Multiple -f options are allowed.", +) @click.argument("dbname", default=lambda: None, envvar="PGDATABASE", nargs=1) @click.argument("username", default=lambda: None, envvar="PGUSER", nargs=1) def cli( @@ -1454,6 +1489,7 @@ def cli( ssh_tunnel: str, init_command: str, log_file: str, + input_files: tuple, ): if version: print("Version:", __version__) @@ -1514,6 +1550,9 @@ def cli( log_file=log_file, ) + # Store file paths for -f option (can be multiple) + pgcli.input_files = input_files if input_files else None + # Choose which ever one has a valid value. if dbname_opt and dbname: # work as psql: when database is given as option and argument use the argument as user diff --git a/tests/features/file_option.feature b/tests/features/file_option.feature new file mode 100644 index 000000000..4533c7c11 --- /dev/null +++ b/tests/features/file_option.feature @@ -0,0 +1,33 @@ +Feature: run the cli with -f/--file option, + execute commands from file, + and exit + + Scenario: run pgcli with -f and a SQL query file + When we create a file with "SELECT 1 as test_diego_column" + and we run pgcli with -f and the file + then we see the query result + and pgcli exits successfully + + Scenario: run pgcli with --file and a SQL query file + When we create a file with "SELECT 'hello' as greeting" + and we run pgcli with --file and the file + then we see the query result + and pgcli exits successfully + + Scenario: run pgcli with -f and a file with special command + When we create a file with "\dt" + and we run pgcli with -f and the file + then we see the command output + and pgcli exits successfully + + Scenario: run pgcli with -f and a file with multiple statements + When we create a file with "SELECT 1; SELECT 2" + and we run pgcli with -f and the file + then we see both query results + and pgcli exits successfully + + Scenario: run pgcli with -f and a file with an invalid query + When we create a file with "SELECT invalid_column FROM nonexistent_table" + and we run pgcli with -f and the file + then we see an error message + and pgcli exits successfully diff --git a/tests/features/steps/file_option.py b/tests/features/steps/file_option.py new file mode 100644 index 000000000..31b109219 --- /dev/null +++ b/tests/features/steps/file_option.py @@ -0,0 +1,138 @@ +""" +Steps for testing -f/--file option behavioral tests. +""" + +import subprocess +import tempfile +import os +from behave import when, then + + +@when('we create a file with "{content}"') +def step_create_file_with_content(context, content): + """Create a temporary file with the given content.""" + # Create a temporary file that will be cleaned up automatically + temp_file = tempfile.NamedTemporaryFile( + mode='w', + delete=False, + suffix='.sql' + ) + temp_file.write(content) + temp_file.close() + context.temp_file_path = temp_file.name + + +@when('we run pgcli with -f and the file') +def step_run_pgcli_with_f(context): + """Run pgcli with -f flag and the temporary file.""" + cmd = [ + "pgcli", + "-h", context.conf["host"], + "-p", str(context.conf["port"]), + "-U", context.conf["user"], + "-d", context.conf["dbname"], + "-f", context.temp_file_path + ] + try: + context.cmd_output = subprocess.check_output( + cmd, + cwd=context.package_root, + stderr=subprocess.STDOUT, + timeout=5 + ) + context.exit_code = 0 + except subprocess.CalledProcessError as e: + context.cmd_output = e.output + context.exit_code = e.returncode + except subprocess.TimeoutExpired as e: + context.cmd_output = b"Command timed out" + context.exit_code = -1 + finally: + # Clean up the temporary file + if hasattr(context, 'temp_file_path') and os.path.exists(context.temp_file_path): + os.unlink(context.temp_file_path) + + +@when('we run pgcli with --file and the file') +def step_run_pgcli_with_file(context): + """Run pgcli with --file flag and the temporary file.""" + cmd = [ + "pgcli", + "-h", context.conf["host"], + "-p", str(context.conf["port"]), + "-U", context.conf["user"], + "-d", context.conf["dbname"], + "--file", context.temp_file_path + ] + try: + context.cmd_output = subprocess.check_output( + cmd, + cwd=context.package_root, + stderr=subprocess.STDOUT, + timeout=5 + ) + context.exit_code = 0 + except subprocess.CalledProcessError as e: + context.cmd_output = e.output + context.exit_code = e.returncode + except subprocess.TimeoutExpired as e: + context.cmd_output = b"Command timed out" + context.exit_code = -1 + finally: + # Clean up the temporary file + if hasattr(context, 'temp_file_path') and os.path.exists(context.temp_file_path): + os.unlink(context.temp_file_path) + + +@then("we see the query result") +def step_see_query_result(context): + """Verify that the query result is in the output.""" + output = context.cmd_output.decode('utf-8') + # Check for common query result indicators + assert any([ + "SELECT" in output, + "test_diego_column" in output, + "greeting" in output, + "hello" in output, + "+-" in output, # table border + "|" in output, # table column separator + ]), f"Expected query result in output, but got: {output}" + + +@then("we see both query results") +def step_see_both_query_results(context): + """Verify that both query results are in the output.""" + output = context.cmd_output.decode('utf-8') + # Should contain output from both SELECT statements + assert "SELECT" in output, f"Expected SELECT in output, but got: {output}" + # The output should have multiple result sets + assert output.count("SELECT") >= 2, f"Expected at least 2 SELECT results, but got: {output}" + + +@then("we see the command output") +def step_see_command_output(context): + """Verify that the special command output is present.""" + output = context.cmd_output.decode('utf-8') + # For \dt we should see table-related output + # It might be empty if no tables exist, but shouldn't error + assert context.exit_code == 0, f"Expected exit code 0, but got: {context.exit_code}" + + +@then("we see an error message") +def step_see_error_message(context): + """Verify that an error message is in the output.""" + output = context.cmd_output.decode('utf-8') + assert any([ + "does not exist" in output, + "error" in output.lower(), + "ERROR" in output, + ]), f"Expected error message in output, but got: {output}" + + +@then("pgcli exits successfully") +def step_pgcli_exits_successfully(context): + """Verify that pgcli exited with code 0.""" + assert context.exit_code == 0, f"Expected exit code 0, but got: {context.exit_code}" + # Clean up + context.cmd_output = None + context.exit_code = None From 917148765920dfa8b69b78c3edf9c3b97c379f4d Mon Sep 17 00:00:00 2001 From: Diego Date: Mon, 20 Jul 2026 14:36:05 -0300 Subject: [PATCH 2/4] Fix style checks and assert on the \dt output The style gate started failing with ruff 0.15.x: the TimeoutExpired handlers bound `as e` without using it (F841), and step_see_command_output decoded cmd_output into `output` but never used it. The command-output step now asserts on the \dt column headers, which are rendered whether or not any tables exist, so it verifies the special command actually produced its listing. --- tests/features/steps/file_option.py | 63 ++++++++++++++--------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/tests/features/steps/file_option.py b/tests/features/steps/file_option.py index 31b109219..dd053c812 100644 --- a/tests/features/steps/file_option.py +++ b/tests/features/steps/file_option.py @@ -12,11 +12,7 @@ def step_create_file_with_content(context, content): """Create a temporary file with the given content.""" # Create a temporary file that will be cleaned up automatically - temp_file = tempfile.NamedTemporaryFile( - mode='w', - delete=False, - suffix='.sql' - ) + temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.sql') temp_file.write(content) temp_file.close() context.temp_file_path = temp_file.name @@ -27,24 +23,24 @@ def step_run_pgcli_with_f(context): """Run pgcli with -f flag and the temporary file.""" cmd = [ "pgcli", - "-h", context.conf["host"], - "-p", str(context.conf["port"]), - "-U", context.conf["user"], - "-d", context.conf["dbname"], - "-f", context.temp_file_path + "-h", + context.conf["host"], + "-p", + str(context.conf["port"]), + "-U", + context.conf["user"], + "-d", + context.conf["dbname"], + "-f", + context.temp_file_path, ] try: - context.cmd_output = subprocess.check_output( - cmd, - cwd=context.package_root, - stderr=subprocess.STDOUT, - timeout=5 - ) + context.cmd_output = subprocess.check_output(cmd, cwd=context.package_root, stderr=subprocess.STDOUT, timeout=5) context.exit_code = 0 except subprocess.CalledProcessError as e: context.cmd_output = e.output context.exit_code = e.returncode - except subprocess.TimeoutExpired as e: + except subprocess.TimeoutExpired: context.cmd_output = b"Command timed out" context.exit_code = -1 finally: @@ -58,24 +54,24 @@ def step_run_pgcli_with_file(context): """Run pgcli with --file flag and the temporary file.""" cmd = [ "pgcli", - "-h", context.conf["host"], - "-p", str(context.conf["port"]), - "-U", context.conf["user"], - "-d", context.conf["dbname"], - "--file", context.temp_file_path + "-h", + context.conf["host"], + "-p", + str(context.conf["port"]), + "-U", + context.conf["user"], + "-d", + context.conf["dbname"], + "--file", + context.temp_file_path, ] try: - context.cmd_output = subprocess.check_output( - cmd, - cwd=context.package_root, - stderr=subprocess.STDOUT, - timeout=5 - ) + context.cmd_output = subprocess.check_output(cmd, cwd=context.package_root, stderr=subprocess.STDOUT, timeout=5) context.exit_code = 0 except subprocess.CalledProcessError as e: context.cmd_output = e.output context.exit_code = e.returncode - except subprocess.TimeoutExpired as e: + except subprocess.TimeoutExpired: context.cmd_output = b"Command timed out" context.exit_code = -1 finally: @@ -95,7 +91,7 @@ def step_see_query_result(context): "greeting" in output, "hello" in output, "+-" in output, # table border - "|" in output, # table column separator + "|" in output, # table column separator ]), f"Expected query result in output, but got: {output}" @@ -113,8 +109,11 @@ def step_see_both_query_results(context): def step_see_command_output(context): """Verify that the special command output is present.""" output = context.cmd_output.decode('utf-8') - # For \dt we should see table-related output - # It might be empty if no tables exist, but shouldn't error + # `\dt` renders its column headers whether or not any tables exist, so the + # headers are what tells us the special command actually ran and produced + # its listing (rather than erroring out). + for header in ("Schema", "Name", "Type", "Owner"): + assert header in output, f"Expected {header!r} in \\dt output, but got: {output}" assert context.exit_code == 0, f"Expected exit code 0, but got: {context.exit_code}" From c724219541d77ba9202cf3518008907e2a429caf Mon Sep 17 00:00:00 2001 From: Diego Date: Mon, 31 Aug 2026 10:48:51 -0300 Subject: [PATCH 3/4] Run files one statement at a time so \watch stays scoped get_watch_command()'s regex captures all the text before a \watch, so feeding the whole file to handle_watch_command made \watch repeat every statement in it. Files now go through sqlparse.split() and run statement by statement, like psql: \watch repeats only its own statement, and a bare \watch picks up the previous one through query_history. A failed statement stops the rest of the file unless on_error is RESUME, matching what the single-block execution already did through pgexecute.run(). 7 tests; 5 fail without the fix. --- changelog.rst | 3 ++ pgcli/main.py | 31 ++++++++++++++-- tests/test_main.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/changelog.rst b/changelog.rst index 418961e9d..e4a8b31ec 100644 --- a/changelog.rst +++ b/changelog.rst @@ -6,6 +6,9 @@ Features: * Add support for executing SQL commands from file and exit. * Command line option `-f` or `--file`. * Multiple files can be specified. + * Files run one statement at a time, like psql, so a ``\watch`` only + repeats its own statement (and a bare ``\watch`` re-runs the statement + before it), instead of re-running the whole file. Bug fixes: ---------- diff --git a/pgcli/main.py b/pgcli/main.py index 97fe2a6ce..d037f26bf 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -1054,11 +1054,12 @@ def run_cli(self): with open(input_file, 'r', encoding='utf-8') as f: file_content = f.read() - # Execute the entire file content as a single command - # This matches psql behavior where the file is treated as one unit if file_content.strip(): logger.debug("Executing commands from file: %s", input_file) - self.handle_watch_command(file_content) + # Statement by statement, like psql -f: \watch only + # repeats its own statement, not the whole file. + if not self._execute_statements(file_content): + break except PgCliQuitError: # Normal exit from quit command @@ -1147,6 +1148,30 @@ def handle_watch_command(self, text): query = self.execute_command(text) self.query_history.append(query) + return query + + def _execute_statements(self, text): + r"""Run a block of SQL the way psql -f does: one statement at a time. + + get_watch_command()'s regex captures ALL the text before a \watch, so + feeding a whole file to handle_watch_command would make \watch repeat + every statement in it. Splitting first keeps \watch scoped to its own + statement, and a bare \watch picks up the previous statement through + query_history, exactly like psql. + + Honors on_error: with STOP, the first failed statement stops the run. + Returns True when every statement succeeded. + """ + ok = True + for statement in sqlparse.split(text): + if not statement.strip(): + continue + query = self.handle_watch_command(statement) + if query is not None and not query.successful: + ok = False + if self.on_error != "RESUME": + break + return ok def _build_cli(self, history): key_bindings = pgcli_bindings(self) diff --git a/tests/test_main.py b/tests/test_main.py index 78dbeeeb7..f6342e9cd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -440,6 +440,97 @@ def run_with_watch(query, target_call_count=1, expected_output="", expected_timi run_with_watch("\\watch 5", target_call_count=4, expected_output="222", expected_timing=5) +@dbtest +def test_execute_statements_splits_a_block(executor): + """A multi-statement block runs one statement at a time, like psql -f.""" + cli = PGCli(pgexecute=executor) + with mock.patch.object(cli, "echo_via_pager") as mock_echo: + ok = cli._execute_statements("select 111;\nselect 222;") + assert ok is True + outputs = [c[0][0] for c in mock_echo.call_args_list] + assert len(outputs) == 2 + assert "111" in outputs[0] and "222" not in outputs[0] + assert "222" in outputs[1] and "111" not in outputs[1] + + +@dbtest +def test_execute_statements_watch_repeats_only_its_own_statement(executor): + r"""Regression: \watch at the end of a file repeated the WHOLE file.""" + cli = PGCli(pgexecute=executor) + with mock.patch.object(cli, "echo_via_pager") as mock_echo, mock.patch("pgcli.main.sleep") as mock_sleep: + mock_sleep.side_effect = [None, KeyboardInterrupt] + cli._execute_statements("select 111;\nselect 222; \\watch 4") + outputs = [c[0][0] for c in mock_echo.call_args_list] + assert "111" in outputs[0] + for out in outputs[1:]: + assert "222" in out + assert "111" not in out, "\\watch repeated the whole block, not just its statement" + assert mock_sleep.call_args_list[0][0][0] == 4 + + +@dbtest +def test_execute_statements_bare_watch_uses_previous_statement(executor): + r"""A \watch alone on its line picks up the statement before it.""" + cli = PGCli(pgexecute=executor) + with mock.patch.object(cli, "echo_via_pager") as mock_echo, mock.patch("pgcli.main.sleep") as mock_sleep: + mock_sleep.side_effect = [KeyboardInterrupt] + cli._execute_statements("select 333;\n\\watch 5") + outputs = [c[0][0] for c in mock_echo.call_args_list] + assert len(outputs) >= 2 + for out in outputs: + assert "333" in out + assert mock_sleep.call_args_list[0][0][0] == 5 + + +@dbtest +def test_execute_statements_on_error_stop_halts(executor): + """With on_error = STOP (the default) the first failure stops the block.""" + cli = PGCli(pgexecute=executor) + assert cli.on_error == "STOP" + with mock.patch.object(cli, "echo_via_pager") as mock_echo: + ok = cli._execute_statements("select boom_not_a_column;\nselect 444;") + assert ok is False + outputs = [c[0][0] for c in mock_echo.call_args_list] + assert not any("444" in out for out in outputs), "the statement after the failure still ran" + + +@dbtest +def test_execute_statements_on_error_resume_continues(executor): + """With on_error = RESUME the block keeps going after a failure.""" + cli = PGCli(pgexecute=executor) + cli.on_error = "RESUME" + with mock.patch.object(cli, "echo_via_pager") as mock_echo: + ok = cli._execute_statements("select boom_not_a_column;\nselect 444;") + assert ok is False + outputs = [c[0][0] for c in mock_echo.call_args_list] + assert any("444" in out for out in outputs) + + +@dbtest +def test_execute_statements_does_not_split_inside_literals(executor): + """Semicolons inside string literals are not statement boundaries.""" + cli = PGCli(pgexecute=executor) + with mock.patch.object(cli, "echo_via_pager") as mock_echo: + ok = cli._execute_statements("select 'a;b' as x;") + assert ok is True + outputs = [c[0][0] for c in mock_echo.call_args_list] + assert len(outputs) == 1 + assert "a;b" in outputs[0] + + +def test_file_mode_runs_statements(tmpdir): + """-f wiring: the file content goes through _execute_statements.""" + sql_file = tmpdir.join("script.sql") + sql_file.write("select 1;\nselect 2;") + cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile"))) + cli.input_files = [str(sql_file)] + with mock.patch.object(cli, "_execute_statements", return_value=True) as mock_exec: + with pytest.raises(SystemExit) as e: + cli.run_cli() + assert e.value.code == 0 + mock_exec.assert_called_once_with("select 1;\nselect 2;") + + def test_missing_rc_dir(tmpdir): rcfile = str(tmpdir.join("subdir").join("rcfile")) From 6f3bb1d0b96f4cb928b21056feec24e670fbadb7 Mon Sep 17 00:00:00 2001 From: Diego Date: Mon, 31 Aug 2026 17:10:29 -0300 Subject: [PATCH 4/4] Cut backslash commands at their newline, like psql sqlparse.split only cuts at semicolons, so a metacommand followed by SQL on the next line traveled as one chunk and the metacommand swallowed the SQL. Interactively this never happens because the buffer submits as soon as it starts with a backslash; files now follow psql's rule: a backslash command spans only its own line, and the rest of the chunk goes back through the splitter. 3 tests; all fail without the fix. --- changelog.rst | 4 +++- pgcli/main.py | 17 +++++++++++++++-- tests/test_main.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/changelog.rst b/changelog.rst index e4a8b31ec..2fc633928 100644 --- a/changelog.rst +++ b/changelog.rst @@ -8,7 +8,9 @@ Features: * Multiple files can be specified. * Files run one statement at a time, like psql, so a ``\watch`` only repeats its own statement (and a bare ``\watch`` re-runs the statement - before it), instead of re-running the whole file. + before it), instead of re-running the whole file. A backslash command + spans only its own line, also like psql, so a metacommand followed by + SQL on the next line does not swallow the SQL. Bug fixes: ---------- diff --git a/pgcli/main.py b/pgcli/main.py index d037f26bf..e469347de 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -1159,13 +1159,26 @@ def _execute_statements(self, text): statement, and a bare \watch picks up the previous statement through query_history, exactly like psql. + A backslash command spans only its own line, like in psql, so a + metacommand followed by SQL on the next line does not swallow the + SQL (sqlparse only cuts at semicolons). + Honors on_error: with STOP, the first failed statement stops the run. Returns True when every statement succeeded. """ ok = True - for statement in sqlparse.split(text): - if not statement.strip(): + statements = sqlparse.split(text) + while statements: + statement = statements.pop(0) + stripped = statement.strip() + if not stripped: continue + if stripped.startswith("\\") and "\n" in stripped: + # psql's rule: a backslash command ends at its newline. Put + # the rest back through the splitter. + first_line, rest = stripped.split("\n", 1) + statements = sqlparse.split(rest) + statements + statement = first_line query = self.handle_watch_command(statement) if query is not None and not query.successful: ok = False diff --git a/tests/test_main.py b/tests/test_main.py index f6342e9cd..7b824ea37 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -507,6 +507,49 @@ def test_execute_statements_on_error_resume_continues(executor): @dbtest +@dbtest +def test_execute_statements_metacommand_spans_only_its_line(executor): + """psql cuts a backslash command at its newline: a metacommand followed + by SQL must not swallow the SQL (sqlparse only cuts at semicolons).""" + cli = PGCli(pgexecute=executor) + with mock.patch.object(cli, "echo_via_pager") as mock_echo: + ok = cli._execute_statements("\\echo hola\nselect 42 as x;") + assert ok is True + outputs = [c[0][0] for c in mock_echo.call_args_list] + # Two separate outputs: the echo, then a real result table. Without the + # line cut there is a single output where \echo swallowed the select and + # repeated its text, which is why the select text alone proves nothing. + assert len(outputs) == 2 + assert "hola" in outputs[0] + assert "42" in outputs[1] and "hola" not in outputs[1] + assert "SELECT 1" in outputs[1], "the select did not actually run" + + +@dbtest +def test_execute_statements_consecutive_metacommands(executor): + """Several backslash commands on consecutive lines each run on their own.""" + cli = PGCli(pgexecute=executor) + with mock.patch.object(cli, "echo_via_pager") as mock_echo: + ok = cli._execute_statements("\\echo uno\n\\echo dos\nselect 7 as x;") + assert ok is True + outputs = [c[0][0] for c in mock_echo.call_args_list] + assert any("uno" in out and "dos" not in out for out in outputs) + assert any("dos" in out and "uno" not in out for out in outputs) + assert any("7" in out for out in outputs) + + +@dbtest +def test_execute_statements_sql_then_metacommand(executor): + """A metacommand after SQL still runs alone, and the SQL after it too.""" + cli = PGCli(pgexecute=executor) + with mock.patch.object(cli, "echo_via_pager") as mock_echo: + ok = cli._execute_statements("select 1 as a;\n\\echo medio\nselect 2 as b;") + assert ok is True + outputs = [c[0][0] for c in mock_echo.call_args_list] + assert len(outputs) == 3 + assert "medio" in outputs[1] + + def test_execute_statements_does_not_split_inside_literals(executor): """Semicolons inside string literals are not statement boundaries.""" cli = PGCli(pgexecute=executor)